From cb95285a8ff99b19d4dba29c8cc16c9fd9abc5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 19 Mar 2026 15:44:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[material]=20=EB=B6=80=EC=A0=81?= =?UTF-8?q?=ED=95=A9=ED=92=88=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8B=A0=EA=B7=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nonconforming-management/[id]/page.tsx | 18 + .../nonconforming-management/page.tsx | 16 + .../nonconforming/NonconformingDetail.tsx | 357 ++++++++++++++ .../nonconforming/NonconformingForm.tsx | 437 +++++++++++++++++ .../nonconforming/NonconformingItemTable.tsx | 201 ++++++++ .../nonconforming/NonconformingList.tsx | 459 ++++++++++++++++++ .../material/nonconforming/actions.ts | 115 +++++ .../material/nonconforming/types.ts | 190 ++++++++ 8 files changed, 1793 insertions(+) create mode 100644 src/app/[locale]/(protected)/material/nonconforming-management/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/material/nonconforming-management/page.tsx create mode 100644 src/components/material/nonconforming/NonconformingDetail.tsx create mode 100644 src/components/material/nonconforming/NonconformingForm.tsx create mode 100644 src/components/material/nonconforming/NonconformingItemTable.tsx create mode 100644 src/components/material/nonconforming/NonconformingList.tsx create mode 100644 src/components/material/nonconforming/actions.ts create mode 100644 src/components/material/nonconforming/types.ts diff --git a/src/app/[locale]/(protected)/material/nonconforming-management/[id]/page.tsx b/src/app/[locale]/(protected)/material/nonconforming-management/[id]/page.tsx new file mode 100644 index 00000000..5bc853e6 --- /dev/null +++ b/src/app/[locale]/(protected)/material/nonconforming-management/[id]/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { NonconformingDetailView } from '@/components/material/nonconforming/NonconformingDetail'; +import { NonconformingForm } from '@/components/material/nonconforming/NonconformingForm'; + +export default function NonconformingDetailPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const id = params.id as string; + const mode = searchParams.get('mode'); + + if (mode === 'edit') { + return ; + } + + return ; +} diff --git a/src/app/[locale]/(protected)/material/nonconforming-management/page.tsx b/src/app/[locale]/(protected)/material/nonconforming-management/page.tsx new file mode 100644 index 00000000..c12adc97 --- /dev/null +++ b/src/app/[locale]/(protected)/material/nonconforming-management/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { NonconformingList } from '@/components/material/nonconforming/NonconformingList'; +import { NonconformingForm } from '@/components/material/nonconforming/NonconformingForm'; + +export default function NonconformingManagementPage() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') { + return ; + } + + return ; +} diff --git a/src/components/material/nonconforming/NonconformingDetail.tsx b/src/components/material/nonconforming/NonconformingDetail.tsx new file mode 100644 index 00000000..da1c20b6 --- /dev/null +++ b/src/components/material/nonconforming/NonconformingDetail.tsx @@ -0,0 +1,357 @@ +'use client'; + +/** + * 부적합관리 - 상세 뷰 (읽기전용) + * + * 상태별 액션 버튼: + * - RECEIVED: "분석 시작" + * - ANALYZING: "조치 완료" (원인분석+처리방안 필수) + * - RESOLVED: "결재상신" + * - CLOSED: 없음 (읽기전용) + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { + FileWarning, + Info, + AlertCircle, + Wrench, + Package, + MessageSquare, + Pencil, + ArrowRight, + CheckCircle, + Send, + Trash2, + Loader2, +} from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import { FormSection } from '@/components/organisms/FormSection'; +import { BadgeSm } from '@/components/atoms/BadgeSm'; +import { formatAmount } from '@/lib/utils/amount'; +import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; +import { + getNonconformingReport, + changeNonconformingStatus, + deleteNonconformingReport, +} from './actions'; +import { NonconformingItemTable } from './NonconformingItemTable'; +import { + NC_STATUS_CONFIG, + NC_TYPE_LABELS, + type NonconformingDetail as DetailType, + type NcStatus, +} from './types'; +import type { DetailConfig, ActionItem } from '@/components/templates/IntegratedDetailTemplate/types'; + +const BASE_PATH = '/material/nonconforming-management'; + +const detailConfig: DetailConfig = { + title: '부적합 상세', + description: '부적합 보고서 상세 정보', + icon: FileWarning, + basePath: BASE_PATH, + fields: [], + actions: { + showBack: true, + backLabel: '목록으로', + }, +}; + +interface Props { + id: string; + mode: 'view' | 'edit'; +} + +function ReadonlyField({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ +
{value || '-'}
+
+ ); +} + +export function NonconformingDetailView({ id, mode }: Props) { + const router = useRouter(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isStatusChanging, setIsStatusChanging] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const loadData = useCallback(async () => { + setIsLoading(true); + const result = await getNonconformingReport(id); + if (result.success && result.data) { + setData(result.data as DetailType); + } else { + toast.error(result.error || '조회 실패'); + router.push(BASE_PATH); + } + setIsLoading(false); + }, [id, router]); + + useEffect(() => { loadData(); }, [loadData]); + + // 상태 변경 + const handleStatusChange = useCallback(async (newStatus: string) => { + if (!data) return; + // ANALYZING → RESOLVED 전이 시 원인분석+처리방안 필수 + if (newStatus === 'RESOLVED') { + if (!data.cause_analysis || !data.corrective_action) { + toast.error('원인 분석과 처리 방안을 먼저 입력하세요. 수정 모드에서 입력 후 다시 시도해주세요.'); + return; + } + } + setIsStatusChanging(true); + try { + const result = await changeNonconformingStatus(id, newStatus); + if (result.success) { + toast.success('상태가 변경되었습니다.'); + loadData(); + } else { + toast.error(result.error || '상태 변경 실패'); + } + } catch { + toast.error('상태 변경 중 오류 발생'); + } finally { + setIsStatusChanging(false); + } + }, [data, id, loadData]); + + // 삭제 + const handleDelete = useCallback(async () => { + setIsDeleting(true); + try { + const result = await deleteNonconformingReport(id); + if (result.success) { + toast.success('삭제되었습니다.'); + router.push(BASE_PATH); + } else { + toast.error(result.error || '삭제 실패'); + } + } catch { + toast.error('삭제 중 오류 발생'); + } finally { + setIsDeleting(false); + setIsDeleteDialogOpen(false); + } + }, [id, router]); + + // 상태별 액션 버튼 + const headerActionItems: ActionItem[] = []; + if (data) { + const isClosed = data.status === 'CLOSED'; + const hasApproval = data.approval && data.approval.status === 'pending'; + + if (!isClosed && !hasApproval) { + // 수정 버튼 + headerActionItems.push({ + icon: Pencil, + label: '수정', + variant: 'outline', + onClick: () => router.push(`${BASE_PATH}/${id}?mode=edit`), + }); + } + + // 상태 전이 버튼 + if (data.status === 'RECEIVED') { + headerActionItems.push({ + icon: ArrowRight, + label: '분석 시작', + onClick: () => handleStatusChange('ANALYZING'), + disabled: isStatusChanging, + loading: isStatusChanging, + }); + } else if (data.status === 'ANALYZING') { + headerActionItems.push({ + icon: CheckCircle, + label: '조치 완료', + className: 'bg-blue-600 hover:bg-blue-700 text-white', + onClick: () => handleStatusChange('RESOLVED'), + disabled: isStatusChanging, + loading: isStatusChanging, + }); + } else if (data.status === 'RESOLVED' && !hasApproval) { + headerActionItems.push({ + icon: Send, + label: '결재상신', + className: 'bg-green-600 hover:bg-green-700 text-white', + onClick: () => { + toast.info('결재상신 기능은 결재 시스템 연동 후 활성화됩니다.'); + }, + }); + } + + // 삭제 (CLOSED가 아닌 경우) + if (!isClosed) { + headerActionItems.push({ + icon: Trash2, + label: '삭제', + variant: 'destructive', + onClick: () => setIsDeleteDialogOpen(true), + }); + } + } + + // 비용 계산 + const materialCost = data?.items?.reduce((sum, item) => sum + (item.amount || 0), 0) ?? 0; + + return ( + <> + } + onBack={() => router.push(BASE_PATH)} + headerActionItems={headerActionItems} + headerActions={ + data ? ( + + {NC_STATUS_CONFIG[data.status].label} + + ) : null + } + renderView={() => + data ? ( +
+ {/* 섹션 1: 기본 정보 */} + +
+ + {data.nc_number} + + } /> + + + + + + + + + +
+
+ + {/* 섹션 2: 불량 내역 */} + +
+ + +
+
+ {data.defect_description}

