feat(WEB): 공정관리/작업지시/작업자화면 기능 강화 및 템플릿 개선

- 공정관리: ProcessDetail/ProcessForm/ProcessList 개선, StepDetail/StepForm 신규 추가
- 작업지시: WorkOrderDetail/Edit/List UI 개선, 작업지시서 문서 추가
- 작업자화면: WorkerScreen 대폭 개선, MaterialInputModal/WorkLogModal 수정, WorkItemCard 신규
- 영업주문: 주문 상세 페이지 개선
- 입고관리: 상세/actions 수정
- 템플릿: IntegratedDetailTemplate/IntegratedListTemplateV2/UniversalListPage 기능 확장
- UI: confirm-dialog 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-29 22:56:01 +09:00
parent 106ce09482
commit 3fc63d0b3e
50 changed files with 5801 additions and 1377 deletions

View File

@@ -0,0 +1,22 @@
'use client';
/**
* 공정 단계 상세/수정/등록 페이지
*
* - /[id]/steps/[stepId] → 상세 보기
* - /[id]/steps/[stepId]?mode=edit → 수정
* - /[id]/steps/new → 등록
*/
import { use } from 'react';
import { StepDetailClient } from '@/components/process-management/StepDetailClient';
export default function StepDetailPage({
params,
}: {
params: Promise<{ id: string; stepId: string }>;
}) {
const { id, stepId } = use(params);
return <StepDetailClient processId={id} stepId={stepId} />;
}

View File

@@ -73,6 +73,7 @@ import {
} from "@/components/orders";
import { sendSalesOrderNotification } from "@/lib/actions/fcm";
import { OrderSalesDetailEdit } from "@/components/orders/OrderSalesDetailEdit";
import { getDepartmentTree, type DepartmentRecord } from "@/components/hr/DepartmentManagement/actions";
/**
* 수량 포맷 함수
@@ -156,6 +157,8 @@ export default function OrderDetailPage() {
const [isProductionDialogOpen, setIsProductionDialogOpen] = useState(false);
const [isCreatingProduction, setIsCreatingProduction] = useState(false);
const [productionPriority, setProductionPriority] = useState<"normal" | "high" | "urgent">("normal");
const [productionDepartmentId, setProductionDepartmentId] = useState<string>("");
const [departments, setDepartments] = useState<DepartmentRecord[]>([]);
const [productionMemo, setProductionMemo] = useState("");
// 생산지시 완료 알림 모달 상태
const [isProductionSuccessDialogOpen, setIsProductionSuccessDialogOpen] = useState(false);
@@ -203,11 +206,45 @@ export default function OrderDetailPage() {
router.push(`/sales/order-management-sales/${orderId}?mode=edit`);
};
const handleProductionOrder = () => {
// 부서 트리를 평탄화
const flattenDepartments = (depts: DepartmentRecord[]): DepartmentRecord[] => {
const result: DepartmentRecord[] = [];
const traverse = (list: DepartmentRecord[]) => {
for (const dept of list) {
result.push(dept);
if (dept.children?.length) traverse(dept.children);
}
};
traverse(depts);
return result;
};
const handleProductionOrder = async () => {
// 생산지시 생성 모달 열기
setProductionPriority("normal");
setProductionMemo("");
setProductionDepartmentId("");
setIsProductionDialogOpen(true);
// 부서 목록 로드
if (departments.length === 0) {
const result = await getDepartmentTree();
if (result.success && result.data) {
const flatList = flattenDepartments(result.data);
setDepartments(flatList);
// 디폴트: 생산부서
const defaultDept = flatList.find(d => d.name.includes("생산"));
if (defaultDept) {
setProductionDepartmentId(String(defaultDept.id));
}
}
} else {
// 이미 로드된 경우 디폴트 설정
const defaultDept = departments.find(d => d.name.includes("생산"));
if (defaultDept) {
setProductionDepartmentId(String(defaultDept.id));
}
}
};
// 생산지시 확정 처리
@@ -217,6 +254,7 @@ export default function OrderDetailPage() {
try {
const result = await createProductionOrder(order.id, {
priority: productionPriority,
departmentId: productionDepartmentId ? Number(productionDepartmentId) : undefined,
memo: productionMemo || undefined,
});
if (result.success && result.data) {
@@ -470,7 +508,7 @@ export default function OrderDetailPage() {
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<InfoItem label="로트번호" value={order.lotNumber} />
<InfoItem label="수일" value={order.orderDate} />
<InfoItem label="수일" value={order.orderDate} />
<InfoItem label="수주처" value={order.client} />
<InfoItem label="현장명" value={order.siteName} />
<InfoItem label="담당자" value={order.manager} />
@@ -1255,6 +1293,26 @@ export default function OrderDetailPage() {
</div>
</div>
{/* 부서 */}
<div className="space-y-2">
<Label className="text-base font-medium"></Label>
<Select
value={productionDepartmentId}
onValueChange={setProductionDepartmentId}
>
<SelectTrigger>
<SelectValue placeholder="부서를 선택하세요" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept.id} value={String(dept.id)}>
{dept.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label htmlFor="productionMemo"></Label>

View File

@@ -478,7 +478,7 @@ function OrderListContent() {
{ key: "lotNumber", label: "로트번호", className: "px-2" },
{ key: "siteName", label: "현장명", className: "px-2" },
{ key: "expectedShipDate", label: "출고예정일", className: "px-2" },
{ key: "orderDate", label: "수일", className: "px-2" },
{ key: "orderDate", label: "수일", className: "px-2" },
{ key: "client", label: "수주처", className: "px-2" },
{ key: "productName", label: "제품명", className: "px-2" },
{ key: "receiver", label: "수신자", className: "px-2" },

View File

@@ -24,7 +24,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { SupplierSearchModal } from './SupplierSearchModal';
// import { SupplierSearchModal } from './SupplierSearchModal';
import {
Select,
SelectContent,
@@ -62,6 +62,7 @@ const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
specification: '',
unit: 'EA',
supplier: '',
manufacturer: '',
receivingQty: undefined,
receivingDate: '',
createdBy: '',
@@ -167,6 +168,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
specification: result.data.specification || '',
unit: result.data.unit || 'EA',
supplier: result.data.supplier,
manufacturer: result.data.manufacturer || '',
receivingQty: result.data.receivingQty,
receivingDate: result.data.receivingDate || '',
createdBy: result.data.createdBy || '',
@@ -274,13 +276,14 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('로트번호', detail.lotNo)}
{renderReadOnlyField('품목코드', detail.itemCode)}
{renderReadOnlyField('품목명', detail.itemName)}
{renderReadOnlyField('규격', detail.specification)}
{renderReadOnlyField('단위', detail.unit)}
{renderReadOnlyField('발주처', detail.supplier)}
{renderReadOnlyField('제조사', detail.manufacturer)}
{renderReadOnlyField('입고수량', detail.receivingQty)}
{renderReadOnlyField('입고일', detail.receivingDate)}
{renderReadOnlyField('작성자', detail.createdBy)}
@@ -334,7 +337,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 로트번호 - 읽기전용 */}
{renderReadOnlyField('로트번호', formData.lotNo, true)}
@@ -397,6 +400,20 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
</div>
</div>
{/* 제조사 - 수정가능 */}
<div>
<Label htmlFor="manufacturer" className="text-sm text-muted-foreground">
</Label>
<Input
id="manufacturer"
value={formData.manufacturer || ''}
onChange={(e) => handleInputChange('manufacturer', e.target.value)}
className="mt-1.5"
placeholder="제조사 입력"
/>
</div>
{/* 입고수량 - 수정가능 */}
<div>
<Label htmlFor="receivingQty" className="text-sm text-muted-foreground">
@@ -569,7 +586,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
}}
/>
{/* 발주처 검색 모달 */}
{/* 발주처 검색 모달 - TODO: SupplierSearchModal 컴포넌트 생성 필요
<SupplierSearchModal
open={isSupplierSearchOpen}
onOpenChange={setIsSupplierSearchOpen}
@@ -580,6 +597,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
}));
}}
/>
*/}
{/* 수입검사 성적서 모달 */}
<InspectionModalV2

View File

@@ -14,7 +14,7 @@
'use server';
// ===== 목데이터 모드 플래그 =====
const USE_MOCK_DATA = false;
const USE_MOCK_DATA = true;
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -169,6 +169,7 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
specification: '1000x2000x3T',
unit: 'EA',
supplier: '(주)대한철강',
manufacturer: '포스코',
receivingQty: 100,
receivingDate: '2026-01-26',
createdBy: '김철수',
@@ -190,6 +191,7 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
specification: 'STM32F103C8T6',
unit: 'EA',
supplier: '삼성전자부품',
manufacturer: '삼성전자',
receivingQty: 500,
receivingDate: '2026-01-27',
createdBy: '이영희',
@@ -208,6 +210,7 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
specification: '150x100x50',
unit: 'SET',
supplier: '한국플라스틱',
manufacturer: '한국플라스틱',
receivingQty: undefined,
receivingDate: undefined,
createdBy: '박민수',
@@ -226,6 +229,7 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
specification: '40x40x2000L',
unit: 'EA',
supplier: '(주)대한철강',
manufacturer: '포스코',
receivingQty: 50,
receivingDate: '2026-01-28',
createdBy: '김철수',
@@ -244,6 +248,7 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
specification: '24V 100RPM',
unit: 'EA',
supplier: '글로벌전자',
manufacturer: '글로벌전자',
receivingQty: undefined,
receivingDate: undefined,
createdBy: '최지훈',

View File

