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:
byeongcheolryu
2025-12-23 21:13:07 +09:00
parent 2ebcea0255
commit f0e8e51d06
108 changed files with 21156 additions and 84 deletions

View File

@@ -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} />;
}

View File

@@ -0,0 +1,5 @@
import { InspectionCreate } from '@/components/material/ReceivingManagement';
export default function InspectionPage() {
return <InspectionCreate />;
}

View File

@@ -0,0 +1,5 @@
import { ReceivingList } from '@/components/material/ReceivingManagement';
export default function ReceivingManagementPage() {
return <ReceivingList />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,5 @@
import { StockStatusList } from '@/components/material/StockStatus';
export default function StockStatusPage() {
return <StockStatusList />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,10 @@
/**
* 출하관리 - 등록 페이지
* URL: /outbound/shipments/new
*/
import { ShipmentCreate } from '@/components/outbound/ShipmentManagement';
export default function NewShipmentPage() {
return <ShipmentCreate />;
}

View File

@@ -0,0 +1,10 @@
/**
* 출하관리 - 목록 페이지
* URL: /outbound/shipments
*/
import { ShipmentList } from '@/components/outbound/ShipmentManagement';
export default function ShipmentsPage() {
return <ShipmentList />;
}

View 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: '공장별 작업 현황을 확인합니다.',
};

View File

@@ -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} />;
}

View File

@@ -0,0 +1,10 @@
/**
* 작업지시 등록 페이지
* URL: /production/work-orders/create
*/
import { WorkOrderCreate } from '@/components/production/WorkOrders';
export default function WorkOrderCreatePage() {
return <WorkOrderCreate />;
}

View File

@@ -0,0 +1,10 @@
/**
* 작업지시 목록 페이지
* URL: /production/work-orders
*/
import { WorkOrderList } from '@/components/production/WorkOrders';
export default function WorkOrdersPage() {
return <WorkOrderList />;
}

View File

@@ -0,0 +1,10 @@
/**
* 작업실적 조회 페이지
* URL: /production/work-results
*/
import { WorkResultList } from '@/components/production/WorkResults';
export default function WorkResultsPage() {
return <WorkResultList />;
}

View File

@@ -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: '내 작업 목록을 확인하고 관리합니다.',
};

View File

@@ -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} />;
}

View File

@@ -0,0 +1,10 @@
/**
* 검사 등록 페이지
* URL: /quality/inspections/new
*/
import { InspectionCreate } from '@/components/quality/InspectionManagement';
export default function InspectionNewPage() {
return <InspectionCreate />;
}

View File

@@ -0,0 +1,10 @@
/**
* 검사 목록 페이지
* URL: /quality/inspections
*/
import { InspectionList } from '@/components/quality/InspectionManagement';
export default function InspectionsPage() {
return <InspectionList />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">
&gt; .
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={handleSuccessDialogClose}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -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} />;
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>