+ ) : null + } /> +
+
+ + {/* 섹션 3: 원인 분석 및 처리 방안 */} + +
+ {data.cause_analysis}

+ ) : null + } /> + {data.corrective_action}

+ ) : null + } /> +
+ + +
+
+
+ + {/* 섹션 4: 자재 내역 및 비용 */} + + {}} disabled /> + + {/* 비용 요약 */} +
+

비용 요약

+
+ + + + +
+ +
+ {formatAmount(data.total_cost)} +
+
+
+
+
+ + {/* 섹션 5: 비고 */} + +
+ {data.remarks}

: null + } /> + +
+
+ + {/* 결재 정보 */} + {data.approval && ( + +
+ + + +
+ {data.approval.steps?.length > 0 && ( +
+ +
+ {data.approval.steps.map((step) => ( + + {step.approver?.name} ({step.step_type}) + + ))} +
+
+ )} +
+ )} +
+ ) : null + } + /> + + + + ); +} diff --git a/src/components/material/nonconforming/NonconformingForm.tsx b/src/components/material/nonconforming/NonconformingForm.tsx new file mode 100644 index 00000000..0bc7e9c1 --- /dev/null +++ b/src/components/material/nonconforming/NonconformingForm.tsx @@ -0,0 +1,437 @@ +'use client'; + +/** + * 부적합관리 - 등록/수정 폼 + * + * IntegratedDetailTemplate + 5개 FormSection + * 1. 기본 정보 + * 2. 불량 내역 + * 3. 원인 분석 및 처리 방안 + * 4. 자재 내역 및 비용 + * 5. 비고 + */ + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { + FileWarning, + Info, + AlertCircle, + Wrench, + Package, + MessageSquare, +} from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { NumberInput } from '@/components/ui/number-input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { toast } from 'sonner'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import { FormSection } from '@/components/organisms/FormSection'; +import { formatAmount } from '@/lib/utils/amount'; +import { + createNonconformingReport, + updateNonconformingReport, + getNonconformingReport, +} from './actions'; +import { NonconformingItemTable } from './NonconformingItemTable'; +import { + NC_TYPE_OPTIONS, + NC_STATUS_CONFIG, + INITIAL_FORM_DATA, + type NonconformingFormData, + type NonconformingDetail, + type NcType, + type NcStatus, +} from './types'; +import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types'; +import { BadgeSm } from '@/components/atoms/BadgeSm'; + +const BASE_PATH = '/material/nonconforming-management'; + +const formCreateConfig: DetailConfig = { + title: '부적합', + description: '부적합 보고서를 등록합니다', + icon: FileWarning, + basePath: BASE_PATH, + fields: [], + actions: { + showBack: true, + showSave: true, + submitLabel: '저장', + backLabel: '취소', + }, +}; + +const formEditConfig: DetailConfig = { + title: '부적합', + description: '부적합 보고서를 수정합니다', + icon: FileWarning, + basePath: BASE_PATH, + fields: [], + actions: { + showBack: true, + showSave: true, + submitLabel: '저장', + backLabel: '취소', + }, +}; + +interface NonconformingFormProps { + id?: string; + mode: 'new' | 'edit'; +} + +export function NonconformingForm({ id, mode }: NonconformingFormProps) { + const router = useRouter(); + const isNew = mode === 'new'; + const [form, setForm] = useState({ ...INITIAL_FORM_DATA }); + const [isLoading, setIsLoading] = useState(!isNew); + const [isSaving, setIsSaving] = useState(false); + const [detailData, setDetailData] = useState(null); + + const config = isNew ? formCreateConfig : formEditConfig; + + // 데이터 로드 (수정 모드) + useEffect(() => { + if (isNew || !id) { setIsLoading(false); return; } + getNonconformingReport(id).then((result) => { + if (result.success && result.data) { + const d = result.data as NonconformingDetail; + setDetailData(d); + setForm({ + nc_type: d.nc_type, + occurred_at: d.occurred_at || '', + confirmed_at: d.confirmed_at || '', + site_name: d.site_name || '', + department_id: d.department?.id ?? null, + order_id: d.order?.id ?? null, + item_id: d.item?.id ?? null, + defect_quantity: d.defect_quantity ?? '', + unit: d.unit || 'EA', + defect_description: d.defect_description || '', + cause_analysis: d.cause_analysis || '', + corrective_action: d.corrective_action || '', + action_manager_id: d.action_manager?.id ?? null, + related_employee_id: d.related_employee?.id ?? null, + shipping_cost: d.shipping_cost || 0, + construction_cost: d.construction_cost || 0, + other_cost: d.other_cost || 0, + drawing_location: d.drawing_location || '', + remarks: d.remarks || '', + items: d.items || [], + }); + } else { + toast.error(result.error || '데이터 조회 실패'); + router.push(BASE_PATH); + } + setIsLoading(false); + }); + }, [id, isNew, router]); + + // 자재비용 합계 계산 + const materialCost = useMemo( + () => form.items.reduce((sum, item) => sum + (item.amount || 0), 0), + [form.items] + ); + + // 총 비용 합계 + const totalCost = useMemo( + () => materialCost + Number(form.shipping_cost || 0) + Number(form.construction_cost || 0) + Number(form.other_cost || 0), + [materialCost, form.shipping_cost, form.construction_cost, form.other_cost] + ); + + // 필드 변경 + const handleChange = useCallback( + (key: K, value: NonconformingFormData[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }, + [] + ); + + // 저장 (IntegratedDetailTemplate의 onSubmit 시그니처에 맞춤) + const handleSubmit = useCallback(async (_data: Record) => { + // 필수 체크 + if (!form.nc_type) { toast.error('부적합 유형을 선택하세요.'); return { success: false, error: '' }; } + if (!form.occurred_at) { toast.error('발생일을 입력하세요.'); return { success: false, error: '' }; } + + const payload: Record = { + nc_type: form.nc_type, + occurred_at: form.occurred_at, + confirmed_at: form.confirmed_at || undefined, + site_name: form.site_name || undefined, + department_id: form.department_id || undefined, + order_id: form.order_id || undefined, + item_id: form.item_id || undefined, + defect_quantity: form.defect_quantity ? Number(form.defect_quantity) : undefined, + unit: form.unit || undefined, + defect_description: form.defect_description || undefined, + cause_analysis: form.cause_analysis || undefined, + corrective_action: form.corrective_action || undefined, + action_manager_id: form.action_manager_id || undefined, + related_employee_id: form.related_employee_id || undefined, + shipping_cost: Number(form.shipping_cost || 0), + construction_cost: Number(form.construction_cost || 0), + other_cost: Number(form.other_cost || 0), + drawing_location: form.drawing_location || undefined, + remarks: form.remarks || undefined, + items: form.items.map((item) => ({ + item_id: item.item_id || undefined, + item_name: item.item_name, + specification: item.specification, + quantity: Number(item.quantity || 0), + unit_price: Number(item.unit_price || 0), + remarks: item.remarks || undefined, + })), + }; + // undefined 값 제거 + Object.keys(payload).forEach(key => { + if (payload[key] === undefined) delete payload[key]; + }); + + const result = isNew + ? await createNonconformingReport(payload) + : await updateNonconformingReport(id!, payload); + + return { success: result.success, error: result.error || '' }; + }, [form, isNew, id]); + + const handleBack = useCallback(() => { + router.push(BASE_PATH); + }, [router]); + + return ( + ( +
+ {/* 섹션 1: 기본 정보 */} + +
+ {/* 부적합번호 (수정 시 읽기전용) */} + {!isNew && detailData && ( +
+ + +
+ )} + + {/* 상태 (수정 시 표시) */} + {!isNew && detailData && ( +
+ +
+ + {NC_STATUS_CONFIG[detailData.status].label} + +
+
+ )} + + {/* 발생일 */} +
+ + handleChange('occurred_at', date)} + /> +
+ + {/* 확인일 */} +
+ + handleChange('confirmed_at', date)} + /> +
+ + {/* 부적합 유형 */} +
+ + +
+ + {/* 현장명 */} +
+ + handleChange('site_name', e.target.value)} + placeholder="현장명" + /> +
+ + {/* 단위 */} +
+ + handleChange('unit', e.target.value)} + placeholder="EA" + /> +
+
+
+ + {/* 섹션 2: 불량 내역 */} + +
+ {/* 불량 수량 */} +
+ + handleChange('defect_quantity', val ?? '')} + allowDecimal + min={0} + placeholder="수량" + /> +
+
+
+ +