@@ -69,6 +69,7 @@ export interface ReceivingDetail {
specification?: string; // 규격 (읽기전용)
unit: string; // 단위 (읽기전용)
supplier: string; // 발주처 (수정가능)
manufacturer?: string; // 제조사 (수정가능)
receivingQty?: number; // 입고수량 (수정가능)
receivingDate?: string; // 입고일 (수정가능)
createdBy?: string; // 작성자 (읽기전용)

View File

@@ -46,7 +46,6 @@ interface AuditItem {
itemName: string;
specification: string;
unit: string;
calculatedQty: number;
actualQty: number;
newActualQty: number;
}
@@ -71,7 +70,6 @@ export function StockAuditModal({
itemName: stock.itemName,
specification: stock.specification || '',
unit: stock.unit,
calculatedQty: stock.calculatedQty,
actualQty: stock.actualQty,
newActualQty: stock.actualQty,
}))
@@ -154,13 +152,12 @@ export function StockAuditModal({
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
.
</TableCell>
</TableRow>
@@ -173,10 +170,9 @@ export function StockAuditModal({
<TableHeader className="sticky top-0 bg-gray-50 z-10">
<TableRow className="bg-gray-50">
<TableHead className="text-center font-medium w-[15%]"></TableHead>
<TableHead className="text-center font-medium w-[25%]"></TableHead>
<TableHead className="text-center font-medium w-[15%]"></TableHead>
<TableHead className="text-center font-medium w-[8%]"></TableHead>
<TableHead className="text-center font-medium w-[12%]"> </TableHead>
<TableHead className="text-center font-medium w-[30%]"></TableHead>
<TableHead className="text-center font-medium w-[20%]"></TableHead>
<TableHead className="text-center font-medium w-[10%]"></TableHead>
<TableHead className="text-center font-medium w-[15%]"> </TableHead>
</TableRow>
</TableHeader>
@@ -191,9 +187,6 @@ export function StockAuditModal({
</TableCell>
<TableCell className="text-center">{item.specification || '-'}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center text-gray-600">
{item.calculatedQty}
</TableCell>
<TableCell className="text-center">
<Input
type="number"

View File

@@ -471,7 +471,7 @@ export function OrderRegistration({
</div>
<div className="space-y-2">
<Label></Label>
<Label></Label>
<Input
value=""
disabled

View File

@@ -383,7 +383,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
<p className="font-medium">{form.lotNumber}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.orderDate || "-"}</p>
</div>
<div className="space-y-1">

View File

@@ -346,6 +346,7 @@ export interface CreateProductionOrderData {
assigneeId?: number;
assigneeIds?: number[]; // 다중 담당자 선택용
teamId?: number;
departmentId?: number; // 부서 ID
scheduledDate?: string;
memo?: string;
}
@@ -1072,6 +1073,7 @@ export async function createProductionOrder(
apiData.assignee_id = data.assigneeId;
}
if (data?.teamId) apiData.team_id = data.teamId;
if (data?.departmentId) apiData.department_id = data.departmentId;
if (data?.scheduledDate) apiData.scheduled_date = data.scheduledDate;
if (data?.memo) apiData.memo = data.memo;

View File

@@ -161,29 +161,21 @@ export function SalesOrderDocument({
</table>
</div>
{/* 로트번호 / 인정번호 */}
<div className="flex justify-end gap-8 mb-3 text-[10px]">
<div className="flex items-center gap-2 border border-gray-400 px-3 py-1">
<span className="bg-gray-100 px-2 py-0.5 font-medium"></span>
<span>{orderNumber}</span>
</div>
<div className="flex items-center gap-2 border border-gray-400 px-3 py-1">
<span className="bg-gray-100 px-2 py-0.5 font-medium"></span>
<span>{certificationNumber}</span>
</div>
</div>
{/* 상품명/제품명 라인 */}
<div className="flex gap-4 mb-3 text-[10px]">
<div className="flex items-center gap-2">
<span className="font-medium"></span>
<span>{products[0]?.productCategory || "screen"}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium"></span>
<span>{products[0]?.productName || "-"}</span>
</div>
</div>
{/* 상품명 / 제품명 / 로트번호 / 인정번호 */}
<table className="border border-gray-400 w-full mb-3 text-[10px]">
<tbody>
<tr>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-400 whitespace-nowrap"></td>
<td className="px-2 py-1 border-r border-gray-400">{products[0]?.productCategory || "-"}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-400 whitespace-nowrap"></td>
<td className="px-2 py-1 border-r border-gray-400">{products[0]?.productName || "-"}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-400 whitespace-nowrap"></td>
<td className="px-2 py-1 border-r border-gray-400">{orderNumber}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-400 whitespace-nowrap"></td>
<td className="px-2 py-1">{certificationNumber}</td>
</tr>
</tbody>
</table>
{/* 3열 섹션: 신청업체 | 신청내용 | 납품정보 */}
<div className="border border-gray-400 mb-4">

View File

@@ -1,17 +1,25 @@
'use client';
import { useState, useMemo } from 'react';
/**
* 공정 상세 페이지 (리디자인)
*
* 기획서 스크린샷 1 기준:
* - 기본 정보: 공정번호, 공정형, 담당부서, 담당자, 생산일자, 상태
* - 품목 설정 정보: 품목 선택 버튼 + 개수 표시
* - 단계 테이블: 드래그&드롭 순서변경 + 단계 등록 버튼
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { List, Edit, Wrench, Package, ArrowLeft } from 'lucide-react';
import { ArrowLeft, Edit, GripVertical, Plus, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal';
import { useMenuStore } from '@/store/menuStore';
import type { Process } from '@/types/process';
import { MATCHING_TYPE_OPTIONS } from '@/types/process';
import { getProcessSteps } from './actions';
import type { Process, ProcessStep } from '@/types/process';
interface ProcessDetailProps {
process: Process;
@@ -19,26 +27,36 @@ interface ProcessDetailProps {
export function ProcessDetail({ process }: ProcessDetailProps) {
const router = useRouter();
const [workLogModalOpen, setWorkLogModalOpen] = useState(false);
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
// 패턴 규칙과 개별 품목 분리
const { patternRules, individualItems } = useMemo(() => {
const patterns = process.classificationRules.filter(
(rule) => rule.registrationType === 'pattern'
);
const individuals = process.classificationRules.filter(
(rule) => rule.registrationType === 'individual'
);
return { patternRules: patterns, individualItems: individuals };
}, [process.classificationRules]);
// 단계 목록 상태
const [steps, setSteps] = useState<ProcessStep[]>([]);
const [isStepsLoading, setIsStepsLoading] = useState(true);
// 매칭 타입 라벨
const getMatchingTypeLabel = (type: string) => {
const option = MATCHING_TYPE_OPTIONS.find((opt) => opt.value === type);
return option?.label || type;
};
// 드래그 상태
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const dragNodeRef = useRef<HTMLTableRowElement | null>(null);
// 품목 개수 계산 (기존 classificationRules에서 individual 품목)
const itemCount = process.classificationRules
.filter((r) => r.registrationType === 'individual')
.reduce((sum, r) => sum + (r.items?.length || 0), 0);
// 단계 목록 로드
useEffect(() => {
const loadSteps = async () => {
setIsStepsLoading(true);
const result = await getProcessSteps(process.id);
if (result.success && result.data) {
setSteps(result.data);
}
setIsStepsLoading(false);
};
loadSteps();
}, [process.id]);
// 네비게이션
const handleEdit = () => {
router.push(`/ko/master-data/process-management/${process.id}?mode=edit`);
};
@@ -47,17 +65,61 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
router.push('/ko/master-data/process-management');
};
const handleViewWorkLog = () => {
setWorkLogModalOpen(true);
const handleAddStep = () => {
router.push(`/ko/master-data/process-management/${process.id}/steps/new`);
};
const handleStepClick = (stepId: string) => {
router.push(`/ko/master-data/process-management/${process.id}/steps/${stepId}`);
};
// ===== 드래그&드롭 (HTML5 네이티브) =====
const handleDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
setDragIndex(index);
dragNodeRef.current = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
// 약간의 딜레이로 드래그 시작 시 스타일 적용
requestAnimationFrame(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '0.4';
}
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
}, []);
const handleDragEnd = useCallback(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '1';
}
setDragIndex(null);
setDragOverIndex(null);
dragNodeRef.current = null;
}, []);
const handleDrop = useCallback((e: React.DragEvent<HTMLTableRowElement>, dropIndex: number) => {
e.preventDefault();
if (dragIndex === null || dragIndex === dropIndex) return;
setSteps((prev) => {
const updated = [...prev];
const [moved] = updated.splice(dragIndex, 1);
updated.splice(dropIndex, 0, moved);
// 순서 재할당
return updated.map((step, i) => ({ ...step, order: i + 1 }));
});
handleDragEnd();
}, [dragIndex, handleDragEnd]);
return (
<PageLayout>
{/* 헤더 */}
<PageHeader
title="공정 상세"
icon={Wrench}
/>
<PageHeader title="공정 상세" />
<div className="space-y-6 pb-24">
{/* 기본 정보 */}
@@ -66,202 +128,199 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
<div className="space-y-1 lg:col-span-2">
<div className="text-sm text-muted-foreground"></div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.processCode}</div>
</div>
<div className="space-y-1 lg:col-span-2">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.processName}</div>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground"></div>
<Badge variant="secondary">{process.processType}</Badge>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.department}</div>
<div className="font-medium">{process.department || '-'}</div>
</div>
<div className="space-y-1 lg:col-span-2">
<div className="text-sm text-muted-foreground"> </div>
<div className="flex items-center gap-2">
<span className="font-medium">
{process.workLogTemplate || '-'}
</span>
{process.workLogTemplate && (
<Button variant="outline" size="sm" onClick={handleViewWorkLog}>
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 등록 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
<div className="space-y-1 lg:col-span-3">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.createdAt}</div>
</div>
<div className="space-y-1 lg:col-span-3">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.updatedAt}</div>
</div>
</div>
</CardContent>
</Card>
{/* 자동 분류 규칙 (패턴 기반) */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
{patternRules.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Wrench className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-3">
{patternRules.map((rule) => (
<div
key={rule.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-4">
<Badge variant={rule.isActive ? 'default' : 'secondary'}>
{rule.isActive ? '활성' : '비활성'}
</Badge>
<div>
<div className="font-medium">
{rule.ruleType} · {getMatchingTypeLabel(rule.matchingType)} · "{rule.conditionValue}"
</div>
{rule.description && (
<div className="text-sm text-muted-foreground">
{rule.description}
</div>
)}
</div>
</div>
<Badge variant="outline">: {rule.priority}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 개별 품목 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
{individualItems.length > 0 && individualItems[0].items && (
<Badge variant="secondary" className="ml-2">
{individualItems[0].items.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
{individualItems.length === 0 || !individualItems[0].items?.length ? (
<div className="text-center py-8 text-muted-foreground">
<Package className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<div className="max-h-80 overflow-y-auto">
<div className="space-y-2">
{individualItems[0].items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-3 border rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<Badge variant="outline" className="font-mono text-xs">
{item.code}
</Badge>
<span className="font-medium">{item.name}</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* 세부 작업단계 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{process.workSteps.length > 0 ? (
<div className="flex items-center gap-2 flex-wrap">
{process.workSteps.map((step, index) => (
<div key={index} className="flex items-center gap-2">
<Badge
variant="outline"
className="px-3 py-1.5 bg-muted/50"
>
<span className="bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center text-xs mr-2">
{index + 1}
</span>
{step}
</Badge>
{index < process.workSteps.length - 1 && (
<span className="text-muted-foreground">{'>'}</span>
)}
</div>
))}
</div>
) : (
<div className="text-muted-foreground">-</div>
)}
</CardContent>
</Card>
{/* 작업 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.requiredWorkers}</div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.manager || '-'}</div>
</div>
<div className="space-y-1 lg:col-span-2">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.equipmentInfo || '-'}</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">
{process.useProductionDate ? '사용' : '미사용'}
</div>
</div>
<div className="space-y-1 lg:col-span-3">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{process.description || '-'}</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<Badge variant={process.status === '사용중' ? 'default' : 'secondary'}>
{process.status}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* 품목 설정 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm">
{itemCount}
</Badge>
<Button variant="outline" size="sm" onClick={handleEdit}>
</Button>
</div>
</div>
</CardHeader>
</Card>
{/* 단계 테이블 */}
<Card>
<CardHeader className="bg-muted/50">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{!isStepsLoading && (
<span className="text-sm font-normal text-muted-foreground ml-2">
{steps.length}
</span>
)}
</CardTitle>
<Button size="sm" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{isStepsLoading ? (
<div className="p-8 text-center text-muted-foreground">
...
</div>
) : steps.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
. [ ] .
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/30">
<th className="w-10 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
{/* 드래그 핸들 헤더 */}
</th>
<th className="w-14 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
No.
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="w-16 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{steps.map((step, index) => (
<tr
key={step.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, index)}
onClick={() => handleStepClick(step.id)}
className={`border-b cursor-pointer transition-colors hover:bg-muted/50 ${
dragOverIndex === index && dragIndex !== index
? 'border-t-2 border-t-primary'
: ''
}`}
>
<td
className="w-10 px-3 py-3 text-center cursor-grab active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
</td>
<td className="w-14 px-3 py-3 text-center text-sm text-muted-foreground">
{index + 1}
</td>
<td className="px-3 py-3 text-sm font-mono">
{step.stepCode}
</td>
<td className="px-3 py-3 text-sm font-medium">
{step.stepName}
</td>
<td className="w-20 px-3 py-3 text-center">
<Badge
variant={step.isRequired ? 'default' : 'outline'}
className="text-xs"
>
{step.isRequired ? 'Y' : 'N'}
</Badge>
</td>
<td className="w-20 px-3 py-3 text-center">
<Badge
variant={step.needsApproval ? 'default' : 'outline'}
className="text-xs"
>
{step.needsApproval ? 'Y' : 'N'}
</Badge>
</td>
<td className="w-20 px-3 py-3 text-center">
<Badge
variant={step.needsInspection ? 'default' : 'outline'}
className="text-xs"
>
{step.needsInspection ? 'Y' : 'N'}
</Badge>
</td>
<td className="w-16 px-3 py-3 text-center">
<Badge
variant={step.isActive ? 'default' : 'secondary'}
className="text-xs"
>
{step.isActive ? 'Y' : 'N'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
<div
className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}
>
<Button variant="outline" onClick={handleList}>
<ArrowLeft className="h-4 w-4 mr-2" />
@@ -271,13 +330,6 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
</Button>
</div>
{/* 작업일지 양식 미리보기 모달 */}
<ProcessWorkLogPreviewModal
open={workLogModalOpen}
onOpenChange={setWorkLogModalOpen}
process={process}
/>
</PageLayout>
);
}
}

View File

@@ -1,21 +1,24 @@
'use client';
/**
* 공정 등록/수정 폼 컴포넌트
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
* 공정 등록/수정 폼 컴포넌트 (리디자인)
*
* 기획서 스크린샷 1 기준:
* - 기본 정보: 공정명(자동생성), 공정형, 담당부서, 담당자, 생산일자, 상태
* - 품목 설정 정보: 품목 선택 팝업 연동
* - 단계 관리: 단계 등록/수정/삭제 (인라인)
*
* 제거된 섹션: 자동분류규칙, 작업정보, 설명
*/
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Wrench, Trash2, Pencil } from 'lucide-react';
import { Plus, GripVertical, Trash2, Package } from 'lucide-react';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { processCreateConfig, processEditConfig } from './processConfig';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { QuantityInput } from '@/components/ui/quantity-input';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
@@ -25,20 +28,18 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { RuleModal } from './RuleModal';
import { toast } from 'sonner';
import type { Process, ClassificationRule, ProcessType } from '@/types/process';
import { PROCESS_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process';
import { createProcess, updateProcess, getDepartmentOptions, type DepartmentOption } from './actions';
// 작업일지 양식 옵션 (추후 API 연동 가능)
const WORK_LOG_OPTIONS = [
{ value: '스크린 작업일지', label: '스크린 작업일지' },
{ value: '절곡 작업일지', label: '절곡 작업일지' },
{ value: '슬랫 작업일지', label: '슬랫 작업일지' },
{ value: '재고생산 작업일지', label: '재고생산 작업일지' },
{ value: '포장 작업일지', label: '포장 작업일지' },
];
import type { Process, ClassificationRule, ProcessType, ProcessStep } from '@/types/process';
import { PROCESS_TYPE_OPTIONS } from '@/types/process';
import {
createProcess,
updateProcess,
getDepartmentOptions,
getProcessSteps,
type DepartmentOption,
} from './actions';
interface ProcessFormProps {
mode: 'create' | 'edit';
@@ -49,36 +50,48 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
const router = useRouter();
const isEdit = mode === 'edit';
// 상태
// 기본 정보 상태
const [processName, setProcessName] = useState(initialData?.processName || '');
const [processType, setProcessType] = useState<ProcessType>(
initialData?.processType || '생산'
);
const [department, setDepartment] = useState(initialData?.department || '');
const [workLogTemplate, setWorkLogTemplate] = useState(
initialData?.workLogTemplate || ''
const [manager, setManager] = useState(initialData?.manager || '');
const [useProductionDate, setUseProductionDate] = useState(
initialData?.useProductionDate ?? false
);
const [isActive, setIsActive] = useState(
initialData ? initialData.status === '사용중' : true
);
const [isLoading, setIsLoading] = useState(false);
// 품목 분류 규칙 (기존 로직 유지)
const [classificationRules, setClassificationRules] = useState<ClassificationRule[]>(
initialData?.classificationRules || []
);
const [requiredWorkers, setRequiredWorkers] = useState(
initialData?.requiredWorkers || 1
);
const [equipmentInfo, setEquipmentInfo] = useState(initialData?.equipmentInfo || '');
const [workSteps, setWorkSteps] = useState(initialData?.workSteps?.join(', ') || '');
const [note, setNote] = useState(initialData?.note || '');
const [isActive, setIsActive] = useState(initialData ? initialData.status === '사용중' : true);
const [isLoading, setIsLoading] = useState(false);
// 단계 목록 상태
const [steps, setSteps] = useState<ProcessStep[]>([]);
const [isStepsLoading, setIsStepsLoading] = useState(isEdit);
// 부서 목록 상태
const [departmentOptions, setDepartmentOptions] = useState<DepartmentOption[]>([]);
const [isDepartmentsLoading, setIsDepartmentsLoading] = useState(true);
// 규칙 모달 상태
// 품목 선택 모달 상태
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<ClassificationRule | undefined>(undefined);
// 부서 목록 로
// 드래그 상태
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// 품목 개수 계산
const itemCount = classificationRules
.filter((r) => r.registrationType === 'individual')
.reduce((sum, r) => sum + (r.items?.length || 0), 0);
// 부서 목록 + 단계 목록 로드
useEffect(() => {
const loadDepartments = async () => {
setIsDepartmentsLoading(true);
@@ -89,20 +102,28 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
loadDepartments();
}, []);
// 규칙 추가/수정
useEffect(() => {
if (isEdit && initialData?.id) {
const loadSteps = async () => {
setIsStepsLoading(true);
const result = await getProcessSteps(initialData.id);
if (result.success && result.data) {
setSteps(result.data);
}
setIsStepsLoading(false);
};
loadSteps();
}
}, [isEdit, initialData?.id]);
// 품목 규칙 추가/수정
const handleSaveRule = useCallback(
(ruleData: Omit<ClassificationRule, 'id' | 'createdAt'>) => {
if (editingRule) {
// 수정 모드
setClassificationRules((prev) =>
prev.map((r) =>
r.id === editingRule.id
? { ...r, ...ruleData }
: r
)
prev.map((r) => (r.id === editingRule.id ? { ...r, ...ruleData } : r))
);
} else {
// 추가 모드
const newRule: ClassificationRule = {
...ruleData,
id: `rule-${Date.now()}`,
@@ -115,18 +136,6 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
[editingRule]
);
// 규칙 수정 모달 열기
const handleEditRule = useCallback((rule: ClassificationRule) => {
setEditingRule(rule);
setRuleModalOpen(true);
}, []);
// 규칙 삭제
const handleDeleteRule = useCallback((ruleId: string) => {
setClassificationRules((prev) => prev.filter((r) => r.id !== ruleId));
}, []);
// 모달 닫기
const handleModalClose = useCallback((open: boolean) => {
setRuleModalOpen(open);
if (!open) {
@@ -134,22 +143,91 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
}
}, []);
// 단계 삭제
const handleDeleteStep = useCallback((stepId: string) => {
setSteps((prev) => prev.filter((s) => s.id !== stepId));
}, []);
// 단계 상세 이동
const handleStepClick = (stepId: string) => {
if (isEdit && initialData?.id) {
router.push(`/ko/master-data/process-management/${initialData.id}/steps/${stepId}`);
}
};
// 단계 등록 이동
const handleAddStep = () => {
if (isEdit && initialData?.id) {
router.push(`/ko/master-data/process-management/${initialData.id}/steps/new`);
} else {
toast.info('공정을 먼저 등록한 후 단계를 추가할 수 있습니다.');
}
};
// 드래그&드롭
const dragNodeRef = useRef<HTMLElement | null>(null);
const handleDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
setDragIndex(index);
dragNodeRef.current = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
requestAnimationFrame(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '0.4';
}
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
}, []);
const handleDragEnd = useCallback(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '1';
}
dragNodeRef.current = null;
setDragIndex(null);
setDragOverIndex(null);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLTableRowElement>, dropIndex: number) => {
e.preventDefault();
if (dragIndex === null || dragIndex === dropIndex) {
handleDragEnd();
return;
}
setSteps((prev) => {
const updated = [...prev];
const [moved] = updated.splice(dragIndex, 1);
updated.splice(dropIndex, 0, moved);
return updated.map((step, i) => ({ ...step, order: i + 1 }));
});
handleDragEnd();
},
[dragIndex, handleDragEnd]
);
// 제출
const handleSubmit = async () => {
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
if (!processName.trim()) {
toast.error('공정명을 입력해주세요.');
return;
return { success: false, error: '공정명을 입력해주세요.' };
}
if (!department) {
toast.error('담당부서를 선택해주세요.');
return;
return { success: false, error: '담당부서를 선택해주세요.' };
}
const formData = {
processName: processName.trim(),
processType,
department,
workLogTemplate: workLogTemplate || undefined,
classificationRules: classificationRules.map((rule) => ({
registrationType: rule.registrationType,
ruleType: rule.ruleType,
@@ -159,10 +237,8 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
description: rule.description,
isActive: rule.isActive,
})),
requiredWorkers,
equipmentInfo: equipmentInfo.trim() || undefined,
workSteps: workSteps,
note: note.trim() || undefined,
requiredWorkers: 1,
workSteps: '',
isActive,
};
@@ -173,294 +249,343 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
if (result.success) {
toast.success('공정이 수정되었습니다.');
router.push('/ko/master-data/process-management');
return { success: true };
} else {
toast.error(result.error || '수정에 실패했습니다.');
return { success: false, error: result.error };
}
} else {
const result = await createProcess(formData);
if (result.success) {
toast.success('공정이 등록되었습니다.');
router.push('/ko/master-data/process-management');
return { success: true };
} else {
toast.error(result.error || '등록에 실패했습니다.');
return { success: false, error: result.error };
}
}
} catch {
toast.error('처리 중 오류가 발생했습니다.');
return { success: false, error: '처리 중 오류가 발생했습니다.' };
} finally {
setIsLoading(false);
}
};
// 취소
const handleCancel = () => {
router.back();
};
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = useCallback(() => (
<>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */}
{/* 4개 필드 → 2+1+2+1 = 6열 채움 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
<div className="space-y-2 lg:col-span-2">
<Label htmlFor="processName"> *</Label>
<Input
id="processName"
value={processName}
onChange={(e) => setProcessName(e.target.value)}
placeholder="예: 스크린"
/>
const renderFormContent = useCallback(
() => (
<>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
<div className="space-y-2">
<Label htmlFor="processName"> *</Label>
<Input
id="processName"
value={processName}
onChange={(e) => setProcessName(e.target.value)}
placeholder="예: 스크린"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={processType}
onValueChange={(v) => setProcessType(v as ProcessType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROCESS_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={department}
onValueChange={setDepartment}
disabled={isDepartmentsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={isDepartmentsLoading ? '로딩 중...' : '선택하세요'}
/>
</SelectTrigger>
<SelectContent>
{departmentOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={manager}
onChange={(e) => setManager(e.target.value)}
placeholder="담당자명"
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2 h-10">
<Switch
checked={useProductionDate}
onCheckedChange={setUseProductionDate}
/>
<span className="text-sm text-muted-foreground">
{useProductionDate ? '사용' : '미사용'}
</span>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={isActive ? '사용중' : '미사용'}
onValueChange={(v) => setIsActive(v === '사용중')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="사용중"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={processType}
onValueChange={(v) => setProcessType(v as ProcessType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROCESS_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 lg:col-span-2">
<Label> *</Label>
<Select value={department} onValueChange={setDepartment} disabled={isDepartmentsLoading}>
<SelectTrigger>
<SelectValue placeholder={isDepartmentsLoading ? "로딩 중..." : "선택하세요"} />
</SelectTrigger>
<SelectContent>
{departmentOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select value={workLogTemplate} onValueChange={setWorkLogTemplate}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{WORK_LOG_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* 자동 분류 규칙 */}
<Card>
<CardHeader className="bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base"> </CardTitle>
<p className="text-sm text-muted-foreground mt-1">
.
</p>
{/* 품목 설정 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm">
{itemCount}
</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRuleModalOpen(true)}
>
</Button>
</div>
</div>
<Button onClick={() => setRuleModalOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent className="pt-6">
{classificationRules.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Wrench className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="font-medium"> </p>
<p className="text-sm mt-1">
</p>
</div>
) : (
<div className="space-y-3">
{classificationRules.map((rule, index) => {
// 개별 품목인 경우 품목 개수 계산
const isIndividual = rule.registrationType === 'individual';
const itemCount = isIndividual
? rule.conditionValue.split(',').filter(Boolean).length
: 0;
</CardHeader>
</Card>
return (
<div
key={rule.id}
className="flex items-start justify-between p-4 border rounded-lg"
>
<div className="flex gap-3">
{/* 번호 */}
<span className="text-muted-foreground font-medium mt-0.5">
{index + 1}.
</span>
<div className="space-y-1">
{/* 제목 */}
<div className="font-medium">
{isIndividual ? (
<> - {itemCount} </>
) : (
<>
{rule.ruleType}{' '}
{
MATCHING_TYPE_OPTIONS.find(
(o) => o.value === rule.matchingType
)?.label
}{' '}
"{rule.conditionValue}"
</>
)}
</div>
{/* 뱃지 + 우선순위 */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{isIndividual
? `${itemCount}개 품목 배정됨`
: rule.isActive
? '활성'
: '비활성'}
{/* 단계 테이블 */}
<Card>
<CardHeader className="bg-muted/50">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{!isStepsLoading && (
<span className="text-sm font-normal text-muted-foreground ml-2">
{steps.length}
</span>
)}
</CardTitle>
<Button type="button" size="sm" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{isStepsLoading ? (
<div className="p-8 text-center text-muted-foreground"> ...</div>
) : steps.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{isEdit
? '등록된 단계가 없습니다. [단계 등록] 버튼으로 추가해주세요.'
: '공정을 먼저 등록한 후 단계를 추가할 수 있습니다.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/30">
<th className="w-10 px-3 py-3" />
<th className="w-14 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
No.
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="w-20 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="w-16 px-3 py-3 text-center text-xs font-medium text-muted-foreground">
</th>
<th className="w-12 px-3 py-3" />
</tr>
</thead>
<tbody>
{steps.map((step, index) => (
<tr
key={step.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, index)}
onClick={() => handleStepClick(step.id)}
className={`border-b cursor-pointer transition-colors hover:bg-muted/50 ${
dragOverIndex === index && dragIndex !== index
? 'border-t-2 border-t-primary'
: ''
}`}
>
<td
className="w-10 px-3 py-3 text-center cursor-grab active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
</td>
<td className="w-14 px-3 py-3 text-center text-sm text-muted-foreground">
{index + 1}
</td>
<td className="px-3 py-3 text-sm font-mono">{step.stepCode}</td>
<td className="px-3 py-3 text-sm font-medium">{step.stepName}</td>
<td className="w-20 px-3 py-3 text-center">
<Badge
variant={step.isRequired ? 'default' : 'outline'}
className="text-xs"
>
{step.isRequired ? 'Y' : 'N'}
</Badge>
<span className="text-sm text-muted-foreground">
: {rule.priority}
</span>
</div>
{/* 설명 */}
<div className="text-sm text-muted-foreground">
{isIndividual
? `직접 선택한 품목 ${itemCount}`
: rule.description || ''}
</div>
</div>
</div>
{/* 수정/삭제 버튼 */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditRule(rule)}
className="h-8 w-8"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteRule(rule.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</td>
<td className="w-20 px-3 py-3 text-center">
<Badge
variant={step.needsApproval ? 'default' : 'outline'}
className="text-xs"
>
{step.needsApproval ? 'Y' : 'N'}
</Badge>
</td>
<td className="w-20 px-3 py-3 text-center">
<Badge
variant={step.needsInspection ? 'default' : 'outline'}
className="text-xs"
>
{step.needsInspection ? 'Y' : 'N'}
</Badge>
</td>
<td className="w-16 px-3 py-3 text-center">
<Badge
variant={step.isActive ? 'default' : 'secondary'}
className="text-xs"
>
{step.isActive ? 'Y' : 'N'}
</Badge>
</td>
<td className="w-12 px-3 py-3 text-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteStep(step.id);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
{/* 작업 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
<div className="space-y-2">
<Label></Label>
<QuantityInput
value={requiredWorkers}
onChange={(value) => setRequiredWorkers(value ?? 1)}
min={1}
/>
</div>
<div className="space-y-2 lg:col-span-2">
<Label></Label>
<Input
value={equipmentInfo}
onChange={(e) => setEquipmentInfo(e.target.value)}
placeholder="예: 미싱기 3대, 절단기 1대"
/>
</div>
<div className="space-y-2 lg:col-span-3">
<Label> ( )</Label>
<Input
value={workSteps}
onChange={(e) => setWorkSteps(e.target.value)}
placeholder="예: 원단절단, 미싱, 핸드작업, 중간검사, 포장"
/>
</div>
</div>
</CardContent>
</Card>
{/* 품목 선택 모달 */}
<RuleModal
open={ruleModalOpen}
onOpenChange={handleModalClose}
onAdd={handleSaveRule}
editRule={editingRule}
/>
</>
),
[
processName,
processType,
department,
manager,
useProductionDate,
isActive,
classificationRules,
steps,
isStepsLoading,
ruleModalOpen,
editingRule,
departmentOptions,
isDepartmentsLoading,
itemCount,
dragIndex,
dragOverIndex,
handleSaveRule,
handleModalClose,
handleDeleteStep,
handleAddStep,
handleStepClick,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
isEdit,
initialData?.id,
]
);
{/* 설명 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-6">
<div className="space-y-2">
<Label></Label>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="공정에 대한 설명"
rows={4}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={isActive}
onCheckedChange={(checked) => setIsActive(checked as boolean)}
/>
<Label htmlFor="isActive" className="font-normal">
</Label>
</div>
</CardContent>
</Card>
</div>
{/* 규칙 추가/수정 모달 */}
<RuleModal
open={ruleModalOpen}
onOpenChange={handleModalClose}
onAdd={handleSaveRule}
editRule={editingRule}
/>
</>
), [
processName, processType, department, workLogTemplate, classificationRules,
requiredWorkers, equipmentInfo, workSteps, note, isActive, ruleModalOpen,
editingRule, departmentOptions, isDepartmentsLoading, handleSaveRule,
handleEditRule, handleDeleteRule, handleModalClose,
]);
// Config 선택 (create/edit)
const config = isEdit ? processEditConfig : processCreateConfig;
return (
@@ -468,8 +593,6 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
config={config}
mode={isEdit ? 'edit' : 'create'}
isLoading={isDepartmentsLoading}
isSubmitting={isLoading}
onBack={handleCancel}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}

View File

@@ -11,8 +11,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Wrench, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Wrench, Plus } from 'lucide-react';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
@@ -21,7 +20,6 @@ import {
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type TabOption,
type ListParams,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
@@ -179,13 +177,6 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
}
}, [allProcesses]);
// ===== 탭 옵션 =====
const tabs: TabOption[] = useMemo(() => [
{ value: 'all', label: '전체', count: stats.total },
{ value: '사용중', label: '사용중', count: stats.active },
{ value: '미사용', label: '미사용', count: stats.inactive },
], [stats]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Process> = useMemo(
() => ({
@@ -242,14 +233,12 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
// 테이블 컬럼
columns: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'processCode', label: '공정코드', className: 'w-[100px]' },
{ key: 'processName', label: '공정명', className: 'min-w-[250px]' },
{ key: 'processType', label: '구분', className: 'w-[80px] text-center' },
{ key: 'processCode', label: '공정번호', className: 'w-[120px]' },
{ key: 'processName', label: '공정명', className: 'min-w-[200px]' },
{ key: 'department', label: '담당부서', className: 'w-[120px]' },
{ key: 'classificationRules', label: '분류규칙', className: 'w-[80px] text-center' },
{ key: 'requiredWorkers', label: '인원', className: 'w-[60px] text-center' },
{ key: 'steps', label: '단계', className: 'w-[80px] text-center' },
{ key: 'items', label: '품목', className: 'w-[80px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
],
// 클라이언트 사이드 필터링
@@ -271,10 +260,26 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
onEndDateChange: setEndDate,
},
// 필터 (공통 컴포넌트에서 처리)
tabFilter: (item, tabValue) => {
if (tabValue === 'all') return true;
return item.status === tabValue;
// 필터 설정 (상태 필터)
filterConfig: [
{
key: 'status',
label: '상태',
type: 'single' as const,
options: [
{ value: '사용중', label: '사용' },
{ value: '미사용', label: '미사용' },
],
allOptionLabel: '전체',
},
],
initialFilters: { status: '' },
// 커스텀 필터 함수 (상태 필터)
customFilterFn: (items: Process[], filterValues: Record<string, string | string[]>) => {
const statusFilter = filterValues.status as string;
if (!statusFilter) return items;
return items.filter(item => item.status === statusFilter);
},
// 검색 필터
@@ -288,10 +293,6 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
);
},
// 탭 (공통 컴포넌트에서 Card 안에 렌더링)
tabs,
defaultTab: 'all',
// 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링)
createButton: {
label: '공정 등록',
@@ -309,6 +310,9 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Process>
) => {
const itemCount = process.classificationRules
.filter(r => r.registrationType === 'individual')
.reduce((sum, r) => sum + (r.items?.length || 0), 0);
return (
<TableRow
key={process.id}
@@ -323,63 +327,19 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{process.processCode}</TableCell>
<TableCell>
<div>
<div className="font-medium">{process.processName}</div>
{process.description && (
<div className="text-sm text-muted-foreground">{process.description}</div>
)}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{process.processType}</Badge>
</TableCell>
<TableCell>{process.processName}</TableCell>
<TableCell>{process.department}</TableCell>
<TableCell className="text-center">
{process.classificationRules.length > 0 ? (
<Badge variant="outline">{process.classificationRules.length}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-center">{process.requiredWorkers}</TableCell>
<TableCell className="text-center">{process.workSteps.length}</TableCell>
<TableCell className="text-center">{itemCount > 0 ? itemCount : '-'}</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Badge
variant={process.status === '사용중' ? 'default' : 'secondary'}
className="cursor-pointer"
onClick={() => handleToggleStatus(process.id)}
>
{process.status}
{process.status === '사용중' ? '사용' : '미사용'}
</Badge>
</TableCell>
<TableCell className="text-center">
{handlers.isSelected && (
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleEdit(process);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(process.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
@@ -391,6 +351,9 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Process>
) => {
const itemCount = process.classificationRules
.filter(r => r.registrationType === 'individual')
.reduce((sum, r) => sum + (r.items?.length || 0), 0);
return (
<ListMobileCard
key={process.id}
@@ -414,60 +377,21 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
handleToggleStatus(process.id);
}}
>
{process.status}
{process.status === '사용중' ? '사용' : '미사용'}
</Badge>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="구분" value={process.processType} />
<InfoField label="담당부서" value={process.department} />
<InfoField label="인원" value={`${process.requiredWorkers}`} />
<InfoField
label="분류규칙"
value={process.classificationRules.length > 0 ? `${process.classificationRules.length}` : '-'}
/>
{process.description && (
<div className="col-span-2">
<InfoField label="설명" value={process.description} />
</div>
)}
<InfoField label="단계" value={`${process.workSteps.length}`} />
<InfoField label="품목" value={itemCount > 0 ? `${itemCount}` : '-'} />
</div>
}
actions={
handlers.isSelected ? (
<div className="flex gap-2">
<Button
variant="default"
size="default"
className="flex-1 h-11"
onClick={(e) => {
e.stopPropagation();
handleEdit(process);
}}
>
<Pencil className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(process.id);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},
}),
[tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery]
[handleCreate, handleRowClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery]
);
return (

View File

@@ -0,0 +1,139 @@
'use client';
/**
* 단계 상세 뷰 컴포넌트
*
* 기획서 스크린샷 2 기준:
* - 기본 정보: 단계코드, 단계명, 필수여부, 승인여부, 검사여부, 상태
* - 연결 정보: 유형(팝업/없음), 도달(입고완료 자재 목록 등)
* - 완료 정보: 유형(선택 완료 시 완료/클릭 시 완료)
*/
import { useRouter } from 'next/navigation';
import { ArrowLeft, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import type { ProcessStep } from '@/types/process';
interface StepDetailProps {
step: ProcessStep;
processId: string;
}
export function StepDetail({ step, processId }: StepDetailProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const handleEdit = () => {
router.push(
`/ko/master-data/process-management/${processId}/steps/${step.id}?mode=edit`
);
};
const handleBack = () => {
router.push(`/ko/master-data/process-management/${processId}`);
};
return (
<PageLayout>
<PageHeader title="단계 상세" />
<div className="space-y-6 pb-24">
{/* 기본 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium font-mono">{step.stepCode}</div>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{step.stepName}</div>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<Badge variant={step.isRequired ? 'default' : 'outline'}>
{step.isRequired ? '필수' : '선택'}
</Badge>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<Badge variant={step.needsApproval ? 'default' : 'outline'}>
{step.needsApproval ? '필요' : '불필요'}
</Badge>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<Badge variant={step.needsInspection ? 'default' : 'outline'}>
{step.needsInspection ? '필요' : '불필요'}
</Badge>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<Badge variant={step.isActive ? 'default' : 'secondary'}>
{step.isActive ? '사용' : '미사용'}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* 연결 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{step.connectionType}</div>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{step.connectionTarget || '-'}</div>
</div>
</div>
</CardContent>
</Card>
{/* 완료 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-1">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{step.completionType}</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 하단 액션 버튼 (sticky) */}
<div
className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}
>
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
/**
* 단계 상세 클라이언트 컴포넌트
*
* 라우팅:
* - /[id]/steps/[stepId] → 상세 보기
* - /[id]/steps/[stepId]?mode=edit → 수정
* - /[id]/steps/new → 등록
*/
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { StepDetail } from './StepDetail';
import { StepForm } from './StepForm';
import { getProcessStepById } from './actions';
import type { ProcessStep } from '@/types/process';
import { DetailPageSkeleton } from '@/components/ui/skeleton';
import { ErrorCard } from '@/components/ui/error-card';
import { toast } from 'sonner';
type DetailMode = 'view' | 'edit' | 'create';
interface StepDetailClientProps {
processId: string;
stepId: string;
}
export function StepDetailClient({ processId, stepId }: StepDetailClientProps) {
const searchParams = useSearchParams();
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = stepId === 'new';
const [mode] = useState<DetailMode>(() => {
if (isNewMode) return 'create';
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
const [stepData, setStepData] = useState<ProcessStep | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isNewMode) {
setIsLoading(false);
return;
}
const loadData = async () => {
setIsLoading(true);
setError(null);
try {
const result = await getProcessStepById(processId, stepId);
if (result.success && result.data) {
setStepData(result.data);
} else {
setError(result.error || '단계 정보를 찾을 수 없습니다.');
toast.error('단계를 불러오는데 실패했습니다.');
}
} catch (err) {
console.error('단계 조회 실패:', err);
setError('단계 정보를 불러오는 중 오류가 발생했습니다.');
toast.error('단계를 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [processId, stepId, isNewMode]);
if (isLoading) {
return <DetailPageSkeleton sections={1} fieldsPerSection={4} />;
}
if (error && !isNewMode) {
return (
<ErrorCard
type="network"
title="단계 정보를 불러올 수 없습니다"
description={error}
tips={[
'해당 단계가 존재하는지 확인해주세요',
'인터넷 연결 상태를 확인해주세요',
]}
homeButtonLabel="공정 상세로 이동"
homeButtonHref={`/ko/master-data/process-management/${processId}`}
/>
);
}
if (mode === 'create') {
return <StepForm mode="create" processId={processId} />;
}
if (mode === 'edit' && stepData) {
return <StepForm mode="edit" processId={processId} initialData={stepData} />;
}
if (mode === 'view' && stepData) {
return <StepDetail step={stepData} processId={processId} />;
}
return (
<ErrorCard
type="not-found"
title="단계를 찾을 수 없습니다"
description="요청하신 단계 정보가 존재하지 않습니다."
homeButtonLabel="공정 상세로 이동"
homeButtonHref={`/ko/master-data/process-management/${processId}`}
/>
);
}

View File

@@ -0,0 +1,332 @@
'use client';
/**
* 단계 등록/수정 폼 컴포넌트
*
* 기획서 스크린샷 2 기준:
* - 기본 정보: 단계코드(자동), 단계명, 필수여부, 승인여부, 검사여부, 상태
* - 연결 정보: 유형(팝업/없음), 도달(Select)
* - 완료 정보: 유형(선택 완료 시 완료/클릭 시 완료)
*/
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { toast } from 'sonner';
import type { ProcessStep, StepConnectionType, StepCompletionType } from '@/types/process';
import {
STEP_CONNECTION_TYPE_OPTIONS,
STEP_COMPLETION_TYPE_OPTIONS,
STEP_CONNECTION_TARGET_OPTIONS,
} from '@/types/process';
import { createProcessStep, updateProcessStep } from './actions';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
const stepCreateConfig: DetailConfig = {
title: '단계',
description: '새로운 단계를 등록합니다',
basePath: '',
fields: [],
actions: {
showBack: true,
showEdit: false,
showDelete: false,
showSave: true,
submitLabel: '등록',
},
};
const stepEditConfig: DetailConfig = {
...stepCreateConfig,
title: '단계',
description: '단계 정보를 수정합니다',
actions: {
...stepCreateConfig.actions,
submitLabel: '저장',
},
};
interface StepFormProps {
mode: 'create' | 'edit';
processId: string;
initialData?: ProcessStep;
}
export function StepForm({ mode, processId, initialData }: StepFormProps) {
const router = useRouter();
const isEdit = mode === 'edit';
// 기본 정보
const [stepName, setStepName] = useState(initialData?.stepName || '');
const [isRequired, setIsRequired] = useState(
initialData?.isRequired ? '필수' : '선택'
);
const [needsApproval, setNeedsApproval] = useState(
initialData?.needsApproval ? '필요' : '불필요'
);
const [needsInspection, setNeedsInspection] = useState(
initialData?.needsInspection ? '필요' : '불필요'
);
const [isActive, setIsActive] = useState(
initialData?.isActive !== false ? '사용' : '미사용'
);
// 연결 정보
const [connectionType, setConnectionType] = useState<StepConnectionType>(
initialData?.connectionType || '없음'
);
const [connectionTarget, setConnectionTarget] = useState(
initialData?.connectionTarget || ''
);
// 완료 정보
const [completionType, setCompletionType] = useState<StepCompletionType>(
initialData?.completionType || '클릭 시 완료'
);
const [isLoading, setIsLoading] = useState(false);
// 제출
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
if (!stepName.trim()) {
toast.error('단계명을 입력해주세요.');
return { success: false, error: '단계명을 입력해주세요.' };
}
const stepData: Omit<ProcessStep, 'id'> = {
stepCode: initialData?.stepCode || `STP-${Date.now().toString().slice(-6)}`,
stepName: stepName.trim(),
isRequired: isRequired === '필수',
needsApproval: needsApproval === '필요',
needsInspection: needsInspection === '필요',
isActive: isActive === '사용',
order: initialData?.order || 0,
connectionType,
connectionTarget: connectionType === '팝업' ? connectionTarget : undefined,
completionType,
};
setIsLoading(true);
try {
if (isEdit && initialData?.id) {
const result = await updateProcessStep(processId, initialData.id, stepData);
if (result.success) {
toast.success('단계가 수정되었습니다.');
router.push(`/ko/master-data/process-management/${processId}`);
return { success: true };
} else {
toast.error(result.error || '수정에 실패했습니다.');
return { success: false, error: result.error };
}
} else {
const result = await createProcessStep(processId, stepData);
if (result.success) {
toast.success('단계가 등록되었습니다.');
router.push(`/ko/master-data/process-management/${processId}`);
return { success: true };
} else {
toast.error(result.error || '등록에 실패했습니다.');
return { success: false, error: result.error };
}
}
} catch {
toast.error('처리 중 오류가 발생했습니다.');
return { success: false, error: '처리 중 오류가 발생했습니다.' };
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
router.push(`/ko/master-data/process-management/${processId}`);
};
const renderFormContent = useCallback(
() => (
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
<div className="space-y-2">
<Label></Label>
<Input
value={initialData?.stepCode || '자동생성'}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="stepName"> *</Label>
<Input
id="stepName"
value={stepName}
onChange={(e) => setStepName(e.target.value)}
placeholder="예: 자재투입"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={isRequired} onValueChange={setIsRequired}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="필수"></SelectItem>
<SelectItem value="선택"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={needsApproval} onValueChange={setNeedsApproval}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="필요"></SelectItem>
<SelectItem value="불필요"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={needsInspection} onValueChange={setNeedsInspection}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="필요"></SelectItem>
<SelectItem value="불필요"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={isActive} onValueChange={setIsActive}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 연결 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<Label></Label>
<Select
value={connectionType}
onValueChange={(v) => setConnectionType(v as StepConnectionType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_CONNECTION_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={connectionTarget} onValueChange={setConnectionTarget}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{STEP_CONNECTION_TARGET_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 완료 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<Label></Label>
<Select
value={completionType}
onValueChange={(v) => setCompletionType(v as StepCompletionType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_COMPLETION_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</div>
),
[
stepName,
isRequired,
needsApproval,
needsInspection,
isActive,
connectionType,
connectionTarget,
completionType,
initialData?.stepCode,
]
);
const config = isEdit ? stepEditConfig : stepCreateConfig;
return (
<IntegratedDetailTemplate
config={config}
mode={isEdit ? 'edit' : 'create'}
isLoading={false}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
);
}

View File

@@ -3,7 +3,7 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { Process, ProcessFormData, ClassificationRule, IndividualItem } from '@/types/process';
import type { Process, ProcessFormData, ClassificationRule, IndividualItem, ProcessStep } from '@/types/process';
// ============================================================================
// API 타입 정의
@@ -661,3 +661,152 @@ export async function getItemList(params?: GetItemListParams): Promise<ItemOptio
return [];
}
}
// ============================================================================
// 공정 단계 (Process Step) - 목데이터 및 스텁 함수
// 백엔드 API 미준비 → 프론트엔드 목데이터로 운영
// ============================================================================
const MOCK_STEPS: Record<string, ProcessStep[]> = {
// processId별로 목데이터 보유
default: [
{
id: 'step-1',
stepCode: 'STP-001',
stepName: '자재투입',
isRequired: true,
needsApproval: false,
needsInspection: false,
isActive: true,
order: 1,
connectionType: '팝업',
connectionTarget: '입고완료 자재 목록',
completionType: '선택 완료 시 완료',
},
{
id: 'step-2',
stepCode: 'STP-002',
stepName: '미싱',
isRequired: true,
needsApproval: false,
needsInspection: false,
isActive: true,
order: 2,
connectionType: '없음',
completionType: '클릭 시 완료',
},
{
id: 'step-3',
stepCode: 'STP-003',
stepName: '중간검사',
isRequired: false,
needsApproval: true,
needsInspection: true,
isActive: true,
order: 3,
connectionType: '없음',
completionType: '클릭 시 완료',
},
{
id: 'step-4',
stepCode: 'STP-004',
stepName: '포장',
isRequired: true,
needsApproval: false,
needsInspection: false,
isActive: true,
order: 4,
connectionType: '없음',
completionType: '클릭 시 완료',
},
],
};
/**
* 공정 단계 목록 조회 (목데이터)
*/
export async function getProcessSteps(processId: string): Promise<{
success: boolean;
data?: ProcessStep[];
error?: string;
}> {
// 목데이터 반환
const steps = MOCK_STEPS[processId] || MOCK_STEPS['default'];
return { success: true, data: steps };
}
/**
* 공정 단계 상세 조회 (목데이터)
*/
export async function getProcessStepById(processId: string, stepId: string): Promise<{
success: boolean;
data?: ProcessStep;
error?: string;
}> {
const steps = MOCK_STEPS[processId] || MOCK_STEPS['default'];
const step = steps.find((s) => s.id === stepId);
if (!step) {
return { success: false, error: '단계를 찾을 수 없습니다.' };
}
return { success: true, data: step };
}
/**
* 공정 단계 생성 (스텁)
*/
export async function createProcessStep(
_processId: string,
data: Omit<ProcessStep, 'id'>
): Promise<{ success: boolean; data?: ProcessStep; error?: string }> {
const newStep: ProcessStep = {
...data,
id: `step-${Date.now()}`,
};
return { success: true, data: newStep };
}
/**
* 공정 단계 수정 (스텁)
*/
export async function updateProcessStep(
_processId: string,
stepId: string,
data: Partial<ProcessStep>
): Promise<{ success: boolean; data?: ProcessStep; error?: string }> {
return {
success: true,
data: {
id: stepId,
stepCode: data.stepCode || '',
stepName: data.stepName || '',
isRequired: data.isRequired ?? false,
needsApproval: data.needsApproval ?? false,
needsInspection: data.needsInspection ?? false,
isActive: data.isActive ?? true,
order: data.order ?? 0,
connectionType: data.connectionType || '없음',
connectionTarget: data.connectionTarget,
completionType: data.completionType || '클릭 시 완료',
},
};
}
/**
* 공정 단계 삭제 (스텁)
*/
export async function deleteProcessStep(
_processId: string,
_stepId: string
): Promise<{ success: boolean; error?: string }> {
return { success: true };
}
/**
* 공정 단계 순서 변경 (스텁)
*/
export async function reorderProcessSteps(
_processId: string,
_stepIds: string[]
): Promise<{ success: boolean; error?: string }> {
return { success: true };
}

View File

@@ -3,4 +3,7 @@ export { ProcessForm } from './ProcessForm';
export { ProcessDetail } from './ProcessDetail';
export { ProcessDetailClientV2 } from './ProcessDetailClientV2';
export { RuleModal } from './RuleModal';
export { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal';
export { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal';
export { StepDetail } from './StepDetail';
export { StepForm } from './StepForm';
export { StepDetailClient } from './StepDetailClient';

View File

@@ -8,7 +8,7 @@
*/
import { useState, useEffect, useMemo } from 'react';
import { Check, X, ChevronDown, ChevronRight } from 'lucide-react';
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -270,16 +270,8 @@ export function AssigneeSelectModal({
</VisuallyHidden>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold"> </h2>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only"></span>
</button>
</div>
{/* 설명 텍스트 */}

View File

@@ -8,7 +8,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Play, CheckCircle2, Loader2, Undo2, Pencil } from 'lucide-react';
import { FileText, Play, CheckCircle2, Loader2, Undo2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -23,13 +23,12 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
import { workOrderConfig } from './workOrderConfig';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
import { InspectionReportModal } from './documents';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getWorkOrderById, updateWorkOrderStatus, updateWorkOrderItemStatus, type WorkOrderItemStatus } from './actions';
import { getWorkOrderById, updateWorkOrderStatus } from './actions';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUS_COLORS,
ITEM_STATUS_LABELS,
ISSUE_STATUS_LABELS,
SCREEN_PROCESS_STEPS,
SLAT_PROCESS_STEPS,
@@ -39,8 +38,8 @@ import {
type ProcessStep,
} from './types';
// 공정 진행 단계 컴포넌트
function ProcessSteps({
// 공정 진행 단계 (wrapper 없이 pills만 렌더링)
function ProcessStepPills({
processType,
currentStep,
workSteps,
@@ -59,45 +58,26 @@ function ProcessSteps({
: BENDING_PROCESS_STEPS;
if (steps.length === 0) {
return (
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<p className="text-gray-500"> .</p>
</div>
);
return <p className="text-gray-500"> .</p>;
}
return (
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> ({steps.length})</h3>
<div className="flex items-center gap-2 flex-wrap">
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
<div className="flex items-center gap-3 flex-wrap">
{steps.map((step, index) => {
const isCompleted = index < currentStep;
return (
<div key={step.key || `step-${index}`} className="flex items-center">
<div
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
isCompleted
? 'bg-gray-900 text-white border-gray-900'
: isCurrent
? 'bg-white border-gray-900 text-gray-900'
: 'bg-white border-gray-300 text-gray-500'
}`}
>
<span className="font-medium">{step.order}</span>
<span>{step.label}</span>
{isCompleted && (
<span className="text-xs bg-white text-gray-900 px-1.5 py-0.5 rounded">
</span>
)}
</div>
</div>
);
})}
</div>
return (
<div
key={step.key || `step-${index}`}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-gray-900 text-white border border-gray-900"
>
<span>{step.label}</span>
{isCompleted && (
<span className="text-green-400 font-medium"></span>
)}
</div>
);
})}
</div>
);
}
@@ -206,10 +186,10 @@ interface WorkOrderDetailProps {
export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
const router = useRouter();
const [isWorkLogOpen, setIsWorkLogOpen] = useState(false);
const [isInspectionOpen, setIsInspectionOpen] = useState(false);
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
const [updatingItemId, setUpdatingItemId] = useState<number | null>(null);
// 수정 모드로 이동 핸들러
const handleEdit = useCallback(() => {
@@ -264,49 +244,6 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
}
}, [order, orderId]);
// 품목 상태 변경 핸들러
const handleItemStatusChange = useCallback(async (itemId: number, newStatus: WorkOrderItemStatus) => {
if (!order) return;
setUpdatingItemId(itemId);
try {
const result = await updateWorkOrderItemStatus(orderId, itemId, newStatus);
if (result.success) {
// 로컬 상태 업데이트 (품목 + 작업지시 상태)
setOrder(prev => {
if (!prev) return prev;
return {
...prev,
status: result.workOrderStatus || prev.status,
items: prev.items.map(item =>
item.id === itemId ? { ...item, status: newStatus } : item
),
};
});
const statusLabels: Record<WorkOrderItemStatus, string> = {
waiting: '대기',
in_progress: '작업중',
completed: '완료',
};
toast.success(`품목 상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
// 작업지시 상태가 변경된 경우 추가 알림
if (result.workOrderStatusChanged && result.workOrderStatus) {
const workOrderStatusLabel = WORK_ORDER_STATUS_LABELS[result.workOrderStatus as keyof typeof WORK_ORDER_STATUS_LABELS] || result.workOrderStatus;
toast.info(`작업지시 상태가 '${workOrderStatusLabel}'(으)로 자동 변경되었습니다.`);
}
} else {
toast.error(result.error || '품목 상태 변경에 실패했습니다.');
}
} catch (error) {
console.error('[WorkOrderDetail] handleItemStatusChange error:', error);
toast.error('품목 상태 변경 중 오류가 발생했습니다.');
} finally {
setUpdatingItemId(null);
}
}, [order, orderId]);
// 커스텀 헤더 액션 (상태 변경 버튼, 작업일지 버튼)
const customHeaderActions = useMemo(() => {
if (!order) return null;
@@ -374,7 +311,14 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
)}
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>
<FileText className="w-4 h-4 mr-1.5" />
</Button>
<Button
variant="outline"
onClick={() => setIsInspectionOpen(true)}
>
<ClipboardCheck className="w-4 h-4 mr-1.5" />
</Button>
</>
);
@@ -386,30 +330,31 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
return (
<div className="space-y-6">
{/* 기본 정보 */}
{/* 기본 정보 (기획서 4열 구성) */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="grid grid-cols-4 gap-y-4">
<div className="grid grid-cols-4 gap-x-6 gap-y-4">
{/* 1행: 작업번호 | 수주일 | 공정구분 | 로트번호 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.workOrderNo}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.lotNo}</p>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.salesOrderDate || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.processName}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
{WORK_ORDER_STATUS_LABELS[order.status]}
</Badge>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.lotNo}</p>
</div>
{/* 2행: 수주처 | 현장명 | 수주 담당자 | 담당자 연락처 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.client}</p>
</div>
<div>
@@ -417,11 +362,35 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
<p className="font-medium">{order.projectName}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.dueDate}</p>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">-</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">-</p>
</div>
{/* 3행: 출고예정일 | 틀수 | 우선순위 | 부서 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.shipmentDate || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.shutterCount ?? '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.priorityLabel || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.department || '-'}</p>
</div>
{/* 4행: 생산 담당자 | 상태 | 비고 (colspan 2) */}
<div>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">
{order.assignees && order.assignees.length > 0
? order.assignees.map(a => a.name).join(', ')
@@ -429,120 +398,65 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">
{order.priority === 1 ? '1 (긴급)' : order.priority === 5 ? '5 (일반)' : order.priority || '-'}
</p>
<p className="text-sm text-muted-foreground mb-1"></p>
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
{WORK_ORDER_STATUS_LABELS[order.status]}
</Badge>
</div>
<div className="col-span-2">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium whitespace-pre-wrap">{order.note || '-'}</p>
</div>
{order.note && (
<div className="col-span-4 mt-2 pt-4 border-t">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium whitespace-pre-wrap">{order.note}</p>
</div>
)}
</div>
</div>
{/* 공정 진행 */}
<ProcessSteps
processType={order.processType}
currentStep={order.currentStep}
workSteps={order.workSteps}
/>
{/* 작업 품목 */}
{/* 공정 진행 + 작업 품목 (기획서 구조) */}
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> ({order.items.length})</h3>
<h3 className="font-semibold mb-4"> </h3>
{/* 공정 단계 박스 (inner bordered box) */}
<div className="border rounded-lg p-4 mb-6">
{/* 품목 정보 헤더 (항상 표시) */}
<p className="font-semibold mb-3">
{order.items.length > 0 ? (
<>
{order.items[0].productName}
{order.items[0].specification !== '-' ? ` ${order.items[0].specification}` : ''}
{` ${order.items[0].quantity}${order.items[0].unit !== '-' ? order.items[0].unit : '개'}`}
</>
) : (
<>
{order.processCode} ({order.processName})
</>
)}
</p>
{/* 공정 단계 pills */}
<ProcessStepPills
processType={order.processType}
currentStep={order.currentStep}
workSteps={order.workSteps}
/>
</div>
{/* 작업 품목 테이블 (로트번호 | 품목명 | 수량 | 단위) */}
{order.items.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-14">No</TableHead>
<TableHead className="w-20"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28">/</TableHead>
<TableHead className="w-32"></TableHead>
<TableHead className="w-20 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{order.items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.no}</TableCell>
<TableCell>
<Badge variant="outline">{ITEM_STATUS_LABELS[item.status]}</Badge>
</TableCell>
<TableCell>{order.lotNo}</TableCell>
<TableCell className="font-medium">{item.productName}</TableCell>
<TableCell>{item.floorCode}</TableCell>
<TableCell>{item.specification}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell>
<div className="flex gap-1">
{item.status === 'waiting' && (
<Button
variant="outline"
size="sm"
onClick={() => handleItemStatusChange(item.id, 'in_progress')}
disabled={updatingItemId === item.id}
>
{updatingItemId === item.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Play className="w-3 h-3 mr-1" />
)}
</Button>
)}
{item.status === 'in_progress' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleItemStatusChange(item.id, 'completed')}
disabled={updatingItemId === item.id}
>
{updatingItemId === item.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<CheckCircle2 className="w-3 h-3 mr-1" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleItemStatusChange(item.id, 'waiting')}
disabled={updatingItemId === item.id}
className="text-muted-foreground hover:text-foreground"
>
{updatingItemId === item.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Undo2 className="w-3 h-3 mr-1" />
)}
</Button>
</>
)}
{item.status === 'completed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleItemStatusChange(item.id, 'in_progress')}
disabled={updatingItemId === item.id}
className="text-muted-foreground hover:text-foreground"
>
{updatingItemId === item.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Undo2 className="w-3 h-3 mr-1" />
)}
</Button>
)}
</div>
</TableCell>
<TableCell>{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -589,12 +503,23 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
renderForm={() => renderFormContent()}
/>
{/* 작업일지 모달 */}
{/* 작업일지 모달 (공정별) */}
{order && (
<WorkLogModal
open={isWorkLogOpen}
onOpenChange={setIsWorkLogOpen}
workOrderId={order.id}
processType={order.processType}
/>
)}
{/* 중간검사 성적서 모달 (공정별) */}
{order && (
<InspectionReportModal
open={isInspectionOpen}
onOpenChange={setIsInspectionOpen}
workOrderId={order.id}
processType={order.processType}
/>
)}
</>

View File

@@ -193,19 +193,13 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
router.back();
};
// 선택된 공정의 코드 가져오기
const getSelectedProcessCode = (): string => {
const selectedProcess = processOptions.find(p => p.id === formData.processId);
return selectedProcess?.processCode || '-';
};
// 동적 config (작업지시 번호 포함)
const dynamicConfig = {
...workOrderEditConfig,
title: workOrder ? `작업지시 (${workOrder.workOrderNo})` : '작업지시',
};
// 폼 컨텐츠 렌더링
// 폼 컨텐츠 렌더링 (기획서 4열 그리드)
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
@@ -237,58 +231,28 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
</Alert>
)}
{/* 기본 정보 (읽기 전용) */}
<section className="bg-muted/30 border rounded-lg p-6">
{/* 기본 정보 (기획서 4열 구성) */}
<section className="bg-amber-50 border border-amber-200 rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={formData.client}
disabled
className="bg-muted"
/>
<div className="grid grid-cols-4 gap-x-6 gap-y-4">
{/* 1행: 작업번호(읽기) | 수주일(읽기) | 공정구분(셀렉트) | 로트번호(읽기) */}
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder?.workOrderNo || '-'} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.projectName}
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
className="bg-white"
/>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder?.salesOrderDate || '-'} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.orderNo}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.itemCount || '-'}
disabled
className="bg-muted"
/>
</div>
</div>
</section>
{/* 작업지시 정보 */}
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="space-y-4">
<div className="space-y-2">
<Label> *</Label>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> *</Label>
<Select
value={formData.processId?.toString() || ''}
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
disabled={isLoadingProcesses}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정 선택하세요'} />
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정 선택'} />
</SelectTrigger>
<SelectContent>
{processOptions.map((process) => (
@@ -298,13 +262,37 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
: {getSelectedProcessCode()}
</p>
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={formData.orderNo || '-'} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label> *</Label>
{/* 2행: 수주처(읽기) | 현장명(입력) | 수주 담당자(읽기) | 담당자 연락처(읽기) */}
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={formData.client} disabled className="bg-muted" />
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input
value={formData.projectName}
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
className="bg-white"
/>
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> </Label>
<Input value="-" disabled className="bg-muted" />
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> </Label>
<Input value="-" disabled className="bg-muted" />
</div>
{/* 3행: 출고예정일(입력) | 틀수(읽기) | 우선순위(셀렉트) | 부서(읽기) */}
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> *</Label>
<Input
type="date"
value={formData.scheduledDate}
@@ -312,9 +300,12 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
className="bg-white"
/>
</div>
<div className="space-y-2">
<Label> (1=, 9=)</Label>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder?.shutterCount ?? '-'} disabled className="bg-muted" />
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Select
value={formData.priority.toString()}
onValueChange={(value) => setFormData({ ...formData, priority: parseInt(value) })}
@@ -325,15 +316,20 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<SelectItem key={n} value={n.toString()}>
{n} {n === 5 ? '(일반)' : n === 1 ? '(긴급)' : ''}
{n} {n <= 3 ? '(긴급)' : n <= 6 ? '(우선)' : '(일반)'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder?.department || '-'} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label> ( )</Label>
{/* 4행: 생산 담당자(선택) | 상태(읽기) | 비고(입력, colspan 2) */}
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> </Label>
<div
onClick={() => setIsAssigneeModalOpen(true)}
className="flex min-h-10 w-full cursor-pointer items-center rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50"
@@ -341,26 +337,28 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
{assigneeNames.length > 0 ? (
<span>{assigneeNames.join(', ')}</span>
) : (
<span className="text-muted-foreground"> (/)</span>
<span className="text-muted-foreground"> </span>
)}
</div>
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder ? (workOrder.status === 'waiting' ? '작업대기' : workOrder.status === 'in_progress' ? '작업중' : workOrder.status === 'completed' ? '작업완료' : workOrder.status) : '-'} disabled className="bg-muted" />
</div>
<div className="space-y-1 col-span-2">
<Label className="text-sm text-muted-foreground"></Label>
<Textarea
value={formData.note}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
placeholder="특이사항이나 메모를 입력하세요"
rows={2}
className="bg-white"
/>
</div>
</div>
</section>
{/* 비고 */}
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"></h3>
<Textarea
value={formData.note}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
placeholder="특이사항이나 메모를 입력하세요"
rows={4}
className="bg-white"
/>
</section>
</div>
), [formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode]);
), [formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, workOrder]);
return (
<>

View File

@@ -1,18 +1,19 @@
'use client';
/**
* 작업지시 목록 - UniversalListPage 마이그레이션
* 작업지시 목록 - 공정 기반 탭 구조
*
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 서버 사이드 페이지네이션 (getWorkOrders API)
* - 통계 카드 (getWorkOrderStats API)
* - 탭 기반 상태 필터링
* 기획서 기반 전면 개편:
* - 탭: 공정 기반 3개 (스크린/슬랫/절곡) — 통계 카드 위에 배치
* - 필터: 상태 + 우선순위
* - 통계 카드 6개: 전체 작업 / 작업 대기 / 작업중 / 작업 완료 / 긴급 / 지연
* - 컬럼: 작업번호/수주일/출고예정일/로트번호/수주처/현장명/틀수/상태/우선순위/부서/비고
* - API: getProcessOptions로 공정 ID 매핑 후 processId로 필터링
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, FileText, Calendar, Users, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FileText, Clock, Loader, CheckCircle2, AlertTriangle, TimerOff } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -25,39 +26,104 @@ import {
type StatCard,
type ListParams,
} from '@/components/templates/UniversalListPage';
import type { FilterFieldConfig } from '@/components/molecules/MobileFilter';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import { getWorkOrders, getWorkOrderStats } from './actions';
import { getWorkOrders, getWorkOrderStats, getProcessOptions } from './actions';
import type { ProcessOption } from './actions';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUS_COLORS,
type WorkOrder,
type WorkOrderStats,
type WorkOrderStatus,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 탭 필터 정의
type TabFilter = 'all' | 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
// 작업자 표시 포맷 (홍길동 외 2명)
function formatAssignees(item: WorkOrder): string {
if (item.assignees && item.assignees.length > 0) {
const primaryAssignee = item.assignees.find(a => a.isPrimary) || item.assignees[0];
const otherCount = item.assignees.length - 1;
if (otherCount > 0) {
return `${primaryAssignee.name}${otherCount}`;
}
return primaryAssignee.name;
}
return item.assignee || '-';
}
// 공정명 → 탭 value 매핑 (DB process_name 기준)
const PROCESS_NAME_TO_TAB: Record<string, string> = {
'스크린': 'screen',
'슬랫': 'slat',
'절곡': 'bending',
};
// 공정코드 → 탭 value 매핑 (DB process_code 기준, 이름 매핑 실패 시 폴백)
const PROCESS_CODE_TO_TAB: Record<string, string> = {
'screen': 'screen',
'slat': 'slat',
'bending': 'bending',
'SCREEN': 'screen',
'SLAT': 'slat',
'BENDING': 'bending',
};
// 우선순위 뱃지 색상
const PRIORITY_COLORS: Record<string, string> = {
'긴급': 'bg-red-100 text-red-700',
'우선': 'bg-orange-100 text-orange-700',
'일반': 'bg-gray-100 text-gray-700',
};
// 필터 설정: 상태 + 우선순위
const filterConfig: FilterFieldConfig[] = [
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'waiting', label: '작업대기' },
{ value: 'in_progress', label: '진행중' },
{ value: 'completed', label: '작업완료' },
],
},
{
key: 'priority',
label: '우선순위',
type: 'single',
options: [
{ value: 'urgent', label: '긴급' },
{ value: 'priority', label: '우선' },
{ value: 'normal', label: '일반' },
],
},
];
export function WorkOrderList() {
const router = useRouter();
// ===== 공정 ID 매핑 (getProcessOptions) =====
const [processMap, setProcessMap] = useState<Record<string, number>>({});
const [processMapLoaded, setProcessMapLoaded] = useState(false);
useEffect(() => {
const loadProcessOptions = async () => {
try {
const result = await getProcessOptions();
if (result.success && result.data) {
const map: Record<string, number> = {};
result.data.forEach((process: ProcessOption) => {
// process_name 또는 process_code로 탭 매핑
const tabKeyByName = PROCESS_NAME_TO_TAB[process.processName];
const tabKeyByCode = PROCESS_CODE_TO_TAB[process.processCode];
const tabKey = tabKeyByName || tabKeyByCode;
if (tabKey) {
map[tabKey] = process.id;
}
});
setProcessMap(map);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderList] loadProcessOptions error:', error);
} finally {
setProcessMapLoaded(true);
}
};
loadProcessOptions();
}, []);
// ===== 통계 데이터 (외부 관리 - 별도 API) =====
const [statsData, setStatsData] = useState<WorkOrderStats>({
total: 0,
@@ -97,46 +163,61 @@ export function WorkOrderList() {
router.push('/ko/production/work-orders?mode=new');
}, [router]);
// ===== 탭 옵션 (통계 데이터 기반) =====
// ===== 탭 옵션 (공정 기반 3개) — 카운트는 API 응답으로 동적 업데이트 =====
const [tabCounts, setTabCounts] = useState<Record<string, number>>({
screen: 0,
slat: 0,
bending: 0,
});
const tabs: TabOption[] = useMemo(
() => [
{ value: 'all', label: '전체', count: statsData.total },
{ value: 'unassigned', label: '미배정', count: statsData.unassigned, color: 'gray' },
{ value: 'pending', label: '승인대기', count: statsData.pending, color: 'orange' },
{ value: 'waiting', label: '작업대기', count: statsData.waiting, color: 'yellow' },
{ value: 'in_progress', label: '작업중', count: statsData.inProgress, color: 'blue' },
{ value: 'completed', label: '작업완료', count: statsData.completed, color: 'green' },
{ value: 'screen', label: '스크린 공정', count: tabCounts.screen },
{ value: 'slat', label: '슬랫 공정', count: tabCounts.slat },
{ value: 'bending', label: '절곡 공정', count: tabCounts.bending },
],
[statsData]
[tabCounts]
);
// ===== 통계 카드 =====
// ===== 통계 카드 6개 (기획서 기반) =====
const stats: StatCard[] = useMemo(
() => [
{
label: '전체',
label: '전체 작업',
value: statsData.total,
icon: FileText,
iconColor: 'text-gray-600',
},
{
label: '미착수',
label: '작업 대기',
value: statsData.waiting + statsData.unassigned + statsData.pending,
icon: Calendar,
iconColor: 'text-orange-600',
icon: Clock,
iconColor: 'text-yellow-600',
},
{
label: '작업중',
value: statsData.inProgress,
icon: Users,
icon: Loader,
iconColor: 'text-blue-600',
},
{
label: '작업완료',
label: '작업 완료',
value: statsData.completed,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
{
label: '긴급',
value: 0, // TODO: API에서 긴급 건수 제공 시 연동
icon: AlertTriangle,
iconColor: 'text-red-600',
},
{
label: '지연',
value: 0, // TODO: API에서 지연 건수 제공 시 연동
icon: TimerOff,
iconColor: 'text-orange-600',
},
],
[statsData]
);
@@ -157,15 +238,45 @@ export function WorkOrderList() {
actions: {
getList: async (params?: ListParams) => {
try {
// 탭 → processId 매핑
const tabValue = params?.tab || 'screen';
const processId = processMap[tabValue];
// 해당 공정이 DB에 없으면 빈 목록 반환
if (!processId) {
return {
success: true,
data: [],
totalCount: 0,
totalPages: 0,
};
}
// 필터 값 추출
const statusFilter = params?.filters?.status as string | undefined;
const priorityFilter = params?.filters?.priority as string | undefined;
const result = await getWorkOrders({
page: params?.page || 1,
perPage: params?.pageSize || ITEMS_PER_PAGE,
status: params?.tab === 'all' ? undefined : (params?.tab as TabFilter),
processId,
status: statusFilter && statusFilter !== 'all'
? (statusFilter as WorkOrderStatus)
: undefined,
priority: priorityFilter && priorityFilter !== 'all'
? priorityFilter
: undefined,
search: params?.search || undefined,
});
if (result.success) {
// 통계도 다시 로드 (탭 변경 시 최신 데이터 반영)
// 현재 탭의 카운트 업데이트
setTabCounts((prev) => ({
...prev,
[tabValue]: result.pagination.total,
}));
// 통계도 다시 로드
const statsResult = await getWorkOrderStats();
if (statsResult.success && statsResult.data) {
setStatsData(statsResult.data);
@@ -186,21 +297,20 @@ export function WorkOrderList() {
},
},
// 테이블 컬럼
// 테이블 컬럼 (기획서 기반 12개)
columns: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'workOrderNo', label: '작업지시번호', className: 'min-w-[140px]' },
{ key: 'processType', label: '공정', className: 'w-[80px]' },
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
{ key: 'orderDate', label: '지시일', className: 'w-[100px]' },
{ key: 'isAssigned', label: '배정', className: 'w-[60px] text-center' },
{ key: 'hasWork', label: '작업', className: 'w-[60px] text-center' },
{ key: 'isStarted', label: '시작', className: 'w-[60px] text-center' },
{ key: 'status', label: '작업상태', className: 'w-[100px]' },
{ key: 'priority', label: '현장순위', className: 'w-[80px] text-center' },
{ key: 'assignee', label: '작업자', className: 'w-[80px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'workOrderNo', label: '작업번호', className: 'min-w-[140px]' },
{ key: 'salesOrderDate', label: '수주일', className: 'w-[100px]' },
{ key: 'shipmentDate', label: '출고예정일', className: 'w-[110px]' },
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
{ key: 'client', label: '수주처', className: 'min-w-[120px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'shutterCount', label: '틀수', className: 'w-[70px] text-center' },
{ key: 'status', label: '상태', className: 'w-[90px]' },
{ key: 'priority', label: '우선순위', className: 'w-[80px]' },
{ key: 'department', label: '부서', className: 'w-[90px]' },
{ key: 'note', label: '비고', className: 'min-w-[120px]' },
],
// 서버 사이드 페이지네이션
@@ -208,7 +318,7 @@ export function WorkOrderList() {
itemsPerPage: ITEMS_PER_PAGE,
// 검색
searchPlaceholder: '작업지시번호, 주처, 현장명 검색...',
searchPlaceholder: '작업번호, 주처, 현장명 검색...',
searchFilter: (item: WorkOrder, search: string) => {
const s = search.toLowerCase();
return (
@@ -220,20 +330,32 @@ export function WorkOrderList() {
);
},
// 설정
tabs,
defaultTab: 'all',
// 필터 설정 (상태 + 우선순위)
filterConfig,
initialFilters: {
status: 'all',
priority: 'all',
},
// 통계 카드
// 탭 설정 (공정 기반) — 통계 카드 위에 배치
tabs,
defaultTab: 'screen',
tabsPosition: 'above-stats',
// 통계 카드 (6개)
stats,
// 헤더 액션 (등록 버튼)
headerActions: () => (
<Button onClick={handleCreate}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
),
// 날짜 범위 선택기
dateRangeSelector: {
enabled: true,
showPresets: true,
},
// 등록 버튼 (발주에서 넘어오는 형태로 변경 예정)
// createButton: {
// label: '등록',
// onClick: handleCreate,
// },
// 테이블 행 렌더링
renderTableRow: (
@@ -256,23 +378,24 @@ export function WorkOrderList() {
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.workOrderNo}</TableCell>
<TableCell>{item.processName}</TableCell>
<TableCell>{item.salesOrderDate}</TableCell>
<TableCell>{item.shipmentDate}</TableCell>
<TableCell>{item.lotNo}</TableCell>
<TableCell>{item.orderDate}</TableCell>
<TableCell className="text-center">{item.isAssigned ? 'Y' : '-'}</TableCell>
<TableCell className="text-center">
{item.status !== 'unassigned' && item.status !== 'pending' ? 'Y' : '-'}
</TableCell>
<TableCell className="text-center">{item.isStarted ? 'Y' : '-'}</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="max-w-[200px] truncate">{item.projectName}</TableCell>
<TableCell className="text-center">{item.shutterCount ?? '-'}</TableCell>
<TableCell>
<Badge className={`${WORK_ORDER_STATUS_COLORS[item.status]} border-0`}>
{WORK_ORDER_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="text-center">{item.priority}</TableCell>
<TableCell>{formatAssignees(item)}</TableCell>
<TableCell className="max-w-[200px] truncate">{item.projectName}</TableCell>
<TableCell>{item.shipmentDate}</TableCell>
<TableCell>
<Badge className={`${PRIORITY_COLORS[item.priorityLabel] || 'bg-gray-100 text-gray-700'} border-0`}>
{item.priorityLabel}
</Badge>
</TableCell>
<TableCell>{item.department}</TableCell>
<TableCell className="max-w-[150px] truncate">{item.note || '-'}</TableCell>
</TableRow>
);
},
@@ -309,21 +432,32 @@ export function WorkOrderList() {
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="공정" value={item.processName} />
<InfoField label="수주처" value={item.client} />
<InfoField label="로트번호" value={item.lotNo} />
<InfoField label="발주처" value={item.client} />
<InfoField label="작업자" value={formatAssignees(item)} />
<InfoField label="지시일" value={item.orderDate} />
<InfoField label="수주일" value={item.salesOrderDate} />
<InfoField label="출고예정일" value={item.shipmentDate} />
<InfoField label="현장순위" value={item.priority} />
<InfoField label="틀수" value={item.shutterCount ?? '-'} />
<InfoField label="우선순위" value={item.priorityLabel} />
<InfoField label="부서" value={item.department} />
<InfoField label="비고" value={item.note || '-'} />
</div>
}
/>
);
},
}),
[tabs, stats, handleRowClick, handleCreate]
[tabs, stats, processMap, handleRowClick]
);
// processMap 로딩 완료 전에는 UniversalListPage를 마운트하지 않음
// (초기 fetch에서 processId가 undefined로 전달되어 전체 데이터가 반환되는 문제 방지)
if (!processMapLoaded) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return <UniversalListPage config={config} />;
}

View File

@@ -48,6 +48,8 @@ export async function getWorkOrders(params?: {
perPage?: number;
status?: WorkOrderStatus | 'all';
processId?: number | 'all'; // 공정 ID (FK → processes.id)
processType?: 'screen' | 'slat' | 'bending'; // 공정 타입 필터
priority?: string; // 우선순위 필터 (urgent/priority/normal)
search?: string;
startDate?: string;
endDate?: string;
@@ -74,6 +76,12 @@ export async function getWorkOrders(params?: {
if (params?.processId && params.processId !== 'all') {
searchParams.set('process_id', String(params.processId));
}
if (params?.processType) {
searchParams.set('process_type', params.processType);
}
if (params?.priority && params.priority !== 'all') {
searchParams.set('priority', params.priority);
}
if (params?.search) searchParams.set('search', params.search);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);

View File

@@ -0,0 +1,481 @@
'use client';
/**
* 절곡 중간검사 성적서 문서 콘텐츠
*
* 기획서 기준:
* - 헤더: "중간검사성적서 (절곡)" + 결재란
* - 기본정보: 제품명/슬랫, 규격/절곡, 수주처, 현장명 | 제품LOT NO, 로트크기, 검사일자, 검사자
* + 제품명/KWE01, 마감유형/소니자감
* - ■ 중간검사 기준서: 2섹션 (가이드레일류 + 연기차단재)
* - ■ 중간검사 DATA: 분류, 제품명, 타입, 절곡상태결모양(양호/불량),
* 길이(도면치수/측정값입력), 너비(도면치수/측정값입력),
* 간격(포인트/도면치수/측정값입력), 판정(자동)
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import type { WorkOrder } from '../types';
interface BendingInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
}
type CheckStatus = '양호' | '불량' | null;
interface GapPoint {
point: string; // ①②③④⑤
designValue: string; // 도면치수
measured: string; // 측정값 (입력)
}
interface ProductRow {
id: string;
category: string;
productName: string;
productType: string;
bendingStatus: CheckStatus;
lengthDesign: string;
lengthMeasured: string;
widthDesign: string;
widthMeasured: string;
gapPoints: GapPoint[];
}
const INITIAL_PRODUCTS: Omit<ProductRow, 'bendingStatus' | 'lengthMeasured' | 'widthMeasured'>[] = [
{
id: 'guide-rail', category: 'KWE01', productName: '가이드레일', productType: '벽면형',
lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', designValue: '30', measured: '' },
{ point: '②', designValue: '80', measured: '' },
{ point: '③', designValue: '45', measured: '' },
{ point: '④', designValue: '40', measured: '' },
{ point: '⑤', designValue: '34', measured: '' },
],
},
{
id: 'case', category: 'KWE01', productName: '케이스', productType: '500X380',
lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', designValue: '380', measured: '' },
{ point: '②', designValue: '50', measured: '' },
{ point: '③', designValue: '240', measured: '' },
{ point: '④', designValue: '50', measured: '' },
],
},
{
id: 'bottom-finish', category: 'KWE01', productName: '하단마감재', productType: '60X40',
lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '②', designValue: '60', measured: '' },
{ point: '②', designValue: '64', measured: '' },
],
},
{
id: 'bottom-l-bar', category: 'KWE01', productName: '하단L-BAR', productType: '17X60',
lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', designValue: '17', measured: '' },
],
},
{
id: 'smoke-w50', category: 'KWE01', productName: '연기차단재', productType: 'W50\n가이드레일용',
lengthDesign: '3000', widthDesign: '',
gapPoints: [
{ point: '①', designValue: '50', measured: '' },
{ point: '②', designValue: '12', measured: '' },
],
},
{
id: 'smoke-w80', category: 'KWE01', productName: '연기차단재', productType: 'W80\n케이스용',
lengthDesign: '3000', widthDesign: '',
gapPoints: [
{ point: '①', designValue: '80', measured: '' },
{ point: '②', designValue: '12', measured: '' },
],
},
];
export function BendingInspectionContent({ data: order, readOnly = false }: BendingInspectionContentProps) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const [products, setProducts] = useState<ProductRow[]>(() =>
INITIAL_PRODUCTS.map(p => ({
...p,
bendingStatus: null,
lengthMeasured: '',
widthMeasured: '',
gapPoints: p.gapPoints.map(gp => ({ ...gp })),
}))
);
const [inadequateContent, setInadequateContent] = useState('');
const handleStatusChange = useCallback((productId: string, value: CheckStatus) => {
if (readOnly) return;
setProducts(prev => prev.map(p =>
p.id === productId ? { ...p, bendingStatus: value } : p
));
}, [readOnly]);
const handleInputChange = useCallback((productId: string, field: 'lengthMeasured' | 'widthMeasured', value: string) => {
if (readOnly) return;
setProducts(prev => prev.map(p =>
p.id === productId ? { ...p, [field]: value } : p
));
}, [readOnly]);
const handleGapMeasuredChange = useCallback((productId: string, gapIndex: number, value: string) => {
if (readOnly) return;
setProducts(prev => prev.map(p => {
if (p.id !== productId) return p;
const newGapPoints = p.gapPoints.map((gp, i) =>
i === gapIndex ? { ...gp, measured: value } : gp
);
return { ...p, gapPoints: newGapPoints };
}));
}, [readOnly]);
// 행별 판정 자동 계산
const getProductJudgment = useCallback((product: ProductRow): '적' | '부' | null => {
if (product.bendingStatus === '불량') return '부';
if (product.bendingStatus === '양호') return '적';
return null;
}, []);
// 종합판정 자동 계산
const overallResult = useMemo(() => {
const judgments = products.map(getProductJudgment);
if (judgments.some(j => j === '부')) return '불합격';
if (judgments.every(j => j === '적')) return '합격';
return null;
}, [products, getProductJudgment]);
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
// 전체 행 수 계산 (간격 포인트 수 합계)
const totalRows = products.reduce((sum, p) => sum + p.gapPoints.length, 0);
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 기본 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.items?.length || 0} </td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.client || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.projectName || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">KWE01</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2"></td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 기준서 (1) 가이드레일/케이스/하단마감재/하단L-BAR ===== */}
<div className="mb-1 font-bold text-sm"> </div>
<table className="w-full table-fixed border-collapse text-xs mb-4">
<colgroup>
<col style={{width: '80px'}} />
<col style={{width: '180px'}} />
<col style={{width: '52px'}} />
<col style={{width: '52px'}} />
<col />
<col style={{width: '68px'}} />
<col style={{width: '78px'}} />
<col style={{width: '110px'}} />
</colgroup>
<tbody>
{/* 헤더 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-bold text-center align-middle" rowSpan={4}>
<br/><br/><br/> L-BAR
</td>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
</tr>
{/* 겉모양 | 절곡상태 */}
<tr>
<td className="border border-gray-400 p-2 text-center text-gray-500 align-middle text-xs" rowSpan={3}>
<br/>
</td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}>n = 1, c = 0</td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1</td>
</tr>
{/* 치수 > 길이 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50" rowSpan={2}><br/>(mm)</td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> ± 4</td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 7<br/>9</td>
</tr>
{/* 치수 > 간격 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> ± 2</td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 7<br/>9 / </td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 기준서 (2) 연기차단재 ===== */}
<table className="w-full table-fixed border-collapse text-xs mb-6">
<colgroup>
<col style={{width: '80px'}} />
<col style={{width: '180px'}} />
<col style={{width: '52px'}} />
<col style={{width: '52px'}} />
<col />
<col style={{width: '68px'}} />
<col style={{width: '78px'}} />
<col style={{width: '110px'}} />
</colgroup>
<tbody>
{/* 헤더 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-bold text-center align-middle" rowSpan={6}>
</td>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
</tr>
{/* 겉모양 | 절곡상태 (row 1) */}
<tr>
<td className="border border-gray-400 p-2 text-center text-gray-300 align-middle" rowSpan={5}>
<div className="h-32 flex items-center justify-center"> </div>
</td>
<td className="border border-gray-400 px-2 py-1 font-medium" rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1 font-medium" rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1" rowSpan={2}> </td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={5}>n = 1, c = 0</td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1</td>
</tr>
{/* 겉모양 | 절곡상태 (row 2 - 관련규정 분리) */}
<tr>
<td className="border border-gray-400 px-2 py-1">KS F 4510 7<br/>9 </td>
</tr>
{/* 치수 > 길이 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50" rowSpan={3}><br/>(mm)</td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> ± 4</td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}></td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}></td>
</tr>
{/* 치수 > 나비 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1">W50 : 50 ± 5<br/>W80 : 80 ± 5</td>
</tr>
{/* 치수 > 간격 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> ± 2</td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 DATA ===== */}
<div className="mb-1 font-bold text-sm"> DATA</div>
<table className="w-full border-collapse text-xs mb-4">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-14" rowSpan={2}></th>
<th className="border border-gray-400 p-1" rowSpan={2}></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={3}> (mm)</th>
<th className="border border-gray-400 p-1 w-14" rowSpan={2}><br/>(/)</th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-10"></th>
<th className="border border-gray-400 p-1 w-14"></th>
<th className="border border-gray-400 p-1 w-14"></th>
</tr>
</thead>
<tbody>
{products.map((product) => {
const judgment = getProductJudgment(product);
const rowCount = product.gapPoints.length;
return product.gapPoints.map((gap, gapIdx) => (
<tr key={`${product.id}-${gapIdx}`}>
{/* 첫 번째 간격 행에만 rowSpan 적용 */}
{gapIdx === 0 && (
<>
<td className="border border-gray-400 p-1 text-center font-medium bg-gray-50" rowSpan={rowCount}>{product.category}</td>
<td className="border border-gray-400 p-1" rowSpan={rowCount}>{product.productName}</td>
<td className="border border-gray-400 p-1 text-center whitespace-pre-line" rowSpan={rowCount}>{product.productType}</td>
{/* 절곡상태 - 양호/불량 체크 */}
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={product.bendingStatus === '양호'}
onChange={() => handleStatusChange(product.id, product.bendingStatus === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={product.bendingStatus === '불량'}
onChange={() => handleStatusChange(product.id, product.bendingStatus === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
</div>
</td>
{/* 길이 */}
<td className="border border-gray-400 p-1 text-center" rowSpan={rowCount}>{product.lengthDesign}</td>
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
<input type="text" value={product.lengthMeasured} onChange={(e) => handleInputChange(product.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 너비 */}
<td className="border border-gray-400 p-1 text-center" rowSpan={rowCount}>{product.widthDesign || 'N/A'}</td>
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
<input type="text" value={product.widthMeasured} onChange={(e) => handleInputChange(product.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
</>
)}
{/* 간격 - 포인트별 개별 행 */}
<td className="border border-gray-400 p-1 text-center">{gap.point}</td>
<td className="border border-gray-400 p-1 text-center">{gap.designValue}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={gap.measured} onChange={(e) => handleGapMeasuredChange(product.id, gapIdx, e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 판정 - 자동 (첫 행에만) */}
{gapIdx === 0 && (
<td className={`border border-gray-400 p-1 text-center font-bold ${
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
}`} rowSpan={rowCount}>
{judgment || '-'}
</td>
)}
</tr>
));
})}
</tbody>
</table>
{/* ===== 부적합 내용 + 종합판정 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
/**
* 절곡 작업일지 문서 콘텐츠
*
* 기획서 기준 구성:
* - 헤더: "작업일지 (절곡)" + 문서번호/작성일자 + 결재란(결재|작성/승인/승인/승인)
* - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일)
* - 제품명 / 재질 / 마감 / 유형 테이블
* - 작업내역: 유형명, 세부품명, 재질, 입고 & 생산 LOT NO, 길이/규격, 수량
* - 생산량 합계 [kg]: SUS / EGI
*/
import type { WorkOrder } from '../types';
import { SectionHeader } from '@/components/document-system';
interface BendingWorkLogContentProps {
data: WorkOrder;
}
export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProps) {
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const items = order.items || [];
const formattedDueDate = order.dueDate !== '-'
? new Date(order.dueDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '')
: '-';
// 빈 행 수 (기획서에 여러 빈 행 표시)
const EMPTY_ROWS = 4;
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
{/* 좌측: 제목 + 문서번호 */}
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 신청업체 / 신청내용 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2">{order.salesOrderDate || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2">{order.projectName}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.client}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">-</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium" colSpan={2}></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{formattedDueDate}</td>
</tr>
</tbody>
</table>
{/* ===== 제품 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
</tbody>
</table>
{/* ===== 작업내역 ===== */}
<SectionHeader variant="dark"></SectionHeader>
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"> &amp; LOT NO</th>
<th className="border border-gray-400 p-2">/</th>
<th className="border border-gray-400 p-2 w-16"></th>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 p-2">{item.productName}</td>
<td className="border border-gray-400 p-2">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">{order.lotNo}</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">{item.quantity}</td>
</tr>
))
) : (
Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
<tr key={idx}>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
))
)}
</tbody>
</table>
{/* ===== 생산량 합계 [kg] ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2"> [kg]</th>
<th className="border border-gray-400 p-2">SUS</th>
<th className="border border-gray-400 p-2">EGI</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
/**
* 중간검사 성적서 모달
*
* DocumentViewer 껍데기 안에 공정별 검사 성적서 콘텐츠를 표시
* - screen: ScreenInspectionContent
* - slat: SlatInspectionContent
* - bending: BendingInspectionContent
*/
import { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { getWorkOrderById } from '../actions';
import type { WorkOrder, ProcessType } from '../types';
import { ScreenInspectionContent } from './ScreenInspectionContent';
import { SlatInspectionContent } from './SlatInspectionContent';
import { BendingInspectionContent } from './BendingInspectionContent';
const PROCESS_LABELS: Record<ProcessType, string> = {
screen: '스크린',
slat: '슬랫',
bending: '절곡',
};
interface InspectionReportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workOrderId: string | null;
processType?: ProcessType;
}
export function InspectionReportModal({
open,
onOpenChange,
workOrderId,
processType = 'screen',
}: InspectionReportModalProps) {
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 목업 WorkOrder 생성
const createMockOrder = (id: string, pType: ProcessType): WorkOrder => ({
id,
workOrderNo: 'KD-WO-260129-01',
lotNo: 'KD-SA-260129-01',
processId: 1,
processName: pType === 'slat' ? '슬랫' : pType === 'bending' ? '절곡' : '스크린',
processCode: pType,
processType: pType,
status: 'in_progress',
client: '(주)경동',
projectName: '서울 강남 현장',
dueDate: '2026-02-05',
assignee: '홍길동',
assignees: [{ id: '1', name: '홍길동', isPrimary: true }],
orderDate: '2026-01-20',
scheduledDate: '2026-01-29',
shipmentDate: '2026-02-05',
salesOrderDate: '2026-01-15',
isAssigned: true,
isStarted: true,
priority: 3,
priorityLabel: '긴급',
shutterCount: 12,
department: '생산부',
items: [
{ id: '1', no: 1, status: 'in_progress', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '8,260 X 8,350', quantity: 2, unit: 'EA' },
{ id: '2', no: 2, status: 'waiting', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '6,400 X 5,200', quantity: 4, unit: 'EA' },
{ id: '3', no: 3, status: 'completed', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12,000 X 4,500', quantity: 1, unit: 'EA' },
],
currentStep: { key: 'cutting', label: '절단', order: 2 },
completedSteps: ['material_input'],
totalProgress: 25,
issues: [],
memo: '',
createdAt: '2026-01-20T09:00:00',
updatedAt: '2026-01-29T14:00:00',
});
useEffect(() => {
if (open && workOrderId) {
// 목업 ID인 경우 API 호출 생략
if (workOrderId.startsWith('mock-')) {
setOrder(createMockOrder(workOrderId, processType));
setError(null);
return;
}
setIsLoading(true);
setError(null);
getWorkOrderById(workOrderId)
.then((result) => {
if (result.success && result.data) {
setOrder(result.data);
} else {
setError(result.error || '데이터를 불러올 수 없습니다.');
}
})
.catch(() => {
setError('서버 오류가 발생했습니다.');
})
.finally(() => {
setIsLoading(false);
});
} else if (!open) {
setOrder(null);
setError(null);
}
}, [open, workOrderId, processType]);
if (!workOrderId) return null;
const processLabel = PROCESS_LABELS[processType] || '스크린';
const subtitle = order ? `${processLabel} 생산부서` : undefined;
const renderContent = () => {
if (!order) return null;
switch (processType) {
case 'screen':
return <ScreenInspectionContent data={order} />;
case 'slat':
return <SlatInspectionContent data={order} />;
case 'bending':
return <BendingInspectionContent data={order} />;
default:
return <ScreenInspectionContent data={order} />;
}
};
return (
<DocumentViewer
title="중간검사 성적서"
subtitle={subtitle}
preset="inspection"
open={open}
onOpenChange={onOpenChange}
>
{isLoading ? (
<div className="flex items-center justify-center h-64 bg-white">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : error || !order ? (
<div className="flex flex-col items-center justify-center h-64 gap-4 bg-white">
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
</div>
) : (
renderContent()
)}
</DocumentViewer>
);
}

View File

@@ -0,0 +1,383 @@
'use client';
/**
* 스크린 중간검사 성적서 문서 콘텐츠
*
* 기획서 기준:
* - 헤더: "중간검사성적서 (스크린)" + 결재란
* - 기본정보: 제품명/스크린, 규격/와이어 글라스 코팅직물, 수주처, 현장명 | 제품LOT NO, 로트크기, 검사일자, 검사자
* - ■ 중간검사 기준서: 도해 + 검사항목/검사기준/검사방법/검사주기/관련규정
* 가공상태, 재봉상태, 조립상태, 치수(길이/높이/간격)
* - ■ 중간검사 DATA: No, 가공상태결모양(양호/불량), 재봉상태결모양(양호/불량), 조립상태(양호/불량),
* 길이(도면치수/측정값입력), 나비(도면치수/측정값입력), 간격(기준치/OK·NG선택), 판정(자동)
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import type { WorkOrder } from '../types';
interface ScreenInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
}
type CheckStatus = '양호' | '불량' | null;
type GapResult = 'OK' | 'NG' | null;
interface InspectionRow {
id: number;
processStatus: CheckStatus; // 가공상태 결모양
sewingStatus: CheckStatus; // 재봉상태 결모양
assemblyStatus: CheckStatus; // 조립상태
lengthDesign: string; // 길이 도면치수 (표시용)
lengthMeasured: string; // 길이 측정값 (입력)
widthDesign: string; // 나비 도면치수 (표시용)
widthMeasured: string; // 나비 측정값 (입력)
gapStandard: string; // 간격 기준치 (표시용)
gapResult: GapResult; // 간격 측정값 (OK/NG 선택)
}
const DEFAULT_ROW_COUNT = 6;
export function ScreenInspectionContent({ data: order, readOnly = false }: ScreenInspectionContentProps) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const [rows, setRows] = useState<InspectionRow[]>(() =>
Array.from({ length: DEFAULT_ROW_COUNT }, (_, i) => ({
id: i + 1,
processStatus: null,
sewingStatus: null,
assemblyStatus: null,
lengthDesign: '7,400',
lengthMeasured: '',
widthDesign: '2,950',
widthMeasured: '',
gapStandard: '400 이하',
gapResult: null,
}))
);
const [inadequateContent, setInadequateContent] = useState('');
const handleStatusChange = useCallback((rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
));
}, [readOnly]);
const handleInputChange = useCallback((rowId: number, field: 'lengthMeasured' | 'widthMeasured', value: string) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
));
}, [readOnly]);
const handleGapChange = useCallback((rowId: number, value: GapResult) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, gapResult: value } : row
));
}, [readOnly]);
// 행별 판정 자동 계산
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
const { processStatus, sewingStatus, assemblyStatus, gapResult } = row;
// 하나라도 불량 or NG → 부
if (processStatus === '불량' || sewingStatus === '불량' || assemblyStatus === '불량' || gapResult === 'NG') {
return '부';
}
// 모두 양호 + OK → 적
if (processStatus === '양호' && sewingStatus === '양호' && assemblyStatus === '양호' && gapResult === 'OK') {
return '적';
}
return null;
}, []);
// 종합판정 자동 계산
const overallResult = useMemo(() => {
const judgments = rows.map(getRowJudgment);
if (judgments.some(j => j === '부')) return '불합격';
if (judgments.every(j => j === '적')) return '합격';
return null;
}, [rows, getRowJudgment]);
// 체크박스 렌더 (양호/불량)
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => (
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '양호'}
onChange={() => handleStatusChange(rowId, field, value === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '불량'}
onChange={() => handleStatusChange(rowId, field, value === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
</div>
</td>
);
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 기본 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2"> </td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.items?.length || 0} </td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.client || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.projectName || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 기준서 ===== */}
<div className="mb-1 font-bold text-sm"> </div>
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
{/* 도해 영역 */}
<td className="border border-gray-400 p-4 text-center text-gray-300 align-middle w-1/4" rowSpan={8}>
<div className="h-40 flex items-center justify-center"> </div>
</td>
{/* 헤더 행 */}
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium" colSpan={2}></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1</td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium" colSpan={2}></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium" colSpan={2}></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1">KS F 4510<br/>n = 1, c = 0</td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50" rowSpan={3}><br/>(mm)</td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> ± 4</td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> + 40</td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1">400 </td>
<td className="border border-gray-400 px-2 py-1 text-center">GONO </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 DATA ===== */}
<div className="mb-1 font-bold text-sm"> DATA</div>
<table className="w-full border-collapse text-xs mb-4">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}></th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1 w-14" rowSpan={2}><br/>(/)</th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const judgment = getRowJudgment(row);
return (
<tr key={row.id}>
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
{/* 가공상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'processStatus', row.processStatus)}
{/* 재봉상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'sewingStatus', row.sewingStatus)}
{/* 조립상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'assemblyStatus', row.assemblyStatus)}
{/* 길이 - 도면치수 표시 + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.lengthMeasured} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 나비 - 도면치수 표시 + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.widthDesign}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.widthMeasured} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 간격 - 기준치 표시 + OK/NG 선택 */}
<td className="border border-gray-400 p-1 text-center">{row.gapStandard}</td>
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={row.gapResult === 'OK'}
onChange={() => handleGapChange(row.id, row.gapResult === 'OK' ? null : 'OK')}
disabled={readOnly}
className="w-3 h-3"
/>
OK
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={row.gapResult === 'NG'}
onChange={() => handleGapChange(row.id, row.gapResult === 'NG' ? null : 'NG')}
disabled={readOnly}
className="w-3 h-3"
/>
NG
</label>
</div>
</td>
{/* 판정 - 자동 계산 */}
<td className={`border border-gray-400 p-1 text-center font-bold ${
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
}`}>
{judgment || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
{/* ===== 부적합 내용 + 종합판정 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
/**
* 스크린 작업일지 문서 콘텐츠
*
* 기획서 스크린샷 기준 구성:
* - 헤더: "작업일지 (스크린)" + 문서번호/작성일자 + 결재란(작성/승인/승인/승인)
* - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일)
* - 작업내역 테이블: No, 입고 LOT NO, 제품명, 부호, 제작사이즈(가로/세로), 나머지 높이,
* 규격(매수)(1220/900/600/400/300), 제작, 재단 사항, 잔량, 완료
* - 합계
* - 내화실 입고 LOT NO
* - 비고
*/
import type { WorkOrder } from '../types';
import { SectionHeader } from '@/components/document-system';
interface ScreenWorkLogContentProps {
data: WorkOrder;
}
export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps) {
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const items = order.items || [];
const formattedDueDate = order.dueDate !== '-'
? new Date(order.dueDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '')
: '-';
// 규격 사이즈 컬럼
const SCREEN_SIZES = ['1220', '900', '600', '400', '300'];
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
{/* 좌측: 제목 + 문서번호 */}
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 신청업체 / 신청내용 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2">{order.salesOrderDate || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2">{order.projectName}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.client}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">-</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium" colSpan={2}></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{formattedDueDate}</td>
</tr>
</tbody>
</table>
{/* ===== 작업내역 ===== */}
<SectionHeader variant="dark"></SectionHeader>
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-2 w-20" rowSpan={2}> LOT<br/>NO</th>
<th className="border border-gray-400 p-2" rowSpan={2}></th>
<th className="border border-gray-400 p-2 w-12" rowSpan={2}></th>
<th className="border border-gray-400 p-2" colSpan={2}></th>
<th className="border border-gray-400 p-2 w-12" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2" colSpan={5}> ()</th>
<th className="border border-gray-400 p-2 w-14" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2 w-12" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2 w-14" rowSpan={2}><br/></th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2 w-12"></th>
<th className="border border-gray-400 p-2 w-12"></th>
{SCREEN_SIZES.map(size => (
<th key={size} className="border border-gray-400 p-1 w-10">{size}</th>
))}
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 p-2 text-center">{idx + 1}</td>
<td className="border border-gray-400 p-2 text-center">{order.lotNo}</td>
<td className="border border-gray-400 p-2">{item.productName}</td>
<td className="border border-gray-400 p-2 text-center">{item.floorCode}</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
{SCREEN_SIZES.map(size => (
<td key={size} className="border border-gray-400 p-1 text-center">-</td>
))}
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
</tr>
))
) : (
<tr>
<td colSpan={15} className="border border-gray-400 p-4 text-center text-gray-400">
.
</td>
</tr>
)}
{/* 합계 행 */}
<tr className="bg-gray-50 font-medium">
<td className="border border-gray-400 p-2 text-center" colSpan={4}></td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
{SCREEN_SIZES.map(size => (
<td key={size} className="border border-gray-400 p-1 text-center">-</td>
))}
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
</tr>
</tbody>
</table>
{/* ===== 내화실 입고 LOT NO ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-40"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2 min-h-[32px]">&nbsp;</td>
</tr>
</tbody>
</table>
{/* ===== 비고 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-40 align-top"></td>
<td className="border border-gray-400 px-3 py-3 min-h-[60px]">
{order.note || ''}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,342 @@
'use client';
/**
* 슬랫 중간검사 성적서 문서 콘텐츠
*
* 기획서 기준:
* - 헤더: "중간검사성적서 (슬랫)" + 결재란
* - 기본정보: 제품명/슬랫, 규격/EGI 1.6T, 수주처, 현장명 | 제품LOT NO, 로트크기, 검사일자, 검사자
* - ■ 중간검사 기준서: 도해 + 검사항목/검사기준/검사방법/검사주기/관련규정
* 가공상태, 결모양, 조립상태, 치수(높이/길이)
* - ■ 중간검사 DATA: No, 가공상태결모양(양호/불량), 조립상태결모양(양호/불량),
* ①높이(기준치/측정값입력), ②높이(기준치/측정값입력),
* 길이(엔드락제외)(도면치수/측정값입력), 판정(자동)
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import type { WorkOrder } from '../types';
interface SlatInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
}
type CheckStatus = '양호' | '불량' | null;
interface InspectionRow {
id: number;
processStatus: CheckStatus; // 가공상태 결모양
assemblyStatus: CheckStatus; // 조립상태 결모양
height1Standard: string; // ① 높이 기준치 (표시용)
height1Measured: string; // ① 높이 측정값 (입력)
height2Standard: string; // ② 높이 기준치 (표시용)
height2Measured: string; // ② 높이 측정값 (입력)
lengthDesign: string; // 길이 도면치수 (입력)
lengthMeasured: string; // 길이 측정값 (입력)
}
const DEFAULT_ROW_COUNT = 6;
export function SlatInspectionContent({ data: order, readOnly = false }: SlatInspectionContentProps) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const [rows, setRows] = useState<InspectionRow[]>(() =>
Array.from({ length: DEFAULT_ROW_COUNT }, (_, i) => ({
id: i + 1,
processStatus: null,
assemblyStatus: null,
height1Standard: '16.5 ± 1',
height1Measured: '',
height2Standard: '14.5 ± 1',
height2Measured: '',
lengthDesign: '0',
lengthMeasured: '',
}))
);
const [inadequateContent, setInadequateContent] = useState('');
const handleStatusChange = useCallback((rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
));
}, [readOnly]);
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
));
}, [readOnly]);
// 행별 판정 자동 계산
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
const { processStatus, assemblyStatus } = row;
if (processStatus === '불량' || assemblyStatus === '불량') return '부';
if (processStatus === '양호' && assemblyStatus === '양호') return '적';
return null;
}, []);
// 종합판정 자동 계산
const overallResult = useMemo(() => {
const judgments = rows.map(getRowJudgment);
if (judgments.some(j => j === '부')) return '불합격';
if (judgments.every(j => j === '적')) return '합격';
return null;
}, [rows, getRowJudgment]);
// 체크박스 렌더 (양호/불량)
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => (
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '양호'}
onChange={() => handleStatusChange(rowId, field, value === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '불량'}
onChange={() => handleStatusChange(rowId, field, value === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
</div>
</td>
);
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 기본 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">EGI 1.6T</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.items?.length || 0} </td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.client || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.projectName || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 기준서 ===== */}
<div className="mb-1 font-bold text-sm"> </div>
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
{/* 도해 영역 */}
<td className="border border-gray-400 p-4 text-center text-gray-300 align-middle w-1/5" rowSpan={7}>
<div className="h-40 flex items-center justify-center"> </div>
</td>
{/* 헤더 행 */}
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={3}></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
</tr>
{/* 결모양 > 가공상태 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium text-center" rowSpan={3}></td>
<td className="border border-gray-400 px-2 py-1 font-medium" colSpan={2}></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1</td>
</tr>
{/* 결모양 > 조립상태 (상단) */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium" colSpan={2} rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1" rowSpan={2}> <br/> <br/> <br/> </td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={2}>n = 1, c = 0</td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 9</td>
</tr>
{/* 결모양 > 조립상태 (하단 - 자체규정) */}
<tr>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
{/* 치수 > 높이 > ① */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50 text-center" rowSpan={3}><br/>(mm)</td>
<td className="border border-gray-400 px-2 py-1 font-medium text-center" rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center">16.5 ± 1</td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}></td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}></td>
<td className="border border-gray-400 px-2 py-1" rowSpan={3}>KS F 4510 7<br/>9</td>
</tr>
{/* 치수 > 높이 > ② */}
<tr>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center">14.5 ± 1</td>
</tr>
{/* 치수 > 길이 > ③ */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center">() ± 4</td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 DATA ===== */}
<div className="mb-1 font-bold text-sm"> DATA</div>
<table className="w-full border-collapse text-xs mb-4">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)<br/><span className="font-normal text-gray-500">( )</span></th>
<th className="border border-gray-400 p-1 w-14" rowSpan={2}><br/>(/)</th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const judgment = getRowJudgment(row);
return (
<tr key={row.id}>
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
{/* 가공상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'processStatus', row.processStatus)}
{/* 조립상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'assemblyStatus', row.assemblyStatus)}
{/* ① 높이 - 기준치 표시 + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.height1Standard}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.height1Measured} onChange={(e) => handleInputChange(row.id, 'height1Measured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* ② 높이 - 기준치 표시 + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.height2Standard}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.height2Measured} onChange={(e) => handleInputChange(row.id, 'height2Measured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 길이 (엔드락 제외) - 도면치수 표시 (입력 불가) + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign || '-'}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.lengthMeasured} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 판정 - 자동 계산 */}
<td className={`border border-gray-400 p-1 text-center font-bold ${
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
}`}>
{judgment || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
{/* ===== 부적합 내용 + 종합판정 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
/**
* 슬랫 작업일지 문서 콘텐츠
*
* 기획서 기준 구성:
* - 헤더: "작업일지 (슬랫)" + 문서번호/작성일자 + 결재란(결재|작성/승인/승인/승인)
* - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일)
* - 작업내역: No, 입고 LOT NO, 방화유리 수량, 제품명,
* 제작사이즈(mm)-미미제외(가로/세로/매수(세로)), 조인트바 수량, 코일 사용량, 설치홈/부호
* - 생산량 합계[m²] / 조인트바 합계
* - 비고
*/
import type { WorkOrder } from '../types';
import { SectionHeader } from '@/components/document-system';
interface SlatWorkLogContentProps {
data: WorkOrder;
}
export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) {
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const items = order.items || [];
const formattedDueDate = order.dueDate !== '-'
? new Date(order.dueDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '')
: '-';
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
{/* 좌측: 제목 + 문서번호 */}
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 신청업체 / 신청내용 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2">{order.salesOrderDate || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2">{order.projectName}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.client}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">-</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium" colSpan={2}></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{formattedDueDate}</td>
</tr>
</tbody>
</table>
{/* ===== 작업내역 ===== */}
<SectionHeader variant="dark"></SectionHeader>
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-2 w-24" rowSpan={2}> LOT<br/>NO</th>
<th className="border border-gray-400 p-2 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2" rowSpan={2}></th>
<th className="border border-gray-400 p-2" colSpan={3}>(mm) - </th>
<th className="border border-gray-400 p-2 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2 w-16" rowSpan={2}>/<br/></th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2 w-14"></th>
<th className="border border-gray-400 p-2 w-14"></th>
<th className="border border-gray-400 p-2 w-14"><br/>()</th>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 p-2 text-center">{idx + 1}</td>
<td className="border border-gray-400 p-2 text-center">{order.lotNo}</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2">{item.productName}</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
</tr>
))
) : (
<tr>
<td colSpan={10} className="border border-gray-400 p-4 text-center text-gray-400">
.
</td>
</tr>
)}
</tbody>
</table>
{/* ===== 생산량 합계 / 조인트바 합계 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-36"> [m²]</td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-36"> </td>
<td className="border border-gray-400 px-3 py-2"></td>
</tr>
</tbody>
</table>
{/* ===== 비고 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-40 align-top"></td>
<td className="border border-gray-400 px-3 py-3 min-h-[60px]">
{order.note || ''}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,12 @@
// 작업일지 문서 (공정별)
export { ScreenWorkLogContent } from './ScreenWorkLogContent';
export { SlatWorkLogContent } from './SlatWorkLogContent';
export { BendingWorkLogContent } from './BendingWorkLogContent';
// 중간검사 성적서 문서 (공정별)
export { ScreenInspectionContent } from './ScreenInspectionContent';
export { SlatInspectionContent } from './SlatInspectionContent';
export { BendingInspectionContent } from './BendingInspectionContent';
// 모달
export { InspectionReportModal } from './InspectionReportModal';

View File

@@ -110,6 +110,7 @@ export interface WorkOrderItem {
floorCode: string; // 층/부호
specification: string; // 규격
quantity: number;
unit: string; // 단위
}
// 전개도 상세 (절곡용)
@@ -180,6 +181,12 @@ export interface WorkOrder {
// 우선순위
priority: number; // 1~9 (1=긴급, 9=낮음)
priorityLabel: string; // 우선순위 라벨 (긴급/우선/일반)
// 수주 관련
salesOrderDate: string; // 수주일
shutterCount: number | null; // 틀수
department: string; // 부서명
// 품목
items: WorkOrderItem[];
@@ -306,9 +313,12 @@ export interface WorkOrderApi {
updated_at: string;
deleted_at: string | null;
// Relations
priority: number | null;
shutter_count: number | null;
sales_order?: {
id: number;
order_no: string;
order_date?: string;
client?: { id: number; name: string };
};
process?: {
@@ -370,6 +380,14 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
return mapping[name] || 'screen';
};
// 우선순위 매핑 (1~3: 긴급, 4~6: 우선, 7~9: 일반)
const priorityValue = api.priority ?? 5;
const getPriorityLabel = (p: number): string => {
if (p <= 3) return '긴급';
if (p <= 6) return '우선';
return '일반';
};
return {
id: String(api.id),
workOrderNo: api.work_order_no,
@@ -397,7 +415,11 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
shipmentDate: api.scheduled_date || '-',
isAssigned: api.assignee_id !== null || assignees.length > 0,
isStarted: ['in_progress', 'completed', 'shipped'].includes(api.status),
priority: 5, // Default priority
priority: priorityValue,
priorityLabel: getPriorityLabel(priorityValue),
salesOrderDate: api.sales_order?.order_date || api.created_at.split('T')[0],
shutterCount: api.shutter_count ?? null,
department: api.team?.name || '-',
currentStep: getStatusStep(api.status),
items: (api.items || []).map((item, idx) => ({
id: String(item.id),
@@ -407,6 +429,7 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
floorCode: '-',
specification: item.specification || '-',
quantity: item.quantity,
unit: item.unit || '-',
})),
bendingDetails: api.bending_detail ? transformBendingDetail(api.bending_detail) : undefined,
issues: (api.issues || []).map(issue => ({

View File

@@ -1,13 +1,11 @@
'use client';
/**
* 자재투입 모달
* API 연동 완료 (2025-12-26)
* 자재투입 모달 (기획서 기반)
*
* 기획 화면에 맞춘 레이아웃:
* - FIFO 순위 설명 (1 최우선, 2 차선, 3+ 대기)
* - ① 자재 선택 (BOM 기준) 테이블
* - 취소 / 투입 등록 버튼 (전체 너비)
* 기획서 변경: BOM 체크박스 → 투입수량 입력 테이블
* 컬럼: 로트번호 | 품목명 | 수량 | 단위 | 투입 수량 (input, 숫자만)
* 하단: 취소 / 투입
*/
import { useState, useEffect, useCallback } from 'react';
@@ -20,8 +18,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
@@ -54,20 +51,48 @@ export function MaterialInputModal({
isCompletionFlow = false,
onSaveMaterials,
}: MaterialInputModalProps) {
const [selectedMaterials, setSelectedMaterials] = useState<Set<string>>(new Set());
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
const [inputQuantities, setInputQuantities] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// 목업 자재 데이터 (기획서 기반 10행)
const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 10 }, (_, i) => ({
id: 100 + i,
materialCode: '123123',
materialName: '품목명',
unit: 'm',
currentStock: 500,
fifoRank: i + 1,
}));
// API로 자재 목록 로드
const loadMaterials = useCallback(async () => {
if (!order) return;
setIsLoading(true);
try {
// 목업 아이템인 경우 목업 자재 데이터 사용
if (order.id.startsWith('mock-')) {
setMaterials(MOCK_MATERIALS);
const initialQuantities: Record<string, string> = {};
MOCK_MATERIALS.forEach((m) => {
initialQuantities[String(m.id)] = '';
});
setInputQuantities(initialQuantities);
setIsLoading(false);
return;
}
const result = await getMaterialsForWorkOrder(order.id);
if (result.success) {
setMaterials(result.data);
// 초기 투입 수량 비우기
const initialQuantities: Record<string, string> = {};
result.data.forEach((m) => {
initialQuantities[String(m.id)] = '';
});
setInputQuantities(initialQuantities);
} else {
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
}
@@ -87,47 +112,50 @@ export function MaterialInputModal({
}
}, [open, order, loadMaterials]);
const handleToggleMaterial = (materialId: string) => {
setSelectedMaterials((prev) => {
const next = new Set(prev);
if (next.has(materialId)) {
next.delete(materialId);
} else {
next.add(materialId);
}
return next;
});
// 투입 수량 변경 핸들러 (숫자만 허용)
const handleQuantityChange = (materialId: string, value: string) => {
// 숫자만 허용
const numericValue = value.replace(/[^0-9]/g, '');
setInputQuantities((prev) => ({
...prev,
[materialId]: numericValue,
}));
};
// 투입 등록
const handleSubmit = async () => {
if (!order) return;
// 투입 수량이 입력된 항목 필터
const materialsWithQuantity = materials.filter((m) => {
const qty = inputQuantities[String(m.id)];
return qty && parseInt(qty) > 0;
});
if (materialsWithQuantity.length === 0) {
toast.error('투입 수량을 입력해주세요.');
return;
}
setIsSubmitting(true);
try {
// 선택된 자재 ID 배열
const materialIds = materials
.filter((m) => selectedMaterials.has(String(m.id)))
.map((m) => m.id);
const materialIds = materialsWithQuantity.map((m) => m.id);
const result = await registerMaterialInput(order.id, materialIds);
if (result.success) {
toast.success('자재 투입이 등록되었습니다.');
// onSaveMaterials 콜백 호출 (기존 호환성)
// onSaveMaterials 콜백 호출
if (onSaveMaterials) {
const selectedMaterialList: MaterialInput[] = materials
.filter((m) => selectedMaterials.has(String(m.id)))
.map((m) => ({
id: String(m.id),
materialCode: m.materialCode,
materialName: m.materialName,
unit: m.unit,
currentStock: m.currentStock,
fifoRank: m.fifoRank,
}));
onSaveMaterials(order.id, selectedMaterialList);
const savedList: MaterialInput[] = materialsWithQuantity.map((m) => ({
id: String(m.id),
lotNo: '', // API에서 가져올 필드
materialName: m.materialName,
quantity: m.currentStock,
unit: m.unit,
inputQuantity: parseInt(inputQuantities[String(m.id)] || '0'),
}));
onSaveMaterials(order.id, savedList);
}
resetAndClose();
@@ -147,13 +175,12 @@ export function MaterialInputModal({
}
};
// 취소
const handleCancel = () => {
resetAndClose();
};
const resetAndClose = () => {
setSelectedMaterials(new Set());
setInputQuantities({});
onOpenChange(false);
};
@@ -164,100 +191,79 @@ export function MaterialInputModal({
<DialogContent className="max-w-2xl p-0 gap-0">
{/* 헤더 */}
<DialogHeader className="p-6 pb-4">
<DialogTitle className="text-xl font-semibold"> </DialogTitle>
<DialogTitle className="text-xl font-semibold"> </DialogTitle>
</DialogHeader>
<div className="px-6 pb-6 space-y-6">
{/* FIFO 순위 설명 */}
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<span className="text-sm font-medium text-gray-700">FIFO :</span>
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
1
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
2
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
3+
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
{/* 자재 목록 테이블 */}
{isLoading ? (
<ContentSkeleton type="table" rows={4} />
) : materials.length === 0 ? (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
{/* 자재 선택 섹션 */}
<div>
<h3 className="text-sm font-medium text-gray-900 mb-3">
(BOM )
</h3>
{isLoading ? (
<ContentSkeleton type="table" rows={4} />
) : materials.length === 0 ? (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
.
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow key={material.id}>
<TableCell className="text-center text-sm">
{material.materialCode}
</TableCell>
<TableCell className="text-center text-sm">
{material.materialName}
</TableCell>
<TableCell className="text-center text-sm">
{material.currentStock.toLocaleString()}
</TableCell>
<TableCell className="text-center text-sm">
{material.unit}
</TableCell>
<TableCell className="text-center">
<Input
type="text"
inputMode="numeric"
placeholder="0"
value={inputQuantities[String(material.id)] || ''}
onChange={(e) =>
handleQuantityChange(String(material.id), e.target.value)
}
className="w-20 mx-auto text-center h-8 text-sm"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow key={material.id}>
<TableCell className="text-center font-medium">
{material.materialCode}
</TableCell>
<TableCell className="text-center">{material.materialName}</TableCell>
<TableCell className="text-center">{material.unit}</TableCell>
<TableCell className="text-center">
{material.currentStock.toLocaleString()}
</TableCell>
<TableCell className="text-center">
<Checkbox
checked={selectedMaterials.has(String(material.id))}
onCheckedChange={() => handleToggleMaterial(String(material.id))}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
))}
</TableBody>
</Table>
</div>
)}
{/* 버튼 영역 */}
<div className="flex gap-3">
@@ -271,8 +277,8 @@ export function MaterialInputModal({
</Button>
<Button
onClick={handleSubmit}
disabled={selectedMaterials.size === 0 || isSubmitting}
className="flex-1 py-6 text-base font-medium bg-gray-400 hover:bg-gray-500 disabled:bg-gray-300"
disabled={isSubmitting}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
{isSubmitting ? (
<>
@@ -280,7 +286,7 @@ export function MaterialInputModal({
...
</>
) : (
'투입 등록'
'투입'
)}
</Button>
</div>

View File

@@ -0,0 +1,313 @@
'use client';
/**
* 작업 아이템 카드 컴포넌트 (기획서 기반)
*
* 공통: 번호 + 품목코드(품목명) + 층/부호, 제작사이즈, 진척률바, pills, 자재투입목록(토글)
* 스크린: 절단정보 (폭 X 장)
* 슬랫: 길이 / 슬랫매수 / 조인트바
* 절곡: 도면(IMG) + 공통사항 + 세부부품
*/
import { useState, useCallback } from 'react';
import { ChevronDown, ChevronUp, Pencil, Trash2, ImageIcon } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
import type {
WorkItemData,
WorkStepData,
MaterialListItem,
} from './types';
interface WorkItemCardProps {
item: WorkItemData;
onStepClick: (itemId: string, step: WorkStepData) => void;
onEditMaterial: (itemId: string, material: MaterialListItem) => void;
onDeleteMaterial: (itemId: string, materialId: string) => void;
}
export function WorkItemCard({
item,
onStepClick,
onEditMaterial,
onDeleteMaterial,
}: WorkItemCardProps) {
const [isMaterialListOpen, setIsMaterialListOpen] = useState(false);
// 진척률 계산
const completedSteps = item.steps.filter((s) => s.isCompleted).length;
const totalSteps = item.steps.length;
const progressPercent = totalSteps > 0 ? (completedSteps / totalSteps) * 100 : 0;
const handleStepClick = useCallback(
(step: WorkStepData) => {
onStepClick(item.id, step);
},
[item.id, onStepClick]
);
return (
<Card className="bg-white shadow-sm border border-gray-200">
<CardContent className="p-4 space-y-3">
{/* 헤더: 번호 + 품목코드(품목명) + 층/부호 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-gray-900 text-white text-sm font-bold">
{item.itemNo}
</span>
<span className="text-sm font-semibold text-gray-900">
{item.itemCode} ({item.itemName})
</span>
</div>
<span className="text-sm text-gray-500">
{item.floor} / {item.code}
</span>
</div>
{/* 제작 사이즈 */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<span className="text-gray-500"> </span>
<span className="font-medium">
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
</span>
<span className="font-medium">{item.quantity}</span>
</div>
{/* 공정별 추가 정보 */}
{item.processType === 'screen' && item.cuttingInfo && (
<ScreenCuttingInfo
width={item.cuttingInfo.width}
sheets={item.cuttingInfo.sheets}
/>
)}
{item.processType === 'slat' && item.slatInfo && (
<SlatExtraInfo
length={item.slatInfo.length}
slatCount={item.slatInfo.slatCount}
jointBar={item.slatInfo.jointBar}
/>
)}
{item.processType === 'bending' && item.bendingInfo && (
<BendingExtraInfo info={item.bendingInfo} />
)}
{/* 진척률 프로그래스 바 */}
<div className="space-y-1">
<Progress value={progressPercent} className="h-2" />
<p className="text-xs text-gray-500 text-right">
{completedSteps}/{totalSteps}
</p>
</div>
{/* 공정 단계 pills */}
<div className="flex flex-wrap gap-2">
{item.steps.map((step) => (
<button
key={step.id}
onClick={() => handleStepClick(step)}
className={cn(
'px-3 py-1.5 rounded-md text-xs font-medium transition-colors',
'bg-gray-900 text-white hover:bg-gray-800'
)}
>
{step.name}
{step.isCompleted && (
<span className="ml-1 text-green-400"></span>
)}
</button>
))}
</div>
{/* 자재 투입 목록 (토글) */}
<div>
<button
onClick={() => setIsMaterialListOpen(!isMaterialListOpen)}
className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900"
>
{isMaterialListOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{isMaterialListOpen && (
<div className="mt-2 border rounded-lg overflow-hidden">
{(!item.materialInputs || item.materialInputs.length === 0) ? (
<div className="py-6 text-center text-sm text-gray-500">
.
</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center text-xs"></TableHead>
<TableHead className="text-center text-xs"></TableHead>
<TableHead className="text-center text-xs"></TableHead>
<TableHead className="text-center text-xs"></TableHead>
<TableHead className="text-center text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{item.materialInputs.map((mat) => (
<TableRow key={mat.id}>
<TableCell className="text-center text-xs">{mat.lotNo}</TableCell>
<TableCell className="text-center text-xs">{mat.itemName}</TableCell>
<TableCell className="text-center text-xs">{mat.quantity.toLocaleString()}</TableCell>
<TableCell className="text-center text-xs">{mat.unit}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onEditMaterial(item.id, mat)}
>
<Pencil className="h-3.5 w-3.5 text-gray-500" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onDeleteMaterial(item.id, mat.id)}
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
}
// ===== 스크린 전용: 절단정보 =====
function ScreenCuttingInfo({ width, sheets }: { width: number; sheets: number }) {
return (
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
<p className="text-xs text-gray-500 mb-1"></p>
<p className="text-sm font-medium text-gray-900">
{width.toLocaleString()}mm X {sheets}
</p>
</div>
);
}
// ===== 슬랫 전용: 길이/매수/조인트바 =====
function SlatExtraInfo({
length,
slatCount,
jointBar,
}: {
length: number;
slatCount: number;
jointBar: number;
}) {
return (
<div className="flex gap-2">
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
{length.toLocaleString()}mm
</Badge>
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
{slatCount}
</Badge>
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
{jointBar}
</Badge>
</div>
);
}
// ===== 절곡 전용: 도면 + 공통사항 + 세부부품 =====
import type { BendingInfo } from './types';
function BendingExtraInfo({ info }: { info: BendingInfo }) {
return (
<div className="space-y-3">
{/* 도면 + 공통사항 (가로 배치) */}
<div className="flex gap-3">
{/* 도면 이미지 */}
<div className="flex-shrink-0 w-24 h-24 border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
{info.drawingUrl ? (
<img
src={info.drawingUrl}
alt="도면"
className="w-full h-full object-contain"
/>
) : (
<div className="flex flex-col items-center gap-1 text-gray-400">
<ImageIcon className="h-6 w-6" />
<span className="text-[10px]"></span>
</div>
)}
</div>
{/* 공통사항 */}
<div className="flex-1 space-y-1.5">
<p className="text-xs font-medium text-gray-500"></p>
<div className="space-y-1">
<div className="flex gap-2 text-xs">
<span className="text-gray-500 w-14"></span>
<span className="text-gray-900 font-medium">{info.common.kind}</span>
</div>
<div className="flex gap-2 text-xs">
<span className="text-gray-500 w-14"></span>
<span className="text-gray-900 font-medium">{info.common.type}</span>
</div>
{info.common.lengthQuantities.map((lq, i) => (
<div key={i} className="flex gap-2 text-xs">
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
<span className="text-gray-900 font-medium">
{lq.length.toLocaleString()}mm X {lq.quantity}
</span>
</div>
))}
</div>
</div>
</div>
{/* 세부부품 */}
{info.detailParts.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1.5">
({info.detailParts.length})
</p>
<div className="border rounded-lg divide-y divide-gray-100">
{info.detailParts.map((part, i) => (
<div key={i} className="p-2.5 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-gray-900">{part.partName}</span>
<span className="text-gray-500">{part.material}</span>
</div>
<p className="text-xs text-gray-600">
{part.barcyInfo}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -4,31 +4,85 @@
* 작업일지 모달
*
* document-system 통합 버전 (2026-01-22)
* 공정별 작업일지 지원 (2026-01-29)
* - DocumentViewer 사용
* - WorkLogContent로 문서 본문
* - 공정 타입에 따라 스크린/슬랫/절곡 작업일지
* - processType 미지정 시 기존 WorkLogContent (범용) 사용
*/
import { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { getWorkOrderById } from '../WorkOrders/actions';
import type { WorkOrder } from '../WorkOrders/types';
import type { WorkOrder, ProcessType } from '../WorkOrders/types';
import { WorkLogContent } from './WorkLogContent';
import {
ScreenWorkLogContent,
SlatWorkLogContent,
BendingWorkLogContent,
} from '../WorkOrders/documents';
interface WorkLogModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workOrderId: string | null;
processType?: ProcessType;
}
export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalProps) {
export function WorkLogModal({ open, onOpenChange, workOrderId, processType }: WorkLogModalProps) {
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 목업 WorkOrder 생성
const createMockOrder = (id: string, pType?: ProcessType): WorkOrder => ({
id,
workOrderNo: 'KD-WO-260129-01',
lotNo: 'KD-SA-260129-01',
processId: 1,
processName: pType === 'slat' ? '슬랫' : pType === 'bending' ? '절곡' : '스크린',
processCode: pType || 'screen',
processType: pType || 'screen',
status: 'in_progress',
client: '(주)경동',
projectName: '서울 강남 현장',
dueDate: '2026-02-05',
assignee: '홍길동',
assignees: [{ id: '1', name: '홍길동', isPrimary: true }],
orderDate: '2026-01-20',
scheduledDate: '2026-01-29',
shipmentDate: '2026-02-05',
salesOrderDate: '2026-01-15',
isAssigned: true,
isStarted: true,
priority: 3,
priorityLabel: '긴급',
shutterCount: 12,
department: '생산부',
items: [
{ id: '1', no: 1, status: 'in_progress', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '8,260 X 8,350', quantity: 2, unit: 'EA' },
{ id: '2', no: 2, status: 'waiting', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '6,400 X 5,200', quantity: 4, unit: 'EA' },
{ id: '3', no: 3, status: 'completed', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12,000 X 4,500', quantity: 1, unit: 'EA' },
],
currentStep: { key: 'cutting', label: '절단', order: 2 },
completedSteps: ['material_input'],
totalProgress: 25,
issues: [],
memo: '',
createdAt: '2026-01-20T09:00:00',
updatedAt: '2026-01-29T14:00:00',
});
// 모달 열릴 때 데이터 fetch
useEffect(() => {
if (open && workOrderId) {
// 목업 ID인 경우 API 호출 생략
if (workOrderId.startsWith('mock-')) {
setOrder(createMockOrder(workOrderId, processType));
setError(null);
return;
}
setIsLoading(true);
setError(null);
@@ -51,13 +105,32 @@ export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalPr
setOrder(null);
setError(null);
}
}, [open, workOrderId]);
}, [open, workOrderId, processType]);
if (!workOrderId) return null;
// 로딩/에러 상태는 DocumentViewer 내부에서 처리
const subtitle = order ? `${order.processName} 생산부서` : undefined;
// 공정 타입에 따라 콘텐츠 분기
const renderContent = () => {
if (!order) return null;
// processType prop 또는 order의 processType 사용
const type = processType || order.processType;
switch (type) {
case 'screen':
return <ScreenWorkLogContent data={order} />;
case 'slat':
return <SlatWorkLogContent data={order} />;
case 'bending':
return <BendingWorkLogContent data={order} />;
default:
return <WorkLogContent data={order} />;
}
};
return (
<DocumentViewer
title="작업일지"
@@ -75,7 +148,7 @@ export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalPr
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
</div>
) : (
<WorkLogContent data={order} />
renderContent()
)}
</DocumentViewer>
);

View File

@@ -59,11 +59,20 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
? Math.ceil((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
: undefined;
// process_type → processCode/processName 매핑
const processTypeMap: Record<string, { code: string; name: string }> = {
screen: { code: 'screen', name: '스크린' },
slat: { code: 'slat', name: '슬랫' },
bending: { code: 'bending', name: '절곡' },
};
const processInfo = processTypeMap[api.process_type] || { code: api.process_type, name: api.process_type };
return {
id: String(api.id),
orderNo: api.work_order_no,
productName,
process: api.process_type,
processCode: processInfo.code,
processName: processInfo.name,
client: api.sales_order?.client?.name || '-',
projectName: api.project_name || '-',
assignees: api.assignee ? [api.assignee.name] : [],

View File

@@ -1,19 +1,24 @@
'use client';
/**
* 작업자 화면 메인 컴포넌트
* API 연동 완료 (2025-12-26)
* 작업자 화면 메인 컴포넌트 (기획서 기반 전면 개편)
*
* 기능:
* - 상단 통계 카드 4개 (할당/작업중/완료/긴급)
* - 내 작업 목록 카드 리스트
* - 각 작업 카드별 버튼 (전량완료/공정상세/자재투입/작업일지/이슈보고)
* 구조:
* - 상단: 페이지 제목
* - 탭: 스크린/슬랫/절곡 (디폴트: 스크린)
* - 상태 카드 4개 (할일/작업중/완료/긴급)
* - 수주 정보 섹션 (읽기 전용)
* - 작업 정보 섹션 (생산담당자 셀렉트 + 생산일자)
* - 작업 목록 (WorkItemCard 나열)
* - 하단 고정 버튼 (작업일지보기 / 중간검사하기)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -21,24 +26,178 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { PageLayout } from '@/components/organisms/PageLayout';
import { toast } from 'sonner';
import { getMyWorkOrders, completeWorkOrder } from './actions';
import type { WorkOrder } from '../ProductionDashboard/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { WorkerStats, CompletionToastInfo, MaterialInput } from './types';
import { WorkCard } from './WorkCard';
import type {
WorkerStats,
CompletionToastInfo,
MaterialInput,
ProcessTab,
WorkItemData,
WorkStepData,
MaterialListItem,
} from './types';
import { PROCESS_TAB_LABELS } from './types';
import { WorkItemCard } from './WorkItemCard';
import { CompletionConfirmDialog } from './CompletionConfirmDialog';
import { CompletionToast } from './CompletionToast';
import { MaterialInputModal } from './MaterialInputModal';
import { WorkLogModal } from './WorkLogModal';
import { IssueReportModal } from './IssueReportModal';
import { WorkCompletionResultDialog } from './WorkCompletionResultDialog';
import { InspectionReportModal } from '../WorkOrders/documents';
// ===== 목업 데이터 =====
const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
screen: [
{
id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'screen',
cuttingInfo: { width: 1210, sheets: 8 },
steps: [
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
{ id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' },
],
},
{
id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03',
width: 6400, height: 5200, quantity: 4, processType: 'screen',
cuttingInfo: { width: 1600, sheets: 4 },
steps: [
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05',
width: 12000, height: 4500, quantity: 1, processType: 'screen',
cuttingInfo: { width: 2400, sheets: 5 },
steps: [
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
{ id: 's3-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
],
},
],
slat: [
{
id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'slat',
slatInfo: { length: 3910, slatCount: 40, jointBar: 4 },
steps: [
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
],
},
{
id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02',
width: 10500, height: 6200, quantity: 3, processType: 'slat',
slatInfo: { length: 5200, slatCount: 55, jointBar: 6 },
steps: [
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
bending: [
{
id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 6, processType: 'bending',
bendingInfo: {
common: {
kind: '벽면형 120X70', type: '벽면형',
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
},
detailParts: [
{ partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' },
{ partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' },
],
},
steps: [
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
{ id: 'b1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-b2', itemNo: 2, itemCode: 'KWWS10', itemName: '천정레일', floor: '2층', code: 'FSS-04',
width: 0, height: 0, quantity: 4, processType: 'bending',
bendingInfo: {
drawingUrl: '',
common: {
kind: '천정형 80X60', type: '천정형',
lengthQuantities: [{ length: 3500, quantity: 4 }],
},
detailParts: [
{ partName: '상장바', material: 'STS 1.2T', barcyInfo: '12 I 60' },
],
},
steps: [
{ id: 'b2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'b2-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 'b2-3', name: '절곡', isMaterialInput: false, isCompleted: false },
{ id: 'b2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
};
// 하드코딩된 공정별 단계 폴백
const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean }[]> = {
screen: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
{ name: '미싱', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
slat: [
{ name: '자재투입', isMaterialInput: true },
{ name: '포밍/절단', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
bending: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
{ name: '절곡', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
};
export default function WorkerScreen() {
// ===== 상태 관리 =====
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
// 작업 정보
const [productionManagerId, setProductionManagerId] = useState('');
const [productionDate, setProductionDate] = useState('');
// 공정별 step 완료 상태: { [itemId-stepName]: boolean }
const [stepCompletionMap, setStepCompletionMap] = useState<Record<string, boolean>>({});
// 데이터 로드
const loadData = useCallback(async () => {
@@ -68,6 +227,7 @@ export default function WorkerScreen() {
const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
// 전량완료 흐름 상태
@@ -83,72 +243,154 @@ export default function WorkerScreen() {
// 완료 토스트 상태
const [toastInfo, setToastInfo] = useState<CompletionToastInfo | null>(null);
// 정렬 상태
const [sortBy, setSortBy] = useState<'dueDate' | 'latest'>('dueDate');
// ===== 통계 계산 =====
const stats: WorkerStats = useMemo(() => {
return {
assigned: workOrders.length,
inProgress: workOrders.filter((o) => o.status === 'inProgress').length,
completed: 0, // 완료된 것은 목록에서 제외되므로 0
urgent: workOrders.filter((o) => o.isUrgent).length,
};
}, [workOrders]);
// ===== 정렬된 작업 목록 =====
const sortedWorkOrders = useMemo(() => {
return [...workOrders].sort((a, b) => {
if (sortBy === 'dueDate') {
// 납기일순 (가까운 날짜 먼저)
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
} else {
// 최신등록순 (최근 ID가 더 큼 = 최근 등록)
return b.id.localeCompare(a.id);
// ===== 탭별 필터링된 작업 =====
const filteredWorkOrders = useMemo(() => {
// process_type 기반 필터링
return workOrders.filter((order) => {
// WorkOrder의 processCode/processName으로 매칭
const processName = (order.processName || '').toLowerCase();
switch (activeTab) {
case 'screen':
return processName.includes('스크린') || processName === 'screen';
case 'slat':
return processName.includes('슬랫') || processName === 'slat';
case 'bending':
return processName.includes('절곡') || processName === 'bending';
default:
return true;
}
});
}, [workOrders, sortBy]);
}, [workOrders, activeTab]);
// ===== 통계 계산 (탭별) =====
const stats: WorkerStats = useMemo(() => {
return {
assigned: filteredWorkOrders.length,
inProgress: filteredWorkOrders.filter((o) => o.status === 'inProgress').length,
completed: 0,
urgent: filteredWorkOrders.filter((o) => o.isUrgent).length,
};
}, [filteredWorkOrders]);
// ===== WorkOrder → WorkItemData 변환 + 목업 =====
const workItems: WorkItemData[] = useMemo(() => {
const apiItems: WorkItemData[] = filteredWorkOrders.map((order, index) => {
const stepsTemplate = PROCESS_STEPS[activeTab];
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
const stepKey = `${order.id}-${st.name}`;
return {
id: `${order.id}-step-${si}`,
name: st.name,
isMaterialInput: st.isMaterialInput,
isCompleted: stepCompletionMap[stepKey] || false,
};
});
return {
id: order.id,
itemNo: index + 1,
itemCode: order.orderNo || '-',
itemName: order.productName || '-',
floor: '-',
code: '-',
width: 0,
height: 0,
quantity: order.quantity || 0,
processType: activeTab,
steps,
materialInputs: [],
};
});
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
const mockItems = MOCK_ITEMS[activeTab].map((item, i) => ({
...item,
itemNo: apiItems.length + i + 1,
}));
return [...apiItems, ...mockItems];
}, [filteredWorkOrders, activeTab, stepCompletionMap]);
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
const orderInfo = useMemo(() => {
const first = filteredWorkOrders[0];
if (!first) return null;
return {
orderDate: first.createdAt ? new Date(first.createdAt).toLocaleDateString('ko-KR') : '-',
lotNo: '-',
siteName: first.projectName || '-',
client: first.client || '-',
salesManager: first.assignees?.[0] || '-',
managerPhone: '-',
shippingDate: first.dueDate ? new Date(first.dueDate).toLocaleDateString('ko-KR') : '-',
};
}, [filteredWorkOrders]);
// ===== 핸들러 =====
// 전량완료 버튼 클릭
const handleComplete = useCallback(
(order: WorkOrder) => {
setSelectedOrder(order);
// 이미 투입된 자재가 있으면 바로 완료 결과 팝업
const savedMaterials = inputMaterialsMap.get(order.id);
if (savedMaterials && savedMaterials.length > 0) {
// LOT 번호 생성
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
setCompletionLotNo(lotNo);
setIsCompletionResultOpen(true);
// pill 클릭 핸들러
const handleStepClick = useCallback(
(itemId: string, step: WorkStepData) => {
if (step.isMaterialInput) {
// 자재투입 → 자재 투입 모달 열기
const order = workOrders.find((o) => o.id === itemId);
if (order) {
setSelectedOrder(order);
setIsMaterialModalOpen(true);
} else {
// 목업 아이템인 경우 합성 WorkOrder 생성
const mockItem = workItems.find((item) => item.id === itemId);
if (mockItem) {
const syntheticOrder: WorkOrder = {
id: mockItem.id,
orderNo: mockItem.itemCode,
productName: mockItem.itemName,
processCode: mockItem.processType,
processName: PROCESS_TAB_LABELS[mockItem.processType],
client: '-',
projectName: '-',
assignees: [],
quantity: mockItem.quantity,
dueDate: '',
priority: 5,
status: 'waiting',
isUrgent: false,
isDelayed: false,
createdAt: '',
};
setSelectedOrder(syntheticOrder);
setIsMaterialModalOpen(true);
}
}
} else {
// 자재 투입이 필요합니다 팝업
setIsCompletionDialogOpen(true);
// 기타 → 완료/미완료 토글
const stepKey = `${itemId}-${step.name}`;
setStepCompletionMap((prev) => ({
...prev,
[stepKey]: !prev[stepKey],
}));
}
},
[inputMaterialsMap]
[workOrders, workItems]
);
// "자재 투입이 필요합니다" 팝업에서 확인 클릭 → MaterialInputModal 열기
const handleCompletionConfirm = useCallback(() => {
setIsCompletionFlow(true);
setIsMaterialModalOpen(true);
}, []);
// 자재 수정 핸들러
const handleEditMaterial = useCallback(
(itemId: string, material: MaterialListItem) => {
console.log('[WorkerScreen] editMaterial:', itemId, material);
// 추후 구현
},
[]
);
// MaterialInputModal에서 투입 등록/건너뛰기 후 → 작업 완료 결과 팝업 표시
const handleWorkCompletion = useCallback(() => {
if (!selectedOrder) return;
// LOT 번호 생성
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
setCompletionLotNo(lotNo);
// 완료 결과 팝업 표시
setIsCompletionResultOpen(true);
setIsCompletionFlow(false);
}, [selectedOrder]);
// 자재 삭제 핸들러
const handleDeleteMaterial = useCallback(
(itemId: string, materialId: string) => {
console.log('[WorkerScreen] deleteMaterial:', itemId, materialId);
// 추후 구현
},
[]
);
// 자재 저장 핸들러
const handleSaveMaterials = useCallback((orderId: string, materials: MaterialInput[]) => {
@@ -157,14 +399,35 @@ export default function WorkerScreen() {
next.set(orderId, materials);
return next;
});
// 자재투입 step 완료로 마킹
const stepKey = `${orderId}-자재투입`;
setStepCompletionMap((prev) => ({
...prev,
[stepKey]: true,
}));
}, []);
// 완료 결과 팝업에서 확인 → API 완료 처리 후 목록에서 제거
// 완료 확인 → MaterialInputModal 열기
const handleCompletionConfirm = useCallback(() => {
setIsCompletionFlow(true);
setIsMaterialModalOpen(true);
}, []);
// MaterialInputModal 완료 후 → 작업 완료 결과 팝업
const handleWorkCompletion = useCallback(() => {
if (!selectedOrder) return;
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
setCompletionLotNo(lotNo);
setIsCompletionResultOpen(true);
setIsCompletionFlow(false);
}, [selectedOrder]);
// 완료 결과 팝업 확인 → API 완료 처리
const handleCompletionResultConfirm = useCallback(async () => {
if (!selectedOrder) return;
try {
// API로 완료 처리
const materials = inputMaterialsMap.get(selectedOrder.id);
const result = await completeWorkOrder(
selectedOrder.id,
@@ -177,15 +440,11 @@ export default function WorkerScreen() {
if (result.success) {
toast.success('작업이 완료되었습니다.');
// 투입된 자재 맵에서도 제거
setInputMaterialsMap((prev) => {
const next = new Map(prev);
next.delete(selectedOrder.id);
return next;
});
// 목록에서 제거
setWorkOrders((prev) => prev.filter((o) => o.id !== selectedOrder.id));
} else {
toast.error(result.error || '작업 완료 처리에 실패했습니다.');
@@ -200,26 +459,53 @@ export default function WorkerScreen() {
}
}, [selectedOrder, inputMaterialsMap]);
const handleProcessDetail = useCallback((order: WorkOrder) => {
setSelectedOrder(order);
// 공정상세는 카드 내 토글로 처리 (Phase 4에서 구현)
console.log('[공정상세] 토글:', order.orderNo);
}, []);
// 하단 버튼용 합성 WorkOrder (API 데이터 없을 때 목업 폴백)
const getTargetOrder = useCallback((): WorkOrder | null => {
const apiTarget = filteredWorkOrders[0];
if (apiTarget) return apiTarget;
const handleMaterialInput = useCallback((order: WorkOrder) => {
setSelectedOrder(order);
setIsMaterialModalOpen(true);
}, []);
// 목업 아이템으로 폴백
const mockItem = workItems[0];
if (!mockItem) return null;
return {
id: mockItem.id,
orderNo: mockItem.itemCode,
productName: mockItem.itemName,
processCode: mockItem.processType,
processName: PROCESS_TAB_LABELS[mockItem.processType],
client: '-',
projectName: '-',
assignees: [],
quantity: mockItem.quantity,
dueDate: '',
priority: 5,
status: 'waiting',
isUrgent: false,
isDelayed: false,
createdAt: '',
};
}, [filteredWorkOrders, workItems]);
const handleWorkLog = useCallback((order: WorkOrder) => {
setSelectedOrder(order);
setIsWorkLogModalOpen(true);
}, []);
// 하단 버튼 핸들러
const handleWorkLog = useCallback(() => {
const target = getTargetOrder();
if (target) {
setSelectedOrder(target);
setIsWorkLogModalOpen(true);
} else {
toast.error('표시할 작업이 없습니다.');
}
}, [getTargetOrder]);
const handleIssueReport = useCallback((order: WorkOrder) => {
setSelectedOrder(order);
setIsIssueReportModalOpen(true);
}, []);
const handleInspection = useCallback(() => {
const target = getTargetOrder();
if (target) {
setSelectedOrder(target);
setIsInspectionModalOpen(true);
} else {
toast.error('표시할 작업이 없습니다.');
}
}, [getTargetOrder]);
return (
<PageLayout>
@@ -234,75 +520,158 @@ export default function WorkerScreen() {
</div>
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-sm text-muted-foreground"> .</p>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="할일"
value={stats.assigned}
icon={<ClipboardList className="h-4 w-4" />}
variant="default"
/>
<StatCard
title="작업중"
value={stats.inProgress}
icon={<PlayCircle className="h-4 w-4" />}
variant="blue"
/>
<StatCard
title="완료"
value={stats.completed}
icon={<CheckCircle2 className="h-4 w-4" />}
variant="green"
/>
<StatCard
title="긴급"
value={stats.urgent}
icon={<AlertTriangle className="h-4 w-4" />}
variant="red"
/>
</div>
{/* 공정별 탭 */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as ProcessTab)}
>
<TabsList className="w-full">
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsTrigger key={tab} value={tab} className="flex-1">
{PROCESS_TAB_LABELS[tab]}
</TabsTrigger>
))}
</TabsList>
{/* 작업 목록 */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> </h2>
<Select value={sortBy} onValueChange={(value: 'dueDate' | 'latest') => setSortBy(value)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dueDate"></SelectItem>
<SelectItem value="latest"></SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<ContentSkeleton type="cards" rows={4} />
) : sortedWorkOrders.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
.
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{sortedWorkOrders.map((order) => (
<WorkCard
key={order.id}
order={order}
onComplete={handleComplete}
onProcessDetail={handleProcessDetail}
onMaterialInput={handleMaterialInput}
onWorkLog={handleWorkLog}
onIssueReport={handleIssueReport}
/>
))}
</div>
)}
{/* 탭 내용은 공통 (탭별 필터링만 다름) */}
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsContent key={tab} value={tab}>
<div className="space-y-6 mt-4">
{/* 상태 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="할일"
value={stats.assigned}
icon={<ClipboardList className="h-4 w-4" />}
variant="default"
/>
<StatCard
title="작업중"
value={stats.inProgress}
icon={<PlayCircle className="h-4 w-4" />}
variant="blue"
/>
<StatCard
title="완료"
value={stats.completed}
icon={<CheckCircle2 className="h-4 w-4" />}
variant="green"
/>
<StatCard
title="긴급"
value={stats.urgent}
icon={<AlertTriangle className="h-4 w-4" />}
variant="red"
/>
</div>
{/* 수주 정보 섹션 */}
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3"> </h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3">
<InfoField label="수주일" value={orderInfo?.orderDate} />
<InfoField label="로트번호" value={orderInfo?.lotNo} />
<InfoField label="현장명" value={orderInfo?.siteName} />
<InfoField label="수주처" value={orderInfo?.client} />
<InfoField label="수주 담당자" value={orderInfo?.salesManager} />
<InfoField label="담당자 연락처" value={orderInfo?.managerPhone} />
<InfoField label="출고예정일" value={orderInfo?.shippingDate} />
</div>
</CardContent>
</Card>
{/* 작업 정보 섹션 */}
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-gray-600"> </Label>
<Select
value={productionManagerId}
onValueChange={setProductionManagerId}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{/* 담당자 목록 - 현재 작업 담당자들 */}
{Array.from(
new Set(
filteredWorkOrders.flatMap((o) => o.assignees || []).filter(Boolean)
)
).map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600"></Label>
<Input
type="date"
value={productionDate}
onChange={(e) => setProductionDate(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
{/* 작업 목록 */}
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3"> </h3>
{isLoading ? (
<ContentSkeleton type="cards" rows={4} />
) : workItems.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{workItems.map((item) => (
<WorkItemCard
key={item.id}
item={item}
onStepClick={handleStepClick}
onEditMaterial={handleEditMaterial}
onDeleteMaterial={handleDeleteMaterial}
/>
))}
</div>
)}
</div>
</div>
</TabsContent>
))}
</Tabs>
</div>
{/* 하단 고정 버튼 */}
<div className="sticky bottom-0 border-t border-gray-200 pt-4 pb-2 z-10">
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleWorkLog}
className="flex-1 py-6 text-base font-medium"
>
</Button>
<Button
onClick={handleInspection}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
</Button>
</div>
</div>
@@ -328,6 +697,14 @@ export default function WorkerScreen() {
open={isWorkLogModalOpen}
onOpenChange={setIsWorkLogModalOpen}
workOrderId={selectedOrder?.id || null}
processType={activeTab}
/>
<InspectionReportModal
open={isInspectionModalOpen}
onOpenChange={setIsInspectionModalOpen}
workOrderId={selectedOrder?.id || null}
processType={activeTab}
/>
<IssueReportModal
@@ -375,3 +752,17 @@ function StatCard({ title, value, icon, variant }: StatCardProps) {
</Card>
);
}
interface InfoFieldProps {
label: string;
value?: string;
}
function InfoField({ label, value }: InfoFieldProps) {
return (
<div>
<p className="text-xs text-gray-500">{label}</p>
<p className="text-sm font-medium text-gray-900 mt-0.5">{value || '-'}</p>
</div>
);
}

View File

@@ -1,8 +1,116 @@
// 작업자 화면 타입 정의
import type { WorkOrder, ProcessType } from '../ProductionDashboard/types';
import type { WorkOrder } from '../ProductionDashboard/types';
// 작업자 작업 아이템 (WorkOrder 확장)
// ===== 공정 탭 =====
export type ProcessTab = 'screen' | 'slat' | 'bending';
export const PROCESS_TAB_LABELS: Record<ProcessTab, string> = {
screen: '스크린 공정',
slat: '슬랫 공정',
bending: '절곡 공정',
};
// ===== 수주 정보 =====
export interface OrderInfo {
orderDate: string; // 수주일
lotNo: string; // 로트번호
siteName: string; // 현장명
client: string; // 수주처
salesManager: string; // 수주담당자
managerPhone: string; // 담당자연락처
shippingDate: string; // 출고예정일
}
// ===== 작업 정보 =====
export interface WorkInfo {
productionManagerId: string; // 생산 담당자 ID
productionDate: string; // 생산일자
}
// ===== 작업 아이템 (카드 1개 단위) =====
export interface WorkItemData {
id: string;
itemNo: number; // 번호 (1, 2, 3...)
itemCode: string; // 품목코드 (KWWS03)
itemName: string; // 품목명 (와이어)
floor: string; // 층 (1층)
code: string; // 부호 (FSS-01)
width: number; // 폭 (mm)
height: number; // 높이 (mm)
quantity: number; // 수량
processType: ProcessTab; // 공정 타입
steps: WorkStepData[]; // 공정 단계들
// 스크린 전용
cuttingInfo?: CuttingInfo;
// 슬랫 전용
slatInfo?: SlatInfo;
// 절곡 전용
bendingInfo?: BendingInfo;
// 자재 투입 목록
materialInputs?: MaterialListItem[];
}
// ===== 절단 정보 (스크린 전용) =====
export interface CuttingInfo {
width: number; // 절단 폭 (mm)
sheets: number; // 장 수
}
// ===== 슬랫 전용 정보 =====
export interface SlatInfo {
length: number; // 길이 (mm)
slatCount: number; // 슬랫 매수
jointBar: number; // 조인트바 개수
}
// ===== 절곡 전용 정보 =====
export interface BendingInfo {
drawingUrl?: string; // 도면 이미지 URL
common: BendingCommonInfo; // 공통사항
detailParts: BendingDetailPart[]; // 세부부품
}
export interface BendingCommonInfo {
kind: string; // 종류 (벽면형 120X70)
type: string; // 유형 (벽면형)
lengthQuantities: { length: number; quantity: number }[]; // 길이별 수량
}
export interface BendingDetailPart {
partName: string; // 부품명 (엘바, 하장바)
material: string; // 재질 (EGI 1.6T)
barcyInfo: string; // 바아시 정보
}
// ===== 공정 단계 (pill) =====
export interface WorkStepData {
id: string;
name: string; // 단계명 (자재투입, 절단, 미싱, 포장완료)
isMaterialInput: boolean; // 자재투입 단계 여부
isCompleted: boolean; // 완료 여부
}
// ===== 자재 투입 목록 항목 =====
export interface MaterialListItem {
id: string;
lotNo: string; // 로트번호
itemName: string; // 품목명
quantity: number; // 수량
unit: string; // 단위
}
// ===== 자재 투입 모달 항목 =====
export interface MaterialInput {
id: string;
lotNo: string; // 로트번호
materialName: string; // 품목명
quantity: number; // 수량
unit: string; // 단위
inputQuantity: number; // 투입 수량 (사용자 입력)
}
// ===== 작업자 작업 아이템 (WorkOrder 확장) =====
export interface WorkerWorkItem extends WorkOrder {
processDetail?: ProcessDetail;
}
@@ -29,22 +137,12 @@ export interface ProcessStep {
// 공정 단계 상세 항목
export interface ProcessStepItem {
id: string;
itemNo: string; // #1, #2
location: string; // 1층 1호-A
isPriority: boolean; // 선행 생산
spec: string; // W2500 × H3000
material: string; // 자재: 절곡판
lot: string; // LOT-절곡-2025-001
}
// 자재 투입 정보
export interface MaterialInput {
id: string;
materialCode: string;
materialName: string;
unit: string;
currentStock: number;
fifoRank: number; // FIFO 순위 (1: 최우선, 2: 차선, 3+: 대기)
itemNo: string;
location: string;
isPriority: boolean;
spec: string;
material: string;
lot: string;
}
// 이슈 유형
@@ -71,4 +169,4 @@ export interface CompletionToastInfo {
orderNo: string;
quantity: number;
lotNo: string;
}
}

View File

@@ -54,7 +54,7 @@ export function FieldInput({
field.readonly ||
(typeof field.disabled === 'function'
? field.disabled(mode)
: field.disabled);
: field.disabled) || false;
// 옵션 (동적 로드된 옵션 우선)
const options = dynamicOptions || field.options || [];

View File

@@ -48,7 +48,7 @@ export function FieldRenderer({
field.readonly ||
(typeof field.disabled === 'function'
? field.disabled(mode)
: field.disabled);
: field.disabled) || false;
// 옵션 (동적 로드된 옵션 우선)
const options = dynamicOptions || field.options || [];

View File

@@ -320,7 +320,10 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
// ===== 액션 설정 =====
const actions = config.actions || {};
const deleteConfirm = actions.deleteConfirmMessage || {};
const deleteConfirm = {
title: actions.deleteConfirmMessage?.title || '삭제 확인',
description: actions.deleteConfirmMessage?.description || '이 항목을 삭제하시겠습니까?',
};
// ===== 버튼 위치 =====
const isTopButtons = buttonPosition === 'top';

View File

@@ -147,6 +147,7 @@ export interface PermissionConfig {
}
// ===== 상세 페이지 설정 =====
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface DetailConfig<T = Record<string, unknown>> {
/** 페이지 제목 */
title: string;
@@ -167,9 +168,9 @@ export interface DetailConfig<T = Record<string, unknown>> {
/** 권한 설정 */
permissions?: PermissionConfig;
/** 초기값 변환 (API 응답 → formData) */
transformInitialData?: (data: T) => Record<string, unknown>;
transformInitialData?: (data: any) => Record<string, unknown>;
/** 제출 데이터 변환 (formData → API 요청) */
transformSubmitData?: (formData: Record<string, unknown>) => Partial<T>;
transformSubmitData?: (formData: Record<string, unknown>) => any;
}
// ===== 컴포넌트 Props =====

View File

@@ -152,6 +152,8 @@ export interface IntegratedListTemplateV2Props<T = any> {
tabs?: TabOption[];
activeTab?: string;
onTabChange?: (value: string) => void;
/** 탭 렌더링 위치: 'card' (기본, 테이블 카드 내부) | 'above-stats' (통계 카드 위) */
tabsPosition?: 'card' | 'above-stats';
// 테이블 헤더 액션 (탭 옆에 표시될 셀렉트박스 등)
tableHeaderActions?: ReactNode;
@@ -246,6 +248,7 @@ export function IntegratedListTemplateV2<T = any>({
tabs,
activeTab,
onTabChange,
tabsPosition = 'card',
tableHeaderActions,
mobileFilterSlot,
filterConfig,
@@ -549,8 +552,8 @@ export function IntegratedListTemplateV2<T = any>({
<DateRangeSelector
startDate={dateRangeSelector.startDate || ''}
endDate={dateRangeSelector.endDate || ''}
onStartDateChange={dateRangeSelector.onStartDateChange}
onEndDateChange={dateRangeSelector.onEndDateChange}
onStartDateChange={dateRangeSelector.onStartDateChange || (() => {})}
onEndDateChange={dateRangeSelector.onEndDateChange || (() => {})}
hidePresets={dateRangeSelector.showPresets === false}
hideDateInputs={dateRangeSelector.hideDateInputs}
extraActions={
@@ -616,6 +619,24 @@ export function IntegratedListTemplateV2<T = any>({
</div>
)}
{/* 탭 - 카드 밖 (tabsPosition === 'above-stats') */}
{tabsPosition === 'above-stats' && tabs && tabs.length > 0 && (
<div className="overflow-x-auto">
<div className="flex gap-2 min-w-max">
{tabs.map((tab) => (
<TabChip
key={tab.value}
label={tab.label}
count={tab.count}
active={activeTab === tab.value}
onClick={() => onTabChange?.(tab.value)}
color={tab.color as any}
/>
))}
</div>
</div>
)}
{/* 통계 카드 - 태블릿/데스크톱 */}
{stats && stats.length > 0 ? (
<div className="hidden md:block">
@@ -664,7 +685,7 @@ export function IntegratedListTemplateV2<T = any>({
<div className="hidden xl:block mb-4">
<div className="flex flex-wrap gap-2 justify-between items-center">
<div className="flex flex-wrap gap-2">
{tabs && tabs.map((tab) => (
{tabsPosition !== 'above-stats' && tabs && tabs.map((tab) => (
<TabChip
key={tab.value}
label={tab.label}
@@ -702,7 +723,7 @@ export function IntegratedListTemplateV2<T = any>({
</div>
{/* 모바일/태블릿 (~1279px) - TabChip 탭 */}
{tabs && tabs.length > 0 && (
{tabsPosition !== 'above-stats' && tabs && tabs.length > 0 && (
<div className="xl:hidden mb-4 overflow-x-auto">
<div className="flex gap-2 min-w-max">
{tabs.map((tab) => (

View File

@@ -21,7 +21,7 @@ import {
IntegratedListTemplateV2,
type PaginationConfig,
} from '@/components/templates/IntegratedListTemplateV2';
import { downloadExcel, downloadSelectedExcel } from '@/lib/utils/excel-download';
import { downloadExcel, downloadSelectedExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import type {
UniversalListPageProps,
TabOption,
@@ -634,7 +634,7 @@ export function UniversalListPage<T>({
downloadExcel({
data: dataToDownload as Record<string, unknown>[],
columns,
columns: columns as ExcelColumn<Record<string, unknown>>[],
filename,
sheetName,
});
@@ -665,7 +665,7 @@ export function UniversalListPage<T>({
downloadSelectedExcel({
data: selectedData as Record<string, unknown>[],
columns,
columns: columns as ExcelColumn<Record<string, unknown>>[],
selectedIds,
idField: 'id',
filename: `${filename}_선택`,
@@ -893,6 +893,7 @@ export function UniversalListPage<T>({
tabs={computedTabs.length > 0 ? computedTabs : undefined}
activeTab={activeTab}
onTabChange={handleTabChange}
tabsPosition={config.tabsPosition}
// 필터 시스템
filterConfig={config.filterConfig}
filterValues={filterValuesObj}

View File

@@ -123,7 +123,7 @@ export interface SelectionHandlers {
// ===== 행 클릭 핸들러 =====
export interface RowClickHandlers<T> {
onRowClick: (item: T) => void;
onRowClick?: (item: T) => void;
onEdit?: (item: T) => void;
onDelete?: (item: T) => void;
}
@@ -205,6 +205,8 @@ export interface UniversalListConfig<T> {
fetchTabs?: () => Promise<TabOption[]>;
/** 기본 활성 탭 */
defaultTab?: string;
/** 탭 렌더링 위치: 'card' (기본, 테이블 카드 내부) | 'above-stats' (통계 카드 위) */
tabsPosition?: 'card' | 'above-stats';
// ===== 통계 카드 =====
/** 고정 통계 카드 */
@@ -386,6 +388,8 @@ export interface UniversalListConfig<T> {
extraFilters?: ReactNode;
/** 선택 항목 변경 콜백 (외부에서 선택 상태 동기화 필요 시) */
onSelectionChange?: (selectedItems: Set<string>) => void;
/** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */
onSearchChange?: (search: string) => void;
// ===== 커스텀 다이얼로그 슬롯 =====
/**
@@ -418,6 +422,7 @@ export interface ExternalSelection<T> {
selectedItems: Set<string>;
onToggleSelection: (id: string) => void;
onToggleSelectAll: () => void;
setSelectedItems?: (items: Set<string>) => void;
getItemId: (item: T) => string;
}

View File

@@ -145,16 +145,19 @@ export interface DeleteConfirmDialogProps
extends Omit<ConfirmDialogProps, 'title' | 'confirmText' | 'variant'> {
/** 삭제 대상 이름 (선택사항) */
itemName?: string;
/** 커스텀 제목 (기본: '삭제 확인') */
title?: string;
}
export function DeleteConfirmDialog({
itemName,
description,
title,
...props
}: DeleteConfirmDialogProps) {
return (
<ConfirmDialog
title="삭제 확인"
title={title || '삭제 확인'}
description={
description ?? (
<>

View File

@@ -70,6 +70,15 @@ export interface Process {
// 설명
note?: string;
// 담당자 (신규 필드 - 백엔드 미준비)
manager?: string;
// 생산일자 사용여부 (신규 필드 - 백엔드 미준비)
useProductionDate?: boolean;
// 단계 목록 (신규 필드 - 백엔드 미준비)
steps?: ProcessStep[];
// 상태
status: ProcessStatus;
@@ -119,4 +128,51 @@ export const PROCESS_TYPE_OPTIONS: { value: ProcessType; label: string }[] = [
{ value: '검사', label: '검사' },
{ value: '포장', label: '포장' },
{ value: '조립', label: '조립' },
];
// ============================================================================
// 공정 단계 (Process Step) 타입 정의
// ============================================================================
// 연결 유형
export type StepConnectionType = '팝업' | '없음';
// 완료 유형
export type StepCompletionType = '선택 완료 시 완료' | '클릭 시 완료';
// 공정 단계 엔티티
export interface ProcessStep {
id: string;
stepCode: string; // 단계코드 (예: 123123)
stepName: string; // 단계명 (예: 자재투입, 미싱 등)
isRequired: boolean; // 필수여부
needsApproval: boolean; // 승인여부
needsInspection: boolean; // 검사여부
isActive: boolean; // 사용여부
order: number; // 순서 (드래그&드롭)
// 연결 정보
connectionType: StepConnectionType;
connectionTarget?: string; // 도달 (입고완료 자재 목록 등)
// 완료 정보
completionType: StepCompletionType;
}
// 연결 유형 옵션
export const STEP_CONNECTION_TYPE_OPTIONS: { value: StepConnectionType; label: string }[] = [
{ value: '팝업', label: '팝업' },
{ value: '없음', label: '없음' },
];
// 완료 유형 옵션
export const STEP_COMPLETION_TYPE_OPTIONS: { value: StepCompletionType; label: string }[] = [
{ value: '선택 완료 시 완료', label: '선택 완료 시 완료' },
{ value: '클릭 시 완료', label: '클릭 시 완료' },
];
// 연결 도달 옵션
export const STEP_CONNECTION_TARGET_OPTIONS: { value: string; label: string }[] = [
{ value: '입고완료 자재 목록', label: '입고완료 자재 목록' },
{ value: '출고 요청 목록', label: '출고 요청 목록' },
{ value: '검사 대기 목록', label: '검사 대기 목록' },
{ value: '작업 지시 목록', label: '작업 지시 목록' },
];