feat: 생산/품질/자재/출고/주문 관리 페이지 구현
- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면 - 품질관리: 검사관리 (리스트/등록/상세) - 자재관리: 입고관리, 재고현황 - 출고관리: 출하관리 (리스트/등록/상세/수정) - 주문관리: 수주관리, 생산의뢰 - 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration - IntegratedListTemplateV2 개선 - 공통 컴포넌트 분석 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { ReceivingDetail } from '@/components/material/ReceivingManagement';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ReceivingDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ReceivingDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InspectionCreate } from '@/components/material/ReceivingManagement';
|
||||
|
||||
export default function InspectionPage() {
|
||||
return <InspectionCreate />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReceivingList } from '@/components/material/ReceivingManagement';
|
||||
|
||||
export default function ReceivingManagementPage() {
|
||||
return <ReceivingList />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StockStatusDetail } from '@/components/material/StockStatus';
|
||||
|
||||
interface StockStatusDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function StockStatusDetailPage({ params }: StockStatusDetailPageProps) {
|
||||
const { id } = await params;
|
||||
return <StockStatusDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StockStatusList } from '@/components/material/StockStatus';
|
||||
|
||||
export default function StockStatusPage() {
|
||||
return <StockStatusList />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 출하관리 - 수정 페이지
|
||||
* URL: /outbound/shipments/[id]/edit
|
||||
*/
|
||||
|
||||
import { ShipmentEdit } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ShipmentEditPage({ params }: ShipmentEditPageProps) {
|
||||
const { id } = await params;
|
||||
return <ShipmentEdit id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 출하관리 - 상세 페이지
|
||||
* URL: /outbound/shipments/[id]
|
||||
*/
|
||||
|
||||
import { ShipmentDetail } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
|
||||
const { id } = await params;
|
||||
return <ShipmentDetail id={id} />;
|
||||
}
|
||||
10
src/app/[locale]/(protected)/outbound/shipments/new/page.tsx
Normal file
10
src/app/[locale]/(protected)/outbound/shipments/new/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 출하관리 - 등록 페이지
|
||||
* URL: /outbound/shipments/new
|
||||
*/
|
||||
|
||||
import { ShipmentCreate } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function NewShipmentPage() {
|
||||
return <ShipmentCreate />;
|
||||
}
|
||||
10
src/app/[locale]/(protected)/outbound/shipments/page.tsx
Normal file
10
src/app/[locale]/(protected)/outbound/shipments/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 출하관리 - 목록 페이지
|
||||
* URL: /outbound/shipments
|
||||
*/
|
||||
|
||||
import { ShipmentList } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function ShipmentsPage() {
|
||||
return <ShipmentList />;
|
||||
}
|
||||
21
src/app/[locale]/(protected)/production/dashboard/page.tsx
Normal file
21
src/app/[locale]/(protected)/production/dashboard/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 생산 현황판 페이지
|
||||
*
|
||||
* 경로: /[locale]/(protected)/production/dashboard
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import ProductionDashboard from '@/components/production/ProductionDashboard';
|
||||
|
||||
export default function ProductionDashboardPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<ProductionDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: '생산 현황판',
|
||||
description: '공장별 작업 현황을 확인합니다.',
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 작업지시 상세 페이지
|
||||
* URL: /production/work-orders/[id]
|
||||
*/
|
||||
|
||||
import { WorkOrderDetail } from '@/components/production/WorkOrders';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function WorkOrderDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <WorkOrderDetail orderId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 작업지시 등록 페이지
|
||||
* URL: /production/work-orders/create
|
||||
*/
|
||||
|
||||
import { WorkOrderCreate } from '@/components/production/WorkOrders';
|
||||
|
||||
export default function WorkOrderCreatePage() {
|
||||
return <WorkOrderCreate />;
|
||||
}
|
||||
10
src/app/[locale]/(protected)/production/work-orders/page.tsx
Normal file
10
src/app/[locale]/(protected)/production/work-orders/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 작업지시 목록 페이지
|
||||
* URL: /production/work-orders
|
||||
*/
|
||||
|
||||
import { WorkOrderList } from '@/components/production/WorkOrders';
|
||||
|
||||
export default function WorkOrdersPage() {
|
||||
return <WorkOrderList />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 작업실적 조회 페이지
|
||||
* URL: /production/work-results
|
||||
*/
|
||||
|
||||
import { WorkResultList } from '@/components/production/WorkResults';
|
||||
|
||||
export default function WorkResultsPage() {
|
||||
return <WorkResultList />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 작업자 화면 페이지
|
||||
*
|
||||
* 경로: /[locale]/(protected)/production/worker-screen
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import WorkerScreen from '@/components/production/WorkerScreen';
|
||||
|
||||
export default function WorkerScreenPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<WorkerScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: '작업자 화면',
|
||||
description: '내 작업 목록을 확인하고 관리합니다.',
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 검사 상세/수정 페이지
|
||||
* URL: /quality/inspections/[id]
|
||||
* 수정 모드: /quality/inspections/[id]?mode=edit
|
||||
*/
|
||||
|
||||
import { InspectionDetail } from '@/components/quality/InspectionManagement';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function InspectionDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <InspectionDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 검사 등록 페이지
|
||||
* URL: /quality/inspections/new
|
||||
*/
|
||||
|
||||
import { InspectionCreate } from '@/components/quality/InspectionManagement';
|
||||
|
||||
export default function InspectionNewPage() {
|
||||
return <InspectionCreate />;
|
||||
}
|
||||
10
src/app/[locale]/(protected)/quality/inspections/page.tsx
Normal file
10
src/app/[locale]/(protected)/quality/inspections/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 검사 목록 페이지
|
||||
* URL: /quality/inspections
|
||||
*/
|
||||
|
||||
import { InspectionList } from '@/components/quality/InspectionManagement';
|
||||
|
||||
export default function InspectionsPage() {
|
||||
return <InspectionList />;
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주 수정 페이지
|
||||
*
|
||||
* - 기본 정보 (읽기전용)
|
||||
* - 수주/배송 정보 (편집 가능)
|
||||
* - 비고 (편집 가능)
|
||||
* - 품목 내역 (생산 시작 후 수정 불가)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { FileText, ArrowLeft, Info, AlertTriangle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { FormSection } from "@/components/templates/ResponsiveFormTemplate";
|
||||
import { FormActions } from "@/components/organisms/FormActions";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { OrderItem } from "@/components/orders";
|
||||
|
||||
// 수주 상태 타입
|
||||
type OrderStatus =
|
||||
| "order_registered"
|
||||
| "order_confirmed"
|
||||
| "production_ordered"
|
||||
| "in_production"
|
||||
| "rework"
|
||||
| "work_completed"
|
||||
| "shipped"
|
||||
| "cancelled";
|
||||
|
||||
// 수정 폼 데이터
|
||||
interface EditFormData {
|
||||
// 읽기전용 정보
|
||||
lotNumber: string;
|
||||
quoteNumber: string;
|
||||
client: string;
|
||||
siteName: string;
|
||||
manager: string;
|
||||
contact: string;
|
||||
status: OrderStatus;
|
||||
|
||||
// 수정 가능 정보
|
||||
expectedShipDate: string;
|
||||
expectedShipDateUndecided: boolean;
|
||||
deliveryRequestDate: string;
|
||||
deliveryMethod: string;
|
||||
shippingCost: string;
|
||||
receiver: string;
|
||||
receiverContact: string;
|
||||
address: string;
|
||||
addressDetail: string;
|
||||
remarks: string;
|
||||
|
||||
// 품목 (수정 제한)
|
||||
items: OrderItem[];
|
||||
canEditItems: boolean;
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 배송방식 옵션
|
||||
const DELIVERY_METHODS = [
|
||||
{ value: "direct", label: "직접배차" },
|
||||
{ value: "pickup", label: "상차" },
|
||||
{ value: "courier", label: "택배" },
|
||||
];
|
||||
|
||||
// 운임비용 옵션
|
||||
const SHIPPING_COSTS = [
|
||||
{ value: "free", label: "무료" },
|
||||
{ value: "prepaid", label: "선불" },
|
||||
{ value: "collect", label: "착불" },
|
||||
{ value: "negotiable", label: "협의" },
|
||||
];
|
||||
|
||||
// 샘플 데이터
|
||||
const SAMPLE_ORDER: EditFormData = {
|
||||
lotNumber: "KD-TS-251217-01",
|
||||
quoteNumber: "KD-PR-251210-01",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
manager: "김철수",
|
||||
contact: "010-1234-5678",
|
||||
status: "order_confirmed",
|
||||
expectedShipDate: "2025-01-15",
|
||||
expectedShipDateUndecided: false,
|
||||
deliveryRequestDate: "2025-01-20",
|
||||
deliveryMethod: "direct",
|
||||
shippingCost: "free",
|
||||
receiver: "박반장",
|
||||
receiverContact: "010-9876-5432",
|
||||
address: "경기도 화성시 동탄대로 123-45",
|
||||
addressDetail: "데시앙 동탄 파크뷰 현장",
|
||||
remarks: "4층 우선 납품 요청",
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
itemCode: "PRD-001",
|
||||
itemName: "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: "FSS1",
|
||||
spec: "7260×2600",
|
||||
width: 7260,
|
||||
height: 2600,
|
||||
quantity: 2,
|
||||
unit: "EA",
|
||||
unitPrice: 8000000,
|
||||
amount: 16000000,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
itemCode: "PRD-002",
|
||||
itemName: "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: "FSS2",
|
||||
spec: "5000×2400",
|
||||
width: 5000,
|
||||
height: 2400,
|
||||
quantity: 3,
|
||||
unit: "EA",
|
||||
unitPrice: 7600000,
|
||||
amount: 22800000,
|
||||
},
|
||||
],
|
||||
canEditItems: true,
|
||||
subtotal: 38800000,
|
||||
discountRate: 0,
|
||||
totalAmount: 38800000,
|
||||
};
|
||||
|
||||
// 상태 뱃지 헬퍼
|
||||
function getOrderStatusBadge(status: OrderStatus) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
||||
order_registered: { label: "수주등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
</BadgeSm>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [form, setForm] = useState<EditFormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
// 상태에 따라 품목 수정 가능 여부 결정
|
||||
const canEditItems = !["in_production", "rework", "work_completed", "shipped"].includes(
|
||||
SAMPLE_ORDER.status
|
||||
);
|
||||
setForm({ ...SAMPLE_ORDER, canEditItems });
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
|
||||
// 유효성 검사
|
||||
if (!form.deliveryRequestDate) {
|
||||
toast.error("납품요청일을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!form.receiver.trim()) {
|
||||
toast.error("수신(반장/업체)을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!form.receiverContact.trim()) {
|
||||
toast.error("수신처 연락처를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: API 연동
|
||||
console.log("수주 수정 데이터:", form);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
toast.success("수주가 수정되었습니다.");
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !form) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title="수주 수정"
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{form.lotNumber}
|
||||
</code>
|
||||
{getOrderStatusBadge(form.status)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 (읽기전용) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
기본 정보
|
||||
<span className="text-sm font-normal text-muted-foreground">(읽기전용)</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">로트번호</Label>
|
||||
<p className="font-medium">{form.lotNumber}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">견적번호</Label>
|
||||
<p className="font-medium">{form.quoteNumber}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">담당자</Label>
|
||||
<p className="font-medium">{form.manager}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">발주처</Label>
|
||||
<p className="font-medium">{form.client}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">현장명</Label>
|
||||
<p className="font-medium">{form.siteName}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">연락처</Label>
|
||||
<p className="font-medium">{form.contact}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수주/배송 정보 (편집 가능) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 출고예정일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.expectedShipDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, expectedShipDate: e.target.value })
|
||||
}
|
||||
disabled={form.expectedShipDateUndecided}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="expectedShipDateUndecided"
|
||||
checked={form.expectedShipDateUndecided}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm({
|
||||
...form,
|
||||
expectedShipDateUndecided: checked as boolean,
|
||||
expectedShipDate: checked ? "" : form.expectedShipDate,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="expectedShipDateUndecided"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
미정
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 납품요청일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
납품요청일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.deliveryRequestDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, deliveryRequestDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배송방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식</Label>
|
||||
<Select
|
||||
value={form.deliveryMethod}
|
||||
onValueChange={(value) =>
|
||||
setForm({ ...form, deliveryMethod: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_METHODS.map((method) => (
|
||||
<SelectItem key={method.value} value={method.value}>
|
||||
{method.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 운임비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
value={form.shippingCost}
|
||||
onValueChange={(value) =>
|
||||
setForm({ ...form, shippingCost: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="운임비용 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIPPING_COSTS.map((cost) => (
|
||||
<SelectItem key={cost.value} value={cost.value}>
|
||||
{cost.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 수신(반장/업체) */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
수신(반장/업체) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={form.receiver}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, receiver: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수신처 연락처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
수신처 연락처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={form.receiverContact}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, receiverContact: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수신처 주소 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>수신처 주소</Label>
|
||||
<Input
|
||||
value={form.address}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, address: e.target.value })
|
||||
}
|
||||
placeholder="주소"
|
||||
className="mb-2"
|
||||
/>
|
||||
<Input
|
||||
value={form.addressDetail}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, addressDetail: e.target.value })
|
||||
}
|
||||
placeholder="상세주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 비고 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={form.remarks}
|
||||
onChange={(e) => setForm({ ...form, remarks: e.target.value })}
|
||||
placeholder="특이사항을 입력하세요"
|
||||
rows={4}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
품목 내역
|
||||
{!form.canEditItems && (
|
||||
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
생산 시작 후 수정 불가
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">No</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>종</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead>규격(mm)</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{form.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(form.subtotal)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">할인율:</span>
|
||||
<span className="w-32 text-right">{form.discountRate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||
<span>총금액:</span>
|
||||
<span className="w-32 text-right text-green-600">
|
||||
{formatAmount(form.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t pt-4 pb-4 -mx-3 md:-mx-6 px-3 md:px-6 mt-6">
|
||||
<FormActions
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
saveLabel="저장"
|
||||
cancelLabel="취소"
|
||||
saveLoading={isSaving}
|
||||
saveDisabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주 상세 페이지
|
||||
*
|
||||
* - 문서 모달: 계약서, 거래명세서, 발주서
|
||||
* - 기본 정보, 수주/배송 정보, 비고
|
||||
* - 제품 내역 테이블
|
||||
* - 상태별 버튼 차이
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Factory,
|
||||
XCircle,
|
||||
FileSpreadsheet,
|
||||
FileCheck,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
OrderItem,
|
||||
OrderDocumentModal,
|
||||
type OrderDocumentType,
|
||||
} from "@/components/orders";
|
||||
|
||||
// 수주 상태 타입
|
||||
type OrderStatus =
|
||||
| "order_registered"
|
||||
| "order_confirmed"
|
||||
| "production_ordered"
|
||||
| "in_production"
|
||||
| "rework"
|
||||
| "work_completed"
|
||||
| "shipped"
|
||||
| "cancelled";
|
||||
|
||||
// 수주 상세 데이터 타입
|
||||
interface OrderDetail {
|
||||
id: string;
|
||||
lotNumber: string;
|
||||
quoteNumber: string;
|
||||
orderDate: string;
|
||||
status: OrderStatus;
|
||||
client: string;
|
||||
siteName: string;
|
||||
manager: string;
|
||||
contact: string;
|
||||
expectedShipDate: string;
|
||||
deliveryRequestDate: string;
|
||||
deliveryMethod: string;
|
||||
shippingCost: string;
|
||||
receiver: string;
|
||||
receiverContact: string;
|
||||
address: string;
|
||||
remarks: string;
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 샘플 품목 데이터
|
||||
const SAMPLE_ITEMS: OrderItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
itemCode: "PRD-001",
|
||||
itemName: "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: "FSS1",
|
||||
spec: "7260×2600",
|
||||
width: 7260,
|
||||
height: 2600,
|
||||
quantity: 2,
|
||||
unit: "EA",
|
||||
unitPrice: 8000000,
|
||||
amount: 16000000,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
itemCode: "PRD-002",
|
||||
itemName: "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: "FSS2",
|
||||
spec: "5000×2400",
|
||||
width: 5000,
|
||||
height: 2400,
|
||||
quantity: 3,
|
||||
unit: "EA",
|
||||
unitPrice: 7600000,
|
||||
amount: 22800000,
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 수주 데이터 (리스트 페이지와 동기화)
|
||||
const SAMPLE_ORDERS: Record<string, OrderDetail> = {
|
||||
"ORD-001": {
|
||||
id: "ORD-001",
|
||||
lotNumber: "KD-TS-251217-01",
|
||||
quoteNumber: "KD-PR-251210-01",
|
||||
orderDate: "2024-12-17",
|
||||
status: "order_confirmed",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
manager: "김철수",
|
||||
contact: "010-1234-5678",
|
||||
expectedShipDate: "2025-01-15",
|
||||
deliveryRequestDate: "2025-01-20",
|
||||
deliveryMethod: "직접배차",
|
||||
shippingCost: "무료",
|
||||
receiver: "박반장",
|
||||
receiverContact: "010-9876-5432",
|
||||
address: "경기도 화성시 동탄대로 123-45 데시앙 동탄 파크뷰 현장",
|
||||
remarks: "4층 우선 납품 요청",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 38800000,
|
||||
discountRate: 0,
|
||||
totalAmount: 38800000,
|
||||
},
|
||||
"ORD-002": {
|
||||
id: "ORD-002",
|
||||
lotNumber: "KD-TS-251217-02",
|
||||
quoteNumber: "KD-PR-251211-02",
|
||||
orderDate: "2024-12-17",
|
||||
status: "in_production",
|
||||
client: "현대건설(주)",
|
||||
siteName: "힐스테이트 판교역",
|
||||
manager: "이영희",
|
||||
contact: "010-2345-6789",
|
||||
expectedShipDate: "2025-01-20",
|
||||
deliveryRequestDate: "2025-01-25",
|
||||
deliveryMethod: "상차",
|
||||
shippingCost: "선불",
|
||||
receiver: "김반장",
|
||||
receiverContact: "010-8765-4321",
|
||||
address: "경기도 성남시 분당구 판교역로 123",
|
||||
remarks: "지하 1층 납품",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 52500000,
|
||||
discountRate: 0,
|
||||
totalAmount: 52500000,
|
||||
},
|
||||
"ORD-003": {
|
||||
id: "ORD-003",
|
||||
lotNumber: "KD-TS-251216-01",
|
||||
quoteNumber: "KD-PR-251208-03",
|
||||
orderDate: "2024-12-16",
|
||||
status: "production_ordered",
|
||||
client: "GS건설(주)",
|
||||
siteName: "자이 강남센터",
|
||||
manager: "박민수",
|
||||
contact: "010-3456-7890",
|
||||
expectedShipDate: "2025-01-10",
|
||||
deliveryRequestDate: "2025-01-15",
|
||||
deliveryMethod: "직접배차",
|
||||
shippingCost: "무료",
|
||||
receiver: "최반장",
|
||||
receiverContact: "010-7654-3210",
|
||||
address: "서울시 강남구 테헤란로 234",
|
||||
remarks: "",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 45000000,
|
||||
discountRate: 0,
|
||||
totalAmount: 45000000,
|
||||
},
|
||||
"ORD-004": {
|
||||
id: "ORD-004",
|
||||
lotNumber: "KD-TS-251215-01",
|
||||
quoteNumber: "KD-PR-251205-04",
|
||||
orderDate: "2024-12-15",
|
||||
status: "shipped",
|
||||
client: "대우건설(주)",
|
||||
siteName: "푸르지오 송도",
|
||||
manager: "정수진",
|
||||
contact: "010-4567-8901",
|
||||
expectedShipDate: "2024-12-20",
|
||||
deliveryRequestDate: "2024-12-22",
|
||||
deliveryMethod: "상차",
|
||||
shippingCost: "착불",
|
||||
receiver: "오반장",
|
||||
receiverContact: "010-6543-2109",
|
||||
address: "인천시 연수구 송도동 456",
|
||||
remarks: "출하 완료",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 28900000,
|
||||
discountRate: 0,
|
||||
totalAmount: 28900000,
|
||||
},
|
||||
"ORD-005": {
|
||||
id: "ORD-005",
|
||||
lotNumber: "KD-TS-251214-01",
|
||||
quoteNumber: "KD-PR-251201-05",
|
||||
orderDate: "2024-12-14",
|
||||
status: "rework",
|
||||
client: "포스코건설",
|
||||
siteName: "더샵 분당센트럴",
|
||||
manager: "강호동",
|
||||
contact: "010-5678-9012",
|
||||
expectedShipDate: "2025-01-25",
|
||||
deliveryRequestDate: "2025-01-30",
|
||||
deliveryMethod: "직접배차",
|
||||
shippingCost: "무료",
|
||||
receiver: "유반장",
|
||||
receiverContact: "010-5432-1098",
|
||||
address: "경기도 성남시 분당구 정자동 789",
|
||||
remarks: "재작업 진행 중",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 62000000,
|
||||
discountRate: 0,
|
||||
totalAmount: 62000000,
|
||||
},
|
||||
"ORD-006": {
|
||||
id: "ORD-006",
|
||||
lotNumber: "KD-TS-251213-01",
|
||||
quoteNumber: "KD-PR-251128-06",
|
||||
orderDate: "2024-12-13",
|
||||
status: "work_completed",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "캐슬 잠실파크",
|
||||
manager: "신동엽",
|
||||
contact: "010-6789-0123",
|
||||
expectedShipDate: "2024-12-25",
|
||||
deliveryRequestDate: "2024-12-28",
|
||||
deliveryMethod: "직접배차",
|
||||
shippingCost: "무료",
|
||||
receiver: "한반장",
|
||||
receiverContact: "010-4321-0987",
|
||||
address: "서울시 송파구 잠실동 321",
|
||||
remarks: "작업 완료, 출하 대기",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 35500000,
|
||||
discountRate: 0,
|
||||
totalAmount: 35500000,
|
||||
},
|
||||
"ORD-007": {
|
||||
id: "ORD-007",
|
||||
lotNumber: "KD-TS-251212-01",
|
||||
quoteNumber: "KD-PR-251125-07",
|
||||
orderDate: "2024-12-12",
|
||||
status: "order_registered",
|
||||
client: "삼성물산(주)",
|
||||
siteName: "래미안 서초",
|
||||
manager: "유재석",
|
||||
contact: "010-7890-1234",
|
||||
expectedShipDate: "",
|
||||
deliveryRequestDate: "2025-02-01",
|
||||
deliveryMethod: "",
|
||||
shippingCost: "",
|
||||
receiver: "이반장",
|
||||
receiverContact: "010-3210-9876",
|
||||
address: "서울시 서초구 서초동 654",
|
||||
remarks: "수주 등록 상태",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 48000000,
|
||||
discountRate: 0,
|
||||
totalAmount: 48000000,
|
||||
},
|
||||
"ORD-008": {
|
||||
id: "ORD-008",
|
||||
lotNumber: "KD-TS-251211-01",
|
||||
quoteNumber: "KD-PR-251120-08",
|
||||
orderDate: "2024-12-11",
|
||||
status: "shipped",
|
||||
client: "SK에코플랜트",
|
||||
siteName: "SK VIEW 일산",
|
||||
manager: "하하",
|
||||
contact: "010-8901-2345",
|
||||
expectedShipDate: "2024-12-18",
|
||||
deliveryRequestDate: "2024-12-20",
|
||||
deliveryMethod: "상차",
|
||||
shippingCost: "선불",
|
||||
receiver: "조반장",
|
||||
receiverContact: "010-2109-8765",
|
||||
address: "경기도 고양시 일산서구 주엽동 987",
|
||||
remarks: "출하 완료, 미수금 있음",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 31200000,
|
||||
discountRate: 0,
|
||||
totalAmount: 31200000,
|
||||
},
|
||||
};
|
||||
|
||||
// 상태 뱃지 헬퍼
|
||||
function getOrderStatusBadge(status: OrderStatus) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
||||
order_registered: { label: "수주등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
</BadgeSm>
|
||||
);
|
||||
}
|
||||
|
||||
// 정보 표시 컴포넌트
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="font-medium">{value || "-"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
|
||||
// 취소 폼 상태
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [cancelDetail, setCancelDetail] = useState("");
|
||||
|
||||
// 문서 모달 상태
|
||||
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
||||
const [documentType, setDocumentType] = useState<OrderDocumentType>("contract");
|
||||
|
||||
// 데이터 로드 (샘플 - ID로 매칭)
|
||||
useEffect(() => {
|
||||
// 실제 구현에서는 API 호출
|
||||
setTimeout(() => {
|
||||
const foundOrder = SAMPLE_ORDERS[orderId];
|
||||
setOrder(foundOrder || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}/edit`);
|
||||
};
|
||||
|
||||
const handleProductionOrder = () => {
|
||||
// 생산지시 생성 페이지로 이동
|
||||
router.push(`/sales/order-management-sales/${orderId}/production-order`);
|
||||
};
|
||||
|
||||
const handleViewProductionOrder = () => {
|
||||
// 생산지시 목록 페이지로 이동 (수주관리 내부)
|
||||
router.push(`/sales/order-management-sales/production-orders`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
setIsCancelDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancel = () => {
|
||||
if (!cancelReason) {
|
||||
toast.error("취소 사유를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (order) {
|
||||
setOrder({ ...order, status: "cancelled" });
|
||||
toast.success("수주가 취소되었습니다.");
|
||||
}
|
||||
setIsCancelDialogOpen(false);
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
};
|
||||
|
||||
// 문서 모달 열기
|
||||
const openDocumentModal = (type: OrderDocumentType) => {
|
||||
setDocumentType(type);
|
||||
setDocumentModalOpen(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">수주 정보를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleBack} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태별 버튼 표시 여부
|
||||
const showEditButton = order.status !== "shipped" && order.status !== "cancelled";
|
||||
// 생산지시 생성 버튼: 출하완료, 취소, 생산지시완료 제외하고 표시
|
||||
// (수주등록, 수주확정, 생산중, 재작업중, 작업완료에서 표시)
|
||||
const showProductionCreateButton =
|
||||
order.status !== "shipped" &&
|
||||
order.status !== "cancelled" &&
|
||||
order.status !== "production_ordered";
|
||||
// 생산지시 보기 버튼: 생산지시완료 상태에서 숨김 (기획서 오류로 제거)
|
||||
const showProductionViewButton = false;
|
||||
const showCancelButton =
|
||||
order.status !== "shipped" &&
|
||||
order.status !== "cancelled" &&
|
||||
order.status !== "production_ordered";
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title="수주 상세"
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{showEditButton && (
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{showProductionCreateButton && (
|
||||
<Button onClick={handleProductionOrder}>
|
||||
<Factory className="h-4 w-4 mr-2" />
|
||||
생산지시 생성
|
||||
</Button>
|
||||
)}
|
||||
{showProductionViewButton && (
|
||||
<Button onClick={handleViewProductionOrder}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
생산지시 보기
|
||||
</Button>
|
||||
)}
|
||||
{showCancelButton && (
|
||||
<Button variant="outline" onClick={handleCancel} className="border-orange-200 text-orange-600 hover:border-orange-300">
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 수주 정보 헤더 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{order.lotNumber}
|
||||
</code>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
수주일: {order.orderDate}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{/* 문서 버튼들 */}
|
||||
<div className="flex items-center gap-2 pt-4 border-t">
|
||||
<span className="text-sm text-muted-foreground mr-2">문서:</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("contract")}
|
||||
>
|
||||
<FileCheck className="h-4 w-4 mr-1" />
|
||||
계약서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("transaction")}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1" />
|
||||
거래명세서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("purchaseOrder")}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4 mr-1" />
|
||||
발주서
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<InfoItem label="발주처" value={order.client} />
|
||||
<InfoItem label="현장명" value={order.siteName} />
|
||||
<InfoItem label="담당자" value={order.manager} />
|
||||
<InfoItem label="연락처" value={order.contact} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수주/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<InfoItem label="수주일자" value={order.orderDate} />
|
||||
<InfoItem label="출고예정일" value={order.expectedShipDate || "미정"} />
|
||||
<InfoItem label="납품요청일" value={order.deliveryRequestDate} />
|
||||
<InfoItem label="배송방식" value={order.deliveryMethod} />
|
||||
<InfoItem label="운임비용" value={order.shippingCost} />
|
||||
<InfoItem label="수신(반장/업체)" value={order.receiver} />
|
||||
<InfoItem label="수신처 연락처" value={order.receiverContact} />
|
||||
<InfoItem label="수신처 주소" value={order.address} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 비고 */}
|
||||
{order.remarks && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap">{order.remarks}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 제품 내역 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">제품 내역</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>층</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{order.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(order.subtotal)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">할인율:</span>
|
||||
<span className="w-32 text-right">{order.discountRate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||
<span>총금액:</span>
|
||||
<span className="w-32 text-right text-green-600">
|
||||
{formatAmount(order.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 문서 모달 */}
|
||||
<OrderDocumentModal
|
||||
open={documentModalOpen}
|
||||
onOpenChange={setDocumentModalOpen}
|
||||
documentType={documentType}
|
||||
data={{
|
||||
lotNumber: order.lotNumber,
|
||||
orderDate: order.orderDate,
|
||||
client: order.client,
|
||||
siteName: order.siteName,
|
||||
manager: order.manager,
|
||||
managerContact: order.contact,
|
||||
deliveryRequestDate: order.deliveryRequestDate,
|
||||
expectedShipDate: order.expectedShipDate,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
address: order.address,
|
||||
items: order.items,
|
||||
subtotal: order.subtotal,
|
||||
discountRate: order.discountRate,
|
||||
totalAmount: order.totalAmount,
|
||||
remarks: order.remarks,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 취소 확인 다이얼로그 */}
|
||||
<Dialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<XCircle className="h-5 w-5" />
|
||||
수주 취소
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 수주 정보 박스 */}
|
||||
<div className="border rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">수주번호</span>
|
||||
<span className="font-medium">{order.lotNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">발주처</span>
|
||||
<span>{order.client}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">현장명</span>
|
||||
<span>{order.siteName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">현재 상태</span>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 취소 사유 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cancelReason">
|
||||
취소 사유 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={cancelReason} onValueChange={setCancelReason}>
|
||||
<SelectTrigger id="cancelReason">
|
||||
<SelectValue placeholder="취소 사유를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="customer_request">고객 요청</SelectItem>
|
||||
<SelectItem value="spec_change">사양 변경</SelectItem>
|
||||
<SelectItem value="price_issue">가격 문제</SelectItem>
|
||||
<SelectItem value="delivery_issue">납기 문제</SelectItem>
|
||||
<SelectItem value="duplicate_order">중복 수주</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상세 사유 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cancelDetail">상세 사유</Label>
|
||||
<Textarea
|
||||
id="cancelDetail"
|
||||
placeholder="취소 사유에 대한 상세 내용을 입력하세요"
|
||||
value={cancelDetail}
|
||||
onChange={(e) => setCancelDetail(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 취소 시 유의사항 */}
|
||||
<div className="bg-gray-50 border rounded-lg p-4 text-sm space-y-1">
|
||||
<p className="font-medium mb-2">취소 시 유의사항</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li>• 취소된 수주는 목록에서 '취소' 상태로 표시됩니다</li>
|
||||
<li>• 취소 후에는 수정이 불가능합니다</li>
|
||||
<li>• 관련된 생산지시가 있는 경우 먼저 생산지시를 취소해야 합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCancelDialogOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConfirmCancel}
|
||||
className="border-gray-300"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
취소 확정
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산지시 생성 페이지
|
||||
*
|
||||
* - 수주 정보 (읽기전용)
|
||||
* - 생산지시 옵션 (우선순위 탭, 메모)
|
||||
* - 생성될 작업지시 (카드 형태)
|
||||
* - 자재 소요량 및 재고 현황
|
||||
* - 스크린 품목 상세
|
||||
* - 모터/전장품 사양 (읽기전용)
|
||||
* - 절곡물 BOM
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Factory, ArrowLeft, BarChart3, CheckCircle2 } from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 수주 정보 타입
|
||||
interface OrderInfo {
|
||||
orderNumber: string;
|
||||
client: string;
|
||||
siteName: string;
|
||||
dueDate: string;
|
||||
itemCount: number;
|
||||
totalQuantity: string;
|
||||
creditGrade: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// 우선순위 설정 타입
|
||||
type PriorityLevel = "urgent" | "high" | "normal" | "low";
|
||||
|
||||
interface PriorityConfig {
|
||||
key: PriorityLevel;
|
||||
label: string;
|
||||
productionOrder: string;
|
||||
workOrderPriority: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// 작업지시 카드 타입
|
||||
interface WorkOrderCard {
|
||||
id: string;
|
||||
type: string;
|
||||
orderNumber: string;
|
||||
itemCount: number;
|
||||
totalQuantity: string;
|
||||
processes: string[];
|
||||
}
|
||||
|
||||
// 자재 소요량 타입
|
||||
interface MaterialRequirement {
|
||||
materialCode: string;
|
||||
materialName: string;
|
||||
unit: string;
|
||||
required: number;
|
||||
currentStock: number;
|
||||
status: "sufficient" | "insufficient";
|
||||
}
|
||||
|
||||
// 스크린 품목 상세 타입
|
||||
interface ScreenItemDetail {
|
||||
no: number;
|
||||
itemName: string;
|
||||
location: string;
|
||||
openWidth: number;
|
||||
openHeight: number;
|
||||
productWidth: number;
|
||||
productHeight: number;
|
||||
guideRail: string;
|
||||
shaft: string;
|
||||
capacity: string;
|
||||
finish: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 가이드레일 BOM 타입
|
||||
interface GuideRailBom {
|
||||
type: string;
|
||||
spec: string;
|
||||
code: string;
|
||||
length: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 케이스 BOM 타입
|
||||
interface CaseBom {
|
||||
item: string;
|
||||
length: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 하단 마감재 BOM 타입
|
||||
interface BottomFinishBom {
|
||||
item: string;
|
||||
spec: string;
|
||||
length: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 우선순위별 색상 설정
|
||||
const PRIORITY_COLORS: Record<PriorityLevel, { bg: string; text: string; border: string; button: string; buttonActive: string }> = {
|
||||
urgent: {
|
||||
bg: "bg-red-50",
|
||||
text: "text-red-700",
|
||||
border: "border-red-200",
|
||||
button: "bg-red-100 text-red-700 hover:bg-red-200",
|
||||
buttonActive: "bg-red-600 text-white",
|
||||
},
|
||||
high: {
|
||||
bg: "bg-orange-50",
|
||||
text: "text-orange-700",
|
||||
border: "border-orange-200",
|
||||
button: "bg-orange-100 text-orange-700 hover:bg-orange-200",
|
||||
buttonActive: "bg-orange-500 text-white",
|
||||
},
|
||||
normal: {
|
||||
bg: "bg-blue-50",
|
||||
text: "text-blue-700",
|
||||
border: "border-blue-200",
|
||||
button: "bg-blue-100 text-blue-700 hover:bg-blue-200",
|
||||
buttonActive: "bg-blue-600 text-white",
|
||||
},
|
||||
low: {
|
||||
bg: "bg-gray-50",
|
||||
text: "text-gray-700",
|
||||
border: "border-gray-200",
|
||||
button: "bg-gray-100 text-gray-700 hover:bg-gray-200",
|
||||
buttonActive: "bg-gray-600 text-white",
|
||||
},
|
||||
};
|
||||
|
||||
// 우선순위 설정 데이터
|
||||
const PRIORITY_CONFIGS: PriorityConfig[] = [
|
||||
{
|
||||
key: "urgent",
|
||||
label: "긴급",
|
||||
productionOrder: "긴급",
|
||||
workOrderPriority: "1순위",
|
||||
note: "무조건 제일 먼저",
|
||||
},
|
||||
{
|
||||
key: "high",
|
||||
label: "높음",
|
||||
productionOrder: "높음",
|
||||
workOrderPriority: "3순위",
|
||||
note: "(2순위는 현장에서 '새치기' 할 때 쓰도록 비워둠)",
|
||||
},
|
||||
{
|
||||
key: "normal",
|
||||
label: "일반",
|
||||
productionOrder: "일반",
|
||||
workOrderPriority: "5순위",
|
||||
note: "(4순위 비워둠)",
|
||||
},
|
||||
{
|
||||
key: "low",
|
||||
label: "낮음",
|
||||
productionOrder: "낮음",
|
||||
workOrderPriority: "9순위",
|
||||
note: "뒤로 쪽 밀어둠",
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 수주 정보
|
||||
const SAMPLE_ORDER_INFO: OrderInfo = {
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
dueDate: "2026-02-25",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
creditGrade: "A (우량)",
|
||||
status: "재작업중",
|
||||
};
|
||||
|
||||
// 샘플 작업지시 카드
|
||||
const SAMPLE_WORK_ORDER_CARDS: WorkOrderCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
type: "스크린",
|
||||
orderNumber: "KD-PL-251223-01",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
processes: ["1. 원단절단", "2. 미싱", "3. 앤드락작업", "4. 중간검사", "5. 포장"],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "절곡",
|
||||
orderNumber: "KD-PL-251223-02",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
processes: ["1. 절단", "2. 절곡", "3. 중간검사", "4. 포장"],
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 자재 소요량
|
||||
const SAMPLE_MATERIALS: MaterialRequirement[] = [
|
||||
{
|
||||
materialCode: "SCR-MAT-001",
|
||||
materialName: "스크린 원단",
|
||||
unit: "㎡",
|
||||
required: 45,
|
||||
currentStock: 500,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "SCR-MAT-002",
|
||||
materialName: "앤드락",
|
||||
unit: "EA",
|
||||
required: 6,
|
||||
currentStock: 800,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-001",
|
||||
materialName: "철판",
|
||||
unit: "KG",
|
||||
required: 90,
|
||||
currentStock: 2000,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-002",
|
||||
materialName: "가이드레일",
|
||||
unit: "M",
|
||||
required: 18,
|
||||
currentStock: 300,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-003",
|
||||
materialName: "케이스",
|
||||
unit: "EA",
|
||||
required: 3,
|
||||
currentStock: 100,
|
||||
status: "sufficient",
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 스크린 품목 상세
|
||||
const SAMPLE_SCREEN_ITEMS: ScreenItemDetail[] = [
|
||||
{
|
||||
no: 1,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "로비 I-01",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "카페 I-02",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "헬스장 I-03",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 가이드레일 BOM
|
||||
const SAMPLE_GUIDE_RAIL_BOM: GuideRailBom[] = [
|
||||
{
|
||||
type: "백면형",
|
||||
spec: "120-70",
|
||||
code: "KSE01/KWE01",
|
||||
length: 3000,
|
||||
quantity: 6,
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 케이스(셔터박스) BOM
|
||||
const SAMPLE_CASE_BOM: CaseBom[] = [
|
||||
{ item: "케이스 본체", length: "L: 4000", quantity: 2 },
|
||||
{ item: "측면 덮개", length: "500-355", quantity: 6 },
|
||||
];
|
||||
|
||||
// 샘플 하단 마감재 BOM
|
||||
const SAMPLE_BOTTOM_FINISH_BOM: BottomFinishBom[] = [
|
||||
{ item: "하단마감재", spec: "50-40", length: "L: 4000", quantity: 3 },
|
||||
];
|
||||
|
||||
export default function ProductionOrderCreatePage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 우선순위 상태
|
||||
const [selectedPriority, setSelectedPriority] = useState<PriorityLevel>("normal");
|
||||
const [memo, setMemo] = useState("");
|
||||
|
||||
// 성공 다이얼로그 상태
|
||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||
const [generatedOrderNumber, setGeneratedOrderNumber] = useState("");
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setOrderInfo(SAMPLE_ORDER_INFO);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [orderId]);
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleBackToDetail = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// TODO: API 호출
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 생산지시번호 생성 (실제로는 API 응답에서 받아옴)
|
||||
const today = new Date();
|
||||
const dateStr = `${String(today.getFullYear()).slice(2)}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||
const newOrderNumber = `PO-${orderInfo?.orderNumber.replace("KD-TS-", "KD-") || "KD-000000"}-${dateStr}`;
|
||||
|
||||
setGeneratedOrderNumber(newOrderNumber);
|
||||
setShowSuccessDialog(true);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccessDialogClose = () => {
|
||||
setShowSuccessDialog(false);
|
||||
// 생산지시 상세 페이지로 이동 (실제로는 API 응답에서 받은 생산지시 ID 사용)
|
||||
// 임시로 PO-002 사용 (샘플 데이터와 매칭)
|
||||
router.push("/sales/order-management-sales/production-orders/PO-002");
|
||||
};
|
||||
|
||||
// 선택된 우선순위 설정 가져오기
|
||||
const getSelectedPriorityConfig = () => {
|
||||
return PRIORITY_CONFIGS.find((p) => p.key === selectedPriority);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!orderInfo) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">수주 정보를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleCancel} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedConfig = getSelectedPriorityConfig();
|
||||
const workOrderCount = SAMPLE_WORK_ORDER_CARDS.length;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>생산지시 생성</span>
|
||||
<BadgeSm className="bg-gray-100 text-gray-700 border-gray-200">
|
||||
{workOrderCount}개 작업지시 생성 예정
|
||||
</BadgeSm>
|
||||
</div>
|
||||
}
|
||||
icon={Factory}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isSubmitting}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
생산지시 확정
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 수주 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">수주 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">수주번호</p>
|
||||
<p className="font-semibold">{orderInfo.orderNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">거래처</p>
|
||||
<p className="font-medium">{orderInfo.client}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">현장명</p>
|
||||
<p className="font-medium">{orderInfo.siteName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">납기일</p>
|
||||
<p className="font-semibold">{orderInfo.dueDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">품목 수</p>
|
||||
<p className="font-medium">{orderInfo.itemCount}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">총수량</p>
|
||||
<p className="font-medium">{orderInfo.totalQuantity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">신용등급</p>
|
||||
<BadgeSm className="bg-green-100 text-green-700 border-green-200">
|
||||
{orderInfo.creditGrade}
|
||||
</BadgeSm>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">상태</p>
|
||||
<p className="font-semibold">{orderInfo.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생산지시 옵션 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">생산지시 옵션</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 우선순위 (영업) */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">우선순위 (영업)</p>
|
||||
<div className="flex gap-2 mb-4">
|
||||
{PRIORITY_CONFIGS.map((config) => {
|
||||
const colors = PRIORITY_COLORS[config.key];
|
||||
return (
|
||||
<button
|
||||
key={config.key}
|
||||
onClick={() => setSelectedPriority(config.key)}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
selectedPriority === config.key
|
||||
? colors.buttonActive
|
||||
: colors.button
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 우선순위 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">생산지시 (영업)</TableHead>
|
||||
<TableHead className="w-[200px]">작업지시 기본값 (공장)</TableHead>
|
||||
<TableHead>비고 (현장의 여유 공간)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{PRIORITY_CONFIGS.map((config) => {
|
||||
const colors = PRIORITY_COLORS[config.key];
|
||||
const isSelected = selectedPriority === config.key;
|
||||
return (
|
||||
<TableRow
|
||||
key={config.key}
|
||||
className={cn(isSelected && colors.bg)}
|
||||
>
|
||||
<TableCell className={cn("font-medium", isSelected && colors.text)}>
|
||||
{config.productionOrder}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<BadgeSm className={cn(colors.bg, colors.text, colors.border)}>
|
||||
{config.workOrderPriority}
|
||||
</BadgeSm>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{config.note}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 선택된 설정 표시 */}
|
||||
{selectedConfig && (
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<span className="text-sm text-muted-foreground">선택된 설정:</span>
|
||||
<BadgeSm className={cn(
|
||||
PRIORITY_COLORS[selectedPriority].bg,
|
||||
PRIORITY_COLORS[selectedPriority].text,
|
||||
PRIORITY_COLORS[selectedPriority].border
|
||||
)}>
|
||||
생산지시: {selectedConfig.productionOrder}
|
||||
</BadgeSm>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<BadgeSm className={cn(
|
||||
PRIORITY_COLORS[selectedPriority].bg,
|
||||
PRIORITY_COLORS[selectedPriority].text,
|
||||
PRIORITY_COLORS[selectedPriority].border
|
||||
)}>
|
||||
작업지시: {selectedConfig.workOrderPriority}
|
||||
</BadgeSm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">메모</p>
|
||||
<Textarea
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="생산지시 관련 특이사항..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성될 작업지시 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">생성될 작업지시 ({workOrderCount}건)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{SAMPLE_WORK_ORDER_CARDS.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={cn(
|
||||
"border rounded-lg p-4",
|
||||
card.type === "스크린" ? "bg-blue-50/50 border-blue-200" : "bg-orange-50/50 border-orange-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<BadgeSm className={cn(
|
||||
card.type === "스크린"
|
||||
? "bg-blue-100 text-blue-700 border-blue-200"
|
||||
: "bg-orange-100 text-orange-700 border-orange-200"
|
||||
)}>
|
||||
{card.type}
|
||||
</BadgeSm>
|
||||
<span className="font-mono text-sm font-medium">{card.orderNumber}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">품목 수</p>
|
||||
<p className="font-medium">{card.itemCount}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">총 수량</p>
|
||||
<p className="font-medium">{card.totalQuantity}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">공정 순서</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{card.processes.map((process, idx) => (
|
||||
<BadgeSm
|
||||
key={idx}
|
||||
className="bg-gray-50 text-gray-600 border-gray-200"
|
||||
>
|
||||
{process}
|
||||
</BadgeSm>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자재 소요량 및 재고 현황 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">자재 소요량 및 재고 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>자재코드</TableHead>
|
||||
<TableHead>자재명</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">소요량</TableHead>
|
||||
<TableHead className="text-right">현재고</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_MATERIALS.map((item) => (
|
||||
<TableRow key={item.materialCode}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.materialCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.materialName}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right font-medium">{item.required}</TableCell>
|
||||
<TableCell className="text-right">{item.currentStock.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<BadgeSm
|
||||
className={cn(
|
||||
item.status === "sufficient"
|
||||
? "bg-green-100 text-green-700 border-green-200"
|
||||
: "bg-red-100 text-red-700 border-red-200"
|
||||
)}
|
||||
>
|
||||
{item.status === "sufficient" ? "충분" : "부족"}
|
||||
</BadgeSm>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 스크린 품목 상세 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">스크린 품목 상세 ({SAMPLE_SCREEN_ITEMS.length}건)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>도면위치</TableHead>
|
||||
<TableHead className="text-right">개구폭</TableHead>
|
||||
<TableHead className="text-right">개구높이</TableHead>
|
||||
<TableHead className="text-right">제작폭</TableHead>
|
||||
<TableHead className="text-right">제작높이</TableHead>
|
||||
<TableHead>가이드레일</TableHead>
|
||||
<TableHead className="text-center">샤프트</TableHead>
|
||||
<TableHead className="text-center">용량</TableHead>
|
||||
<TableHead>마감</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_SCREEN_ITEMS.map((item) => (
|
||||
<TableRow key={item.no}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{String(item.no).padStart(2, "0")}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.location}</TableCell>
|
||||
<TableCell className="text-right">{item.openWidth.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.openHeight.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.productWidth.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.productHeight.toLocaleString()}</TableCell>
|
||||
<TableCell>{item.guideRail}</TableCell>
|
||||
<TableCell className="text-center">{item.shaft}</TableCell>
|
||||
<TableCell className="text-center">{item.capacity}</TableCell>
|
||||
<TableCell>{item.finish}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 모터/전장품 사양 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">모터/전장품 사양</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">모터 사양 (380V)</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-medium">KD-150K</span>
|
||||
<span className="text-muted-foreground">3대</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">모터 브라켓</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-medium">380-180 [2-4"]</span>
|
||||
<span className="text-muted-foreground">3개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 절곡물 BOM */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">절곡물 BOM</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 가이드레일 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">가이드레일</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>형태</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead className="text-right">길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_GUIDE_RAIL_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell>{item.code}</TableCell>
|
||||
<TableCell className="text-right">{item.length.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 케이스(셔터박스) */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">케이스(셔터박스) - 메인 규격: 500-330</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_CASE_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.item}</TableCell>
|
||||
<TableCell>{item.length}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 마감재 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">하단 마감재</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_BOTTOM_FINISH_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.item}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell>{item.length}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 영역 */}
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t pt-4 pb-4 -mx-3 md:-mx-6 px-3 md:px-6 mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 우선순위 뱃지 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{selectedConfig && (
|
||||
<BadgeSm className={cn(
|
||||
PRIORITY_COLORS[selectedPriority].buttonActive,
|
||||
"border-0"
|
||||
)}>
|
||||
우선순위: {selectedConfig.productionOrder}
|
||||
</BadgeSm>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBackToDetail}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
수주상세로
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isSubmitting}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
생산지시 확정 ({SAMPLE_SCREEN_ITEMS.length}건)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 생산지시 생성 성공 다이얼로그 */}
|
||||
<AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
생산지시가 생성되었습니다.
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">생산지시번호:</p>
|
||||
<p className="font-mono font-semibold text-foreground">{generatedOrderNumber}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
생산관리 > 생산지시 관리에서 작업지시서를 생성하세요.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={handleSuccessDialogClose}>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주 등록 페이지
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OrderRegistration, OrderFormData } from "@/components/orders";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function OrderNewPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
const handleSave = async (formData: OrderFormData) => {
|
||||
// TODO: API 연동
|
||||
console.log("수주 등록 데이터:", formData);
|
||||
|
||||
// 임시: 성공 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
toast.success("수주가 등록되었습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
return <OrderRegistration onBack={handleBack} onSave={handleSave} />;
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주관리 - IntegratedListTemplateV2 적용
|
||||
*
|
||||
* 수주 관리 페이지
|
||||
* - 상단 통계 카드: 이번 달 수주, 분할 대기, 생산지시 대기, 출하 대기
|
||||
* - 필터 탭: 전체, 수주등록, 수주확정, 생산지시완료, 미수
|
||||
* - 완전한 반응형 지원
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FileText,
|
||||
Edit,
|
||||
Trash2,
|
||||
DollarSign,
|
||||
SplitSquareVertical,
|
||||
ClipboardList,
|
||||
Truck,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
} from "@/components/templates/IntegratedListTemplateV2";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { formatAmount, formatAmountManwon } from "@/utils/formatAmount";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/ListMobileCard";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
// 수주 상태 타입
|
||||
type OrderStatus =
|
||||
| "order_registered" // 수주등록
|
||||
| "order_confirmed" // 수주확정
|
||||
| "production_ordered" // 생산지시완료
|
||||
| "in_production" // 생산중
|
||||
| "rework" // 재작업중
|
||||
| "work_completed" // 작업완료
|
||||
| "shipped" // 출하완료
|
||||
| "cancelled"; // 취소
|
||||
|
||||
// 수주 타입
|
||||
interface Order {
|
||||
id: string;
|
||||
lotNumber: string; // 로트번호 KD-TS-XXXXXX-XX
|
||||
quoteNumber: string; // 견적번호 KD-PR-XXXXXX-XX
|
||||
orderDate: string; // 수주일자
|
||||
client: string; // 발주처
|
||||
siteName: string; // 현장명
|
||||
status: OrderStatus;
|
||||
expectedShipDate?: string; // 출고예정일
|
||||
deliveryMethod?: string; // 배송방식
|
||||
amount: number; // 금액
|
||||
itemCount: number; // 품목 수
|
||||
hasReceivable?: boolean; // 미수 여부
|
||||
}
|
||||
|
||||
// 샘플 수주 데이터
|
||||
const SAMPLE_ORDERS: Order[] = [
|
||||
{
|
||||
id: "ORD-001",
|
||||
lotNumber: "KD-TS-251217-01",
|
||||
quoteNumber: "KD-PR-251210-01",
|
||||
orderDate: "2024-12-17",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
status: "order_confirmed",
|
||||
expectedShipDate: "2025-01-15",
|
||||
deliveryMethod: "직접배차",
|
||||
amount: 38800000,
|
||||
itemCount: 5,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-002",
|
||||
lotNumber: "KD-TS-251217-02",
|
||||
quoteNumber: "KD-PR-251211-02",
|
||||
orderDate: "2024-12-17",
|
||||
client: "현대건설(주)",
|
||||
siteName: "힐스테이트 판교역",
|
||||
status: "in_production",
|
||||
expectedShipDate: "2025-01-20",
|
||||
deliveryMethod: "상차",
|
||||
amount: 52500000,
|
||||
itemCount: 8,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-003",
|
||||
lotNumber: "KD-TS-251216-01",
|
||||
quoteNumber: "KD-PR-251208-03",
|
||||
orderDate: "2024-12-16",
|
||||
client: "GS건설(주)",
|
||||
siteName: "자이 강남센터",
|
||||
status: "production_ordered",
|
||||
expectedShipDate: "2025-01-10",
|
||||
deliveryMethod: "직접배차",
|
||||
amount: 45000000,
|
||||
itemCount: 6,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-004",
|
||||
lotNumber: "KD-TS-251215-01",
|
||||
quoteNumber: "KD-PR-251205-04",
|
||||
orderDate: "2024-12-15",
|
||||
client: "대우건설(주)",
|
||||
siteName: "푸르지오 송도",
|
||||
status: "shipped",
|
||||
expectedShipDate: "2024-12-20",
|
||||
deliveryMethod: "상차",
|
||||
amount: 28900000,
|
||||
itemCount: 4,
|
||||
hasReceivable: true,
|
||||
},
|
||||
{
|
||||
id: "ORD-005",
|
||||
lotNumber: "KD-TS-251214-01",
|
||||
quoteNumber: "KD-PR-251201-05",
|
||||
orderDate: "2024-12-14",
|
||||
client: "포스코건설",
|
||||
siteName: "더샵 분당센트럴",
|
||||
status: "rework",
|
||||
expectedShipDate: "2025-01-25",
|
||||
deliveryMethod: "직접배차",
|
||||
amount: 62000000,
|
||||
itemCount: 10,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-006",
|
||||
lotNumber: "KD-TS-251213-01",
|
||||
quoteNumber: "KD-PR-251128-06",
|
||||
orderDate: "2024-12-13",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "캐슬 잠실파크",
|
||||
status: "work_completed",
|
||||
expectedShipDate: "2024-12-25",
|
||||
deliveryMethod: "직접배차",
|
||||
amount: 35500000,
|
||||
itemCount: 5,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-007",
|
||||
lotNumber: "KD-TS-251212-01",
|
||||
quoteNumber: "KD-PR-251125-07",
|
||||
orderDate: "2024-12-12",
|
||||
client: "삼성물산(주)",
|
||||
siteName: "래미안 서초",
|
||||
status: "order_registered",
|
||||
expectedShipDate: undefined,
|
||||
deliveryMethod: undefined,
|
||||
amount: 48000000,
|
||||
itemCount: 7,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-008",
|
||||
lotNumber: "KD-TS-251211-01",
|
||||
quoteNumber: "KD-PR-251120-08",
|
||||
orderDate: "2024-12-11",
|
||||
client: "SK에코플랜트",
|
||||
siteName: "SK VIEW 일산",
|
||||
status: "shipped",
|
||||
expectedShipDate: "2024-12-18",
|
||||
deliveryMethod: "상차",
|
||||
amount: 31200000,
|
||||
itemCount: 4,
|
||||
hasReceivable: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 상태 뱃지 헬퍼 함수
|
||||
function getOrderStatusBadge(status: OrderStatus) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; variant: string; className: string }> = {
|
||||
order_registered: { label: "수주등록", variant: "default", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
order_confirmed: { label: "수주확정", variant: "default", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", variant: "default", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", variant: "default", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
rework: { label: "재작업중", variant: "default", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", variant: "default", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipped: { label: "출하완료", variant: "default", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", variant: "default", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
</BadgeSm>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderManagementSalesPage() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterType, setFilterType] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 취소 확인 다이얼로그 state
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
const [cancelTargetId, setCancelTargetId] = useState<string | null>(null);
|
||||
|
||||
// 삭제 확인 다이얼로그 state (다중 선택 삭제 지원)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetIds, setDeleteTargetIds] = useState<string[]>([]);
|
||||
|
||||
// 모바일 인피니티 스크롤 state
|
||||
const [mobileDisplayCount, setMobileDisplayCount] = useState(20);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 로컬 데이터 state (실제 구현에서는 API 연동)
|
||||
const [orders, setOrders] = useState<Order[]>(SAMPLE_ORDERS);
|
||||
|
||||
// 필터링 및 정렬
|
||||
const filteredOrders = orders
|
||||
.filter((order) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
order.lotNumber.toLowerCase().includes(searchLower) ||
|
||||
order.quoteNumber.toLowerCase().includes(searchLower) ||
|
||||
order.client.toLowerCase().includes(searchLower) ||
|
||||
order.siteName.toLowerCase().includes(searchLower);
|
||||
|
||||
let matchesFilter = true;
|
||||
if (filterType === "registered") {
|
||||
matchesFilter = order.status === "order_registered";
|
||||
} else if (filterType === "confirmed") {
|
||||
matchesFilter = order.status === "order_confirmed";
|
||||
} else if (filterType === "production_ordered") {
|
||||
matchesFilter = order.status === "production_ordered";
|
||||
} else if (filterType === "receivable") {
|
||||
matchesFilter = order.hasReceivable === true;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return new Date(b.orderDate).getTime() - new Date(a.orderDate).getTime();
|
||||
});
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
|
||||
const paginatedOrders = filteredOrders.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// 모바일용 인피니티 스크롤 데이터
|
||||
const mobileOrders = filteredOrders.slice(0, mobileDisplayCount);
|
||||
|
||||
// Intersection Observer를 이용한 인피니티 스크롤
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.innerWidth >= 1280) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (
|
||||
entries[0].isIntersecting &&
|
||||
mobileDisplayCount < filteredOrders.length
|
||||
) {
|
||||
setMobileDisplayCount((prev) =>
|
||||
Math.min(prev + 20, filteredOrders.length)
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
rootMargin: "100px",
|
||||
}
|
||||
);
|
||||
|
||||
if (sentinelRef.current) {
|
||||
observer.observe(sentinelRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [mobileDisplayCount, filteredOrders.length]);
|
||||
|
||||
// 탭이나 검색어 변경 시 모바일 표시 개수 초기화
|
||||
useEffect(() => {
|
||||
setMobileDisplayCount(20);
|
||||
}, [searchTerm, filterType]);
|
||||
|
||||
// 통계 계산
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// 이번 달 수주 금액
|
||||
const thisMonthOrders = orders.filter(
|
||||
(o) => new Date(o.orderDate) >= startOfMonth
|
||||
);
|
||||
const thisMonthAmount = thisMonthOrders.reduce((sum, o) => sum + o.amount, 0);
|
||||
|
||||
// 분할 대기 (예시: 수주확정 상태)
|
||||
const splitPendingCount = orders.filter((o) => o.status === "order_confirmed").length;
|
||||
|
||||
// 생산지시 대기 (수주확정 상태 중 생산지시 안된 것)
|
||||
const productionPendingCount = orders.filter(
|
||||
(o) => o.status === "order_confirmed" || o.status === "order_registered"
|
||||
).length;
|
||||
|
||||
// 출하 대기 (작업완료 상태)
|
||||
const shipPendingCount = orders.filter((o) => o.status === "work_completed").length;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: "이번 달 수주",
|
||||
value: formatAmountManwon(thisMonthAmount),
|
||||
icon: DollarSign,
|
||||
iconColor: "text-blue-600",
|
||||
},
|
||||
{
|
||||
label: "분할 대기",
|
||||
value: `${splitPendingCount}건`,
|
||||
icon: SplitSquareVertical,
|
||||
iconColor: "text-orange-600",
|
||||
},
|
||||
{
|
||||
label: "생산지시 대기",
|
||||
value: `${productionPendingCount}건`,
|
||||
icon: ClipboardList,
|
||||
iconColor: "text-green-600",
|
||||
},
|
||||
{
|
||||
label: "출하 대기",
|
||||
value: `${shipPendingCount}건`,
|
||||
icon: Truck,
|
||||
iconColor: "text-purple-600",
|
||||
},
|
||||
];
|
||||
|
||||
// 핸들러
|
||||
const handleView = (order: Order) => {
|
||||
router.push(`/sales/order-management-sales/${order.id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (order: Order) => {
|
||||
router.push(`/sales/order-management-sales/${order.id}/edit`);
|
||||
};
|
||||
|
||||
const handleCancel = (orderId: string) => {
|
||||
setCancelTargetId(orderId);
|
||||
setIsCancelDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancel = () => {
|
||||
if (cancelTargetId) {
|
||||
const order = orders.find((o) => o.id === cancelTargetId);
|
||||
setOrders(
|
||||
orders.map((o) =>
|
||||
o.id === cancelTargetId ? { ...o, status: "cancelled" as OrderStatus } : o
|
||||
)
|
||||
);
|
||||
toast.success(
|
||||
`수주가 취소되었습니다${order ? `: ${order.lotNumber}` : ""}`
|
||||
);
|
||||
setIsCancelDialogOpen(false);
|
||||
setCancelTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 삭제 (row에서 휴지통 아이콘 클릭)
|
||||
const handleDelete = (orderId: string) => {
|
||||
setDeleteTargetIds([orderId]);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 다중 선택 삭제 (IntegratedListTemplateV2에서 확인 후 호출됨)
|
||||
// 템플릿 내부에서 이미 확인 팝업을 처리하므로 바로 삭제 실행
|
||||
const handleBulkDelete = () => {
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
if (selectedIds.length > 0) {
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${selectedIds.length}개의 수주가 삭제되었습니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확정 (단일/다중 모두 처리)
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteTargetIds.length > 0) {
|
||||
const count = deleteTargetIds.length;
|
||||
setOrders(orders.filter((o) => !deleteTargetIds.includes(o.id)));
|
||||
// 선택 상태 초기화
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${count}개의 수주가 삭제되었습니다.`);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (
|
||||
selectedItems.size === paginatedOrders.length &&
|
||||
paginatedOrders.length > 0
|
||||
) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedOrders.map((o) => o.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 구성
|
||||
const tabs: TabOption[] = [
|
||||
{
|
||||
value: "all",
|
||||
label: "전체",
|
||||
count: orders.length,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
value: "registered",
|
||||
label: "수주등록",
|
||||
count: orders.filter((o) => o.status === "order_registered").length,
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
value: "confirmed",
|
||||
label: "수주확정",
|
||||
count: orders.filter((o) => o.status === "order_confirmed").length,
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
value: "production_ordered",
|
||||
label: "생산지시완료",
|
||||
count: orders.filter((o) => o.status === "production_ordered").length,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
value: "receivable",
|
||||
label: "미수",
|
||||
count: orders.filter((o) => o.hasReceivable).length,
|
||||
color: "red",
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: "rowNumber", label: "번호", className: "px-4" },
|
||||
{ key: "lotNumber", label: "로트번호", className: "px-4" },
|
||||
{ key: "quoteNumber", label: "견적번호", className: "px-4" },
|
||||
{ key: "client", label: "발주처", className: "px-4" },
|
||||
{ key: "siteName", label: "현장명", className: "px-4" },
|
||||
{ key: "status", label: "상태", className: "px-4" },
|
||||
{ key: "expectedShipDate", label: "출고예정일", className: "px-4" },
|
||||
{ key: "deliveryMethod", label: "배송방식", className: "px-4" },
|
||||
{ key: "actions", label: "작업", className: "px-4" },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
order: Order,
|
||||
index: number,
|
||||
globalIndex: number
|
||||
) => {
|
||||
const itemId = order.id;
|
||||
const isSelected = selectedItems.has(itemId);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? "bg-blue-50" : ""}`}
|
||||
onClick={() => handleView(order)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(itemId)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{globalIndex}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
|
||||
{order.lotNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
|
||||
{order.quoteNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{order.client}</TableCell>
|
||||
<TableCell>{order.siteName}</TableCell>
|
||||
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
|
||||
<TableCell>{order.expectedShipDate || "-"}</TableCell>
|
||||
<TableCell>{order.deliveryMethod || "-"}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(order)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(order)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 삭제 버튼 - shipped, cancelled 제외 모든 상태에서 표시 */}
|
||||
{order.status !== "shipped" && order.status !== "cancelled" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(order.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
order: Order,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={order.id}
|
||||
id={order.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleView(order)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-gray-100 text-gray-700 font-mono text-xs"
|
||||
>
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<code className="inline-block text-xs bg-gray-100 text-gray-700 px-2.5 py-0.5 rounded-md font-mono whitespace-nowrap">
|
||||
{order.lotNumber}
|
||||
</code>
|
||||
</>
|
||||
}
|
||||
title={order.client}
|
||||
statusBadge={getOrderStatusBadge(order.status)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="현장명" value={order.siteName} />
|
||||
<InfoField label="견적번호" value={order.quoteNumber} />
|
||||
<InfoField label="출고예정일" value={order.expectedShipDate || "-"} />
|
||||
<InfoField label="배송방식" value={order.deliveryMethod || "-"} />
|
||||
<InfoField
|
||||
label="금액"
|
||||
value={formatAmount(order.amount)}
|
||||
valueClassName="text-green-600"
|
||||
/>
|
||||
<InfoField label="품목 수" value={`${order.itemCount}개`} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(order);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(order);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{/* 삭제 버튼 - shipped, cancelled 제외 모든 상태에서 표시 */}
|
||||
{order.status !== "shipped" && order.status !== "cancelled" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(order.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="수주 목록"
|
||||
description="수주 관리 및 생산지시 연동"
|
||||
icon={FileText}
|
||||
headerActions={
|
||||
<Button onClick={() => router.push("/sales/order-management-sales/new")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
수주 등록
|
||||
</Button>
|
||||
}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="로트번호, 견적번호, 발주처, 현장명 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={filterType}
|
||||
onTabChange={(value) => {
|
||||
setFilterType(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
tableColumns={tableColumns}
|
||||
tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredOrders.length}개)`}
|
||||
data={paginatedOrders}
|
||||
totalCount={filteredOrders.length}
|
||||
allData={mobileOrders}
|
||||
mobileDisplayCount={mobileDisplayCount}
|
||||
infinityScrollSentinelRef={sentinelRef}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(order) => order.id}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredOrders.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 수주 취소 확인 다이얼로그 */}
|
||||
<AlertDialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>수주 취소 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{cancelTargetId
|
||||
? `수주번호: ${orders.find((o) => o.id === cancelTargetId)?.lotNumber || cancelTargetId}`
|
||||
: ""}
|
||||
<br />
|
||||
이 수주를 취소하시겠습니까? 취소된 수주는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>닫기</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmCancel} className="bg-orange-600 hover:bg-orange-700">
|
||||
취소 확정
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 수주 삭제 확인 다이얼로그 - 스크린샷 디자인 적용 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<span className="text-yellow-600">⚠️</span>
|
||||
삭제 확인
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<p className="text-foreground">
|
||||
선택한 <strong>{deleteTargetIds.length}개</strong>의 수주를 삭제하시겠습니까?
|
||||
</p>
|
||||
{/* 주의 박스 */}
|
||||
<div className="bg-gray-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 mt-0.5">⚠️</span>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">주의</span>
|
||||
<br />
|
||||
삭제된 수주는 복구할 수 없습니다. 관련된 작업지시, 출하정보도 함께 삭제될 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산지시 상세 페이지
|
||||
*
|
||||
* - 공정 진행 현황
|
||||
* - 기본 정보 / 거래처/현장 정보
|
||||
* - BOM 품목별 공정 분류
|
||||
* - 작업지시서 목록
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Factory,
|
||||
ArrowLeft,
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Activity,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 상태 타입
|
||||
type WorkOrderStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 데이터 타입
|
||||
interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNumber: string; // KD-WO-XXXXXX-XX
|
||||
process: string; // 공정명
|
||||
quantity: number;
|
||||
status: WorkOrderStatus;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
// 생산지시 상세 데이터 타입
|
||||
interface ProductionOrderDetail {
|
||||
id: string;
|
||||
productionOrderNumber: string;
|
||||
orderNumber: string;
|
||||
productionOrderDate: string;
|
||||
dueDate: string;
|
||||
quantity: number;
|
||||
status: ProductionOrderStatus;
|
||||
client: string;
|
||||
siteName: string;
|
||||
productType: string;
|
||||
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
|
||||
workOrders: WorkOrder[];
|
||||
}
|
||||
|
||||
// 샘플 생산지시 상세 데이터
|
||||
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
|
||||
"PO-001": {
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-15",
|
||||
quantity: 2,
|
||||
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
|
||||
client: "호반건설(주)",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-001",
|
||||
workOrderNumber: "KD-WO-251217-07",
|
||||
process: "재단",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-002",
|
||||
workOrderNumber: "KD-WO-251217-08",
|
||||
process: "조립",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-003",
|
||||
workOrderNumber: "KD-WO-251217-09",
|
||||
process: "검수",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
"PO-002": {
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 10,
|
||||
status: "waiting",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-003": {
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 1,
|
||||
status: "waiting",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-004": {
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
productionOrderDate: "2025-12-20",
|
||||
dueDate: "2026-02-03",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
client: "현대건설(주)",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0,
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-004",
|
||||
workOrderNumber: "KD-WO-251220-01",
|
||||
process: "재단",
|
||||
quantity: 3,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-005",
|
||||
workOrderNumber: "KD-WO-251220-02",
|
||||
process: "조립",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// 공정 진행 현황 컴포넌트
|
||||
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
if (workOrders.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
공정 진행 현황
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm text-center py-4">
|
||||
아직 작업지시가 생성되지 않았습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = workOrders.filter((w) => w.status === "completed").length;
|
||||
const totalCount = workOrders.length;
|
||||
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
공정 진행 현황
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 진행률 바 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">진행률</span>
|
||||
<span className="font-medium">{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 단계 */}
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
{workOrders.map((wo, index) => (
|
||||
<div key={wo.id} className="flex items-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
wo.status === "completed"
|
||||
? "bg-green-500 text-white"
|
||||
: wo.status === "in_progress"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{wo.status === "completed" ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{wo.process}</span>
|
||||
</div>
|
||||
{index < workOrders.length - 1 && (
|
||||
<div
|
||||
className={`w-12 h-0.5 mx-1 ${
|
||||
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
completed: {
|
||||
label: "생산완료",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
};
|
||||
const c = config[status];
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
// 작업지시 상태 배지 헬퍼
|
||||
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
|
||||
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
|
||||
pending: {
|
||||
label: "대기",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "작업중",
|
||||
className: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
},
|
||||
completed: {
|
||||
label: "완료",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
};
|
||||
const c = config[status];
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
// 정보 표시 컴포넌트
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="font-medium">{value || "-"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
|
||||
const SAMPLE_PROCESSES = [
|
||||
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
|
||||
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
|
||||
{ id: "P3", name: "3.1 케이스", quantity: 10 },
|
||||
{ id: "P4", name: "4. 연기단자", quantity: 10 },
|
||||
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
|
||||
];
|
||||
|
||||
// BOM 품목 타입
|
||||
interface BomItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
lotNo: string;
|
||||
requiredQty: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
// BOM 공정 분류 타입
|
||||
interface BomProcessGroup {
|
||||
processName: string;
|
||||
sizeSpec?: string;
|
||||
items: BomItem[];
|
||||
}
|
||||
|
||||
// BOM 품목별 공정 분류 목데이터
|
||||
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
|
||||
{
|
||||
processName: "1.1 백판필름",
|
||||
sizeSpec: "[20-70]",
|
||||
items: [
|
||||
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
|
||||
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
|
||||
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "2. 하안마감재",
|
||||
sizeSpec: "[60-40]",
|
||||
items: [
|
||||
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
|
||||
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "3.1 케이스",
|
||||
sizeSpec: "[500*330]",
|
||||
items: [
|
||||
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
|
||||
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
|
||||
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
|
||||
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
|
||||
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
|
||||
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "4. 연기단자",
|
||||
sizeSpec: "",
|
||||
items: [
|
||||
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
|
||||
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductionOrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const productionOrderId = params.id as string;
|
||||
|
||||
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
|
||||
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
|
||||
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
|
||||
setProductionOrder(found || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [productionOrderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales/production-orders");
|
||||
};
|
||||
|
||||
const handleCreateWorkOrder = () => {
|
||||
setIsCreateWorkOrderDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCreateWorkOrder = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
// API 호출 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
|
||||
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
|
||||
const created = Array.from({ length: workOrderCount }, (_, i) =>
|
||||
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
|
||||
);
|
||||
setCreatedWorkOrders(created);
|
||||
|
||||
// 확인 팝업 닫고 성공 팝업 열기
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccessDialogClose = () => {
|
||||
setIsSuccessDialogOpen(false);
|
||||
// 작업지시 관리 페이지로 이동
|
||||
router.push("/production/work-orders");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!productionOrder) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">생산지시 정보를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleBack} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>생산지시 상세</span>
|
||||
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
{productionOrder.productionOrderNumber}
|
||||
</code>
|
||||
{getStatusBadge(productionOrder.status)}
|
||||
</div>
|
||||
}
|
||||
icon={Factory}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<ClipboardList className="h-4 w-4 mr-2" />
|
||||
작업지시 생성
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공정 진행 현황 */}
|
||||
<ProcessProgress workOrders={productionOrder.workOrders} />
|
||||
|
||||
{/* 기본 정보 & 거래처/현장 정보 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
|
||||
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
|
||||
<InfoItem label="납기일" value={productionOrder.dueDate} />
|
||||
<InfoItem label="수량" value={`${productionOrder.quantity}개`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 거래처/현장 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">거래처/현장 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="거래처" value={productionOrder.client} />
|
||||
<InfoItem label="현장명" value={productionOrder.siteName} />
|
||||
<InfoItem label="제품유형" value={productionOrder.productType} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* BOM 품목별 공정 분류 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">BOM 품목별 공정 분류</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 절곡 부품 전개도 정보 헤더 */}
|
||||
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
절곡 부품 전개도 정보
|
||||
</p>
|
||||
|
||||
{/* 공정별 테이블 */}
|
||||
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
{/* 공정명 헤더 */}
|
||||
<h4 className="text-sm font-semibold">
|
||||
{group.processName}
|
||||
{group.sizeSpec && (
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
{group.sizeSpec}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{/* BOM 품목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="w-[60px] text-center">항목코드</TableHead>
|
||||
<TableHead>세부품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>LOT NO</TableHead>
|
||||
<TableHead className="text-right">필요수량</TableHead>
|
||||
<TableHead className="text-center w-[60px]">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.lotNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.requiredQty > 0 ? item.requiredQty.toLocaleString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.qty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 합계 정보 */}
|
||||
<div className="flex justify-between items-center pt-4 border-t text-sm">
|
||||
<span className="text-muted-foreground">총 부품 종류: 18개</span>
|
||||
<span className="text-muted-foreground">총 중량: 25.8 kg</span>
|
||||
<span className="text-muted-foreground">비고: VT칼 작업 완료 후 절곡 진행</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업지시서 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">작업지시서 목록</CardTitle>
|
||||
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{productionOrder.pendingWorkOrderCount > 1
|
||||
? "작업지시 일괄생성"
|
||||
: "작업지시 생성"}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productionOrder.workOrders.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ClipboardList className="h-12 w-12 text-gray-300" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
아직 작업지시서가 생성되지 않았습니다.
|
||||
</p>
|
||||
{productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
위 버튼을 클릭하여 BOM 기반 작업지시서를 자동 생성하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>작업지시번호</TableHead>
|
||||
<TableHead>공정</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{productionOrder.workOrders.map((wo) => (
|
||||
<TableRow key={wo.id}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{wo.workOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{wo.process}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개</TableCell>
|
||||
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
|
||||
<TableCell>{wo.assignee}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
|
||||
<AlertDialog
|
||||
open={isCreateWorkOrderDialogOpen}
|
||||
onOpenChange={setIsCreateWorkOrderDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
작업지시서 자동 생성
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="font-medium text-foreground">
|
||||
다음 공정에 대한 작업지시서가 생성됩니다:
|
||||
</p>
|
||||
{/* 공정 목록 (실제로는 API에서 받아온 데이터) */}
|
||||
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
|
||||
<li key={process.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{process.name} ({process.quantity}개)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
생성된 작업지시서는 생산팀에서 확인하고 작업을 진행할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isCreating}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmCreateWorkOrder}
|
||||
disabled={isCreating}
|
||||
className="gap-2"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{isCreating ? "생성 중..." : "작업지시 생성"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
|
||||
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="sr-only">작업지시 생성 완료</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<span className="font-medium text-foreground">
|
||||
{createdWorkOrders.length}개의 작업지시서가 공정별로 자동 생성되었습니다.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">생성된 작업지시서:</p>
|
||||
{createdWorkOrders.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{createdWorkOrders.map((wo, idx) => (
|
||||
<li key={wo} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||
{wo}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
작업지시 관리 페이지로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={handleSuccessDialogClose}>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산지시 목록 페이지
|
||||
*
|
||||
* - 수주관리 > 생산지시 보기에서 접근
|
||||
* - 진행 단계 바
|
||||
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
|
||||
* - IntegratedListTemplateV2 템플릿 적용
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Factory,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
} from "@/components/templates/IntegratedListTemplateV2";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/ListMobileCard";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus =
|
||||
| "waiting" // 생산대기
|
||||
| "in_progress" // 생산중
|
||||
| "completed"; // 생산완료
|
||||
|
||||
// 생산지시 데이터 타입
|
||||
interface ProductionOrder {
|
||||
id: string;
|
||||
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
|
||||
orderNumber: string; // KD-TS-XXXXXX-XX
|
||||
siteName: string;
|
||||
client: string;
|
||||
quantity: number;
|
||||
dueDate: string;
|
||||
productionOrderDate: string;
|
||||
status: ProductionOrderStatus;
|
||||
workOrderCount: number;
|
||||
}
|
||||
|
||||
// 샘플 생산지시 데이터
|
||||
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
|
||||
{
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
client: "호반건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-02-15",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
client: "태영건설(주)",
|
||||
quantity: 10,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
client: "롯데건설(주)",
|
||||
quantity: 1,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
client: "현대건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2026-02-03",
|
||||
productionOrderDate: "2025-12-20",
|
||||
status: "in_progress",
|
||||
workOrderCount: 2,
|
||||
},
|
||||
{
|
||||
id: "PO-005",
|
||||
productionOrderNumber: "PO-KD-BD-251219-34",
|
||||
orderNumber: "KD-BD-251219-34",
|
||||
siteName: "[코레타스프1] 김포 6차 필라테스장",
|
||||
client: "신성플랜(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-01-15",
|
||||
productionOrderDate: "2025-12-19",
|
||||
status: "in_progress",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-006",
|
||||
productionOrderNumber: "PO-KD-TS-250401-29",
|
||||
orderNumber: "KD-TS-250401-29",
|
||||
siteName: "포레나 전주",
|
||||
client: "한화건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2025-05-16",
|
||||
productionOrderDate: "2025-04-01",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-007",
|
||||
productionOrderNumber: "PO-KD-BD-250331-28",
|
||||
orderNumber: "KD-BD-250331-28",
|
||||
siteName: "포레나 수원",
|
||||
client: "포레나건설(주)",
|
||||
quantity: 4,
|
||||
dueDate: "2025-05-15",
|
||||
productionOrderDate: "2025-03-31",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-008",
|
||||
productionOrderNumber: "PO-KD-TS-250314-23",
|
||||
orderNumber: "KD-TS-250314-23",
|
||||
siteName: "자이 흑산파크",
|
||||
client: "GS건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2025-04-28",
|
||||
productionOrderDate: "2025-03-14",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
];
|
||||
|
||||
// 진행 단계 컴포넌트
|
||||
function ProgressSteps() {
|
||||
const steps = [
|
||||
{ label: "수주확정", active: true, completed: true },
|
||||
{ label: "생산지시", active: true, completed: false },
|
||||
{ label: "작업지시", active: false, completed: false },
|
||||
{ label: "생산", active: false, completed: false },
|
||||
{ label: "검사출하", active: false, completed: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.label} className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
|
||||
step.completed
|
||||
? "bg-primary text-primary-foreground"
|
||||
: step.active
|
||||
? "bg-primary/20 text-primary border-2 border-primary"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{step.completed ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
step.active || step.completed
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`w-8 h-0.5 mx-2 ${
|
||||
step.completed ? "bg-primary" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<
|
||||
ProductionOrderStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
completed: {
|
||||
label: "생산완료",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
};
|
||||
const c = config[status];
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: "no", label: "번호", className: "w-[60px] text-center" },
|
||||
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
|
||||
{ key: "siteName", label: "현장명", className: "min-w-[180px]" },
|
||||
{ key: "client", label: "거래처", className: "min-w-[120px]" },
|
||||
{ key: "quantity", label: "수량", className: "w-[80px] text-center" },
|
||||
{ key: "dueDate", label: "납기", className: "w-[110px]" },
|
||||
{ key: "productionOrderDate", label: "생산지시일", className: "w-[110px]" },
|
||||
{ key: "status", label: "상태", className: "w-[100px]" },
|
||||
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
|
||||
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
|
||||
];
|
||||
|
||||
export default function ProductionOrdersListPage() {
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 삭제 확인 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = orders.filter((item) => {
|
||||
// 탭 필터
|
||||
if (activeTab !== "all") {
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
if (item.status !== statusMap[activeTab]) return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// 탭별 건수
|
||||
const tabCounts = {
|
||||
all: orders.length,
|
||||
waiting: orders.filter((i) => i.status === "waiting").length,
|
||||
in_progress: orders.filter((i) => i.status === "in_progress").length,
|
||||
completed: orders.filter((i) => i.status === "completed").length,
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: tabCounts.all },
|
||||
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
|
||||
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
|
||||
];
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
const handleRowClick = (item: ProductionOrder) => {
|
||||
router.push(`/sales/order-management-sales/production-orders/${item.id}`);
|
||||
};
|
||||
|
||||
const handleView = (item: ProductionOrder) => {
|
||||
router.push(`/sales/order-management-sales/production-orders/${item.id}`);
|
||||
};
|
||||
|
||||
// 개별 삭제 다이얼로그 열기
|
||||
const handleDelete = (item: ProductionOrder) => {
|
||||
setDeleteTargetId(item.id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 일괄 삭제 다이얼로그 열기
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedItems.size > 0) {
|
||||
setDeleteTargetId(null); // 일괄 삭제
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
|
||||
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
|
||||
|
||||
// 실제 삭제 실행
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteTargetId) {
|
||||
// 개별 삭제
|
||||
setOrders(orders.filter((o) => o.id !== deleteTargetId));
|
||||
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
|
||||
} else {
|
||||
// 일괄 삭제
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
item: ProductionOrder,
|
||||
index: number,
|
||||
globalIndex: number
|
||||
) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? "bg-muted/30" : ""}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{globalIndex}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
{item.productionOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.orderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{item.siteName}
|
||||
</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}개</TableCell>
|
||||
<TableCell>{item.dueDate}</TableCell>
|
||||
<TableCell>{item.productionOrderDate}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.workOrderCount > 0 ? (
|
||||
<Badge variant="outline">{item.workOrderCount}건</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<div className="flex gap-1 justify-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: ProductionOrder,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleRowClick(item)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-blue-50 text-blue-700 font-mono text-xs"
|
||||
>
|
||||
{item.productionOrderNumber}
|
||||
</Badge>
|
||||
{getStatusBadge(item.status)}
|
||||
</>
|
||||
}
|
||||
fields={
|
||||
<>
|
||||
<InfoField label="수주번호" value={item.orderNumber} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="거래처" value={item.client} />
|
||||
<InfoField label="수량" value={`${item.quantity}개`} />
|
||||
<InfoField label="납기" value={item.dueDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderDate} />
|
||||
<InfoField
|
||||
label="작업지시"
|
||||
value={item.workOrderCount > 0 ? `${item.workOrderCount}건` : "-"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(item);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="생산지시 목록"
|
||||
icon={Factory}
|
||||
headerActions={
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
수주 목록
|
||||
</Button>
|
||||
}
|
||||
// 진행 단계 표시
|
||||
tabsContent={
|
||||
<Card className="w-full mb-4">
|
||||
<CardContent className="py-2">
|
||||
<ProgressSteps />
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
// 검색
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="생산지시번호, 수주번호, 현장명 검색..."
|
||||
// 탭
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(value) => {
|
||||
setActiveTab(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
// 테이블
|
||||
tableColumns={TABLE_COLUMNS}
|
||||
data={paginatedData}
|
||||
allData={filteredData}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<span className="text-yellow-600">⚠️</span>
|
||||
삭제 확인
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<p className="text-foreground">
|
||||
선택한 <strong>{deleteCount}개</strong>의 항목을 삭제하시겠습니까?
|
||||
</p>
|
||||
<div className="bg-gray-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 mt-0.5">⚠️</span>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">주의</span>
|
||||
<br />
|
||||
삭제된 항목은 복구할 수 없습니다. 관련된 데이터도 함께 삭제될 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { locales, type Locale } from '@/i18n/config';
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||
import { Toaster } from 'sonner';
|
||||
import "../globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -68,6 +69,7 @@ export default async function RootLayout({
|
||||
<ThemeProvider>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user