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="수량"
+ />
+
+
+
+
+
+
+
+ {/* 섹션 3: 원인 분석 및 처리 방안 */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* 섹션 4: 자재 내역 및 비용 */}
+
+ {/* 자재 상세 테이블 */}
+ handleChange('items', items)}
+ />
+
+ {/* 비용 요약 */}
+
+
비용 요약
+
+
+
+
+ {formatAmount(materialCost)}
+
+
+
+
+ handleChange('shipping_cost', val ?? 0)}
+ min={0}
+ useComma
+ className="h-9"
+ />
+
+
+
+ handleChange('construction_cost', val ?? 0)}
+ min={0}
+ useComma
+ className="h-9"
+ />
+
+
+
+ handleChange('other_cost', val ?? 0)}
+ min={0}
+ useComma
+ className="h-9"
+ />
+
+
+
+
+ {formatAmount(totalCost)}
+
+
+
+
+
+
+ {/* 섹션 5: 비고 */}
+
+
+
+
+
+
+
+ handleChange('drawing_location', e.target.value)}
+ placeholder="예: nas2dual/도면/2026/03"
+ />
+
+
+
+
+ )}
+ />
+ );
+}
diff --git a/src/components/material/nonconforming/NonconformingItemTable.tsx b/src/components/material/nonconforming/NonconformingItemTable.tsx
new file mode 100644
index 00000000..a7647237
--- /dev/null
+++ b/src/components/material/nonconforming/NonconformingItemTable.tsx
@@ -0,0 +1,201 @@
+'use client';
+
+/**
+ * 부적합관리 - 자재 상세 테이블
+ *
+ * 행 추가/삭제, 수량×단가=금액 자동계산
+ * 합계(material_cost) 변경 시 부모에 콜백
+ */
+
+import { useCallback } from 'react';
+import { Plus, Trash2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { NumberInput } from '@/components/ui/number-input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { formatAmount } from '@/lib/utils/amount';
+import { type NonconformingReportItem, EMPTY_ITEM } from './types';
+
+interface NonconformingItemTableProps {
+ items: NonconformingReportItem[];
+ onChange: (items: NonconformingReportItem[]) => void;
+ disabled?: boolean;
+}
+
+export function NonconformingItemTable({
+ items,
+ onChange,
+ disabled = false,
+}: NonconformingItemTableProps) {
+
+ const handleAddRow = useCallback(() => {
+ onChange([...items, { ...EMPTY_ITEM, sort_order: items.length }]);
+ }, [items, onChange]);
+
+ const handleRemoveRow = useCallback(
+ (index: number) => {
+ const next = items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sort_order: i }));
+ onChange(next);
+ },
+ [items, onChange]
+ );
+
+ const handleFieldChange = useCallback(
+ (index: number, field: keyof NonconformingReportItem, value: string | number) => {
+ const next = items.map((item, i) => {
+ if (i !== index) return item;
+ const updated = { ...item, [field]: value };
+ // 수량 또는 단가 변경 시 금액 자동 계산
+ if (field === 'quantity' || field === 'unit_price') {
+ const qty = field === 'quantity' ? Number(value) : Number(updated.quantity);
+ const price = field === 'unit_price' ? Number(value) : Number(updated.unit_price);
+ updated.amount = qty * price;
+ }
+ return updated;
+ });
+ onChange(next);
+ },
+ [items, onChange]
+ );
+
+ const totalAmount = items.reduce((sum, item) => sum + (item.amount || 0), 0);
+
+ return (
+
+
+
+
+
+ No
+ 품목명
+ 규격
+ 수량
+ 단가
+ 금액
+ 비고
+ {!disabled && }
+
+
+
+ {items.length === 0 ? (
+
+
+ 자재 내역이 없습니다. {!disabled && '아래 버튼으로 행을 추가하세요.'}
+
+
+ ) : (
+ items.map((item, index) => (
+
+ {index + 1}
+
+ {disabled ? (
+ {item.item_name || '-'}
+ ) : (
+ handleFieldChange(index, 'item_name', e.target.value)}
+ placeholder="품목명"
+ className="h-8"
+ />
+ )}
+
+
+ {disabled ? (
+ {item.specification || '-'}
+ ) : (
+ handleFieldChange(index, 'specification', e.target.value)}
+ placeholder="규격"
+ className="h-8"
+ />
+ )}
+
+
+ {disabled ? (
+ {item.quantity}
+ ) : (
+ handleFieldChange(index, 'quantity', val ?? 0)}
+ className="h-8 text-right"
+ min={0}
+ allowDecimal
+ />
+ )}
+
+
+ {disabled ? (
+ {formatAmount(item.unit_price)}
+ ) : (
+ handleFieldChange(index, 'unit_price', val ?? 0)}
+ className="h-8 text-right"
+ min={0}
+ useComma
+ />
+ )}
+
+
+ {formatAmount(item.amount)}
+
+
+ {disabled ? (
+ {item.remarks || '-'}
+ ) : (
+ handleFieldChange(index, 'remarks', e.target.value)}
+ placeholder="비고"
+ className="h-8"
+ />
+ )}
+
+ {!disabled && (
+
+
+
+ )}
+
+ ))
+ )}
+ {/* 합계 행 */}
+ {items.length > 0 && (
+
+
+ 자재비용 합계
+
+
+ {formatAmount(totalAmount)}
+
+
+
+ )}
+
+
+
+
+ {!disabled && (
+
+ )}
+
+ );
+}
diff --git a/src/components/material/nonconforming/NonconformingList.tsx b/src/components/material/nonconforming/NonconformingList.tsx
new file mode 100644
index 00000000..50243d75
--- /dev/null
+++ b/src/components/material/nonconforming/NonconformingList.tsx
@@ -0,0 +1,459 @@
+'use client';
+
+/**
+ * 부적합관리 - 목록 페이지
+ *
+ * UniversalListPage 기반
+ * - 상단 통계 카드 (접수/분석중/조치완료/종결)
+ * - 필터: 상태, 유형 (filterConfig), 날짜범위
+ * - 테이블: 체크박스, No, 부적합번호, 유형, 현장명, 품목명, 발생일, 비용합계, 상태, 등록자
+ */
+
+import { useState, useEffect, useCallback, useMemo } from 'react';
+import { useRouter } from 'next/navigation';
+import {
+ AlertTriangle,
+ Search,
+ Clock,
+ CheckCircle2,
+ Lock,
+ Eye,
+ Edit,
+ Trash2,
+ Loader2,
+ Plus,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { BadgeSm } from '@/components/atoms/BadgeSm';
+import {
+ UniversalListPage,
+ type UniversalListConfig,
+ type TableColumn,
+ type FilterFieldConfig,
+} from '@/components/templates/UniversalListPage';
+import { toast } from 'sonner';
+import { TableRow, TableCell } from '@/components/ui/table';
+import { Checkbox } from '@/components/ui/checkbox';
+import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
+import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
+import { formatAmount } from '@/lib/utils/amount';
+import {
+ getNonconformingReports,
+ getNonconformingStats,
+ deleteNonconformingReport,
+} from './actions';
+import {
+ NC_STATUS_CONFIG,
+ NC_TYPE_LABELS,
+ NC_STATUS_OPTIONS,
+ NC_TYPE_OPTIONS,
+ type NonconformingListItem,
+ type NonconformingStats,
+ type NcStatus,
+ type NcType,
+} from './types';
+
+const BASE_PATH = '/material/nonconforming-management';
+
+function getStatusBadge(status: NcStatus) {
+ const config = NC_STATUS_CONFIG[status];
+ return {config.label};
+}
+
+export function NonconformingList() {
+ const router = useRouter();
+ const [searchTerm, setSearchTerm] = useState('');
+ const [selectedItems, setSelectedItems] = useState>(new Set());
+ const [currentPage, setCurrentPage] = useState(1);
+ const itemsPerPage = 20;
+
+ // 날짜 범위
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+
+ // 필터 상태
+ const [filterValues, setFilterValues] = useState>({
+ status: 'all',
+ nc_type: 'all',
+ });
+
+ const filterConfig: FilterFieldConfig[] = [
+ {
+ key: 'status',
+ label: '상태',
+ type: 'single',
+ options: NC_STATUS_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
+ allOptionLabel: '전체 상태',
+ },
+ {
+ key: 'nc_type',
+ label: '유형',
+ type: 'single',
+ options: NC_TYPE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
+ allOptionLabel: '전체 유형',
+ },
+ ];
+
+ // 삭제 다이얼로그
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [deleteTargetIds, setDeleteTargetIds] = useState([]);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // API 상태
+ const [data, setData] = useState([]);
+ const [statsData, setStatsData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [totalCount, setTotalCount] = useState(0);
+
+ // 데이터 로드
+ const loadData = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const statusFilter = filterValues.status as string;
+ const typeFilter = filterValues.nc_type as string;
+
+ const [listResult, statsResult] = await Promise.all([
+ getNonconformingReports({
+ page: currentPage,
+ per_page: itemsPerPage,
+ search: searchTerm || undefined,
+ status: statusFilter !== 'all' ? statusFilter : undefined,
+ nc_type: typeFilter !== 'all' ? typeFilter : undefined,
+ from_date: startDate || undefined,
+ to_date: endDate || undefined,
+ }),
+ getNonconformingStats(),
+ ]);
+
+ if (listResult.success) {
+ setData(listResult.data);
+ setTotalCount(listResult.pagination?.total ?? 0);
+ } else {
+ toast.error(listResult.error || '목록 조회 실패');
+ }
+
+ if (statsResult.success && statsResult.data) {
+ setStatsData(statsResult.data as NonconformingStats);
+ }
+ } catch {
+ toast.error('데이터 로드 중 오류 발생');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [currentPage, searchTerm, filterValues, startDate, endDate]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ // 통계 카드
+ const stats = useMemo(() => {
+ const byStatus = statsData?.by_status ?? { RECEIVED: 0, ANALYZING: 0, RESOLVED: 0, CLOSED: 0 };
+ return [
+ { label: '접수', value: `${byStatus.RECEIVED}건`, icon: Clock, iconColor: 'text-gray-600' },
+ { label: '분석중', value: `${byStatus.ANALYZING}건`, icon: Search, iconColor: 'text-yellow-600' },
+ { label: '조치완료', value: `${byStatus.RESOLVED}건`, icon: CheckCircle2, iconColor: 'text-blue-600' },
+ { label: '종결', value: `${byStatus.CLOSED}건`, icon: Lock, iconColor: 'text-green-600' },
+ ];
+ }, [statsData]);
+
+ // 핸들러
+ const handleView = (item: NonconformingListItem) => {
+ router.push(`${BASE_PATH}/${item.id}`);
+ };
+
+ const handleEdit = (item: NonconformingListItem) => {
+ router.push(`${BASE_PATH}/${item.id}?mode=edit`);
+ };
+
+ const handleDelete = (id: string) => {
+ setDeleteTargetIds([id]);
+ setIsDeleteDialogOpen(true);
+ };
+
+ const handleConfirmDelete = async () => {
+ if (deleteTargetIds.length === 0) return;
+ setIsDeleting(true);
+ try {
+ const result = await deleteNonconformingReport(deleteTargetIds[0]);
+ if (result.success) {
+ toast.success('삭제되었습니다.');
+ setSelectedItems(new Set());
+ loadData();
+ } else {
+ toast.error(result.error || '삭제 실패');
+ }
+ } catch {
+ toast.error('삭제 중 오류 발생');
+ } finally {
+ setIsDeleting(false);
+ setIsDeleteDialogOpen(false);
+ setDeleteTargetIds([]);
+ }
+ };
+
+ // 선택
+ const toggleSelection = (id: string) => {
+ const next = new Set(selectedItems);
+ next.has(id) ? next.delete(id) : next.add(id);
+ setSelectedItems(next);
+ };
+
+ const toggleSelectAll = () => {
+ if (selectedItems.size === data.length && data.length > 0) {
+ setSelectedItems(new Set());
+ } else {
+ setSelectedItems(new Set(data.map((d) => String(d.id))));
+ }
+ };
+
+ // 테이블 컬럼
+ const tableColumns: TableColumn[] = useMemo(
+ () => [
+ { key: 'no', label: '번호', className: 'text-center w-[60px]' },
+ { key: 'nc_number', label: '부적합번호', className: 'px-2', copyable: true },
+ { key: 'nc_type', label: '유형', className: 'px-2 text-center w-[90px]' },
+ { key: 'site_name', label: '현장명', className: 'px-2' },
+ { key: 'item_name', label: '품목명', className: 'px-2' },
+ { key: 'occurred_at', label: '발생일', className: 'px-2 w-[100px]' },
+ { key: 'total_cost', label: '비용합계', className: 'px-2 text-right w-[110px]' },
+ { key: 'status', label: '상태', className: 'px-2 text-center w-[90px]' },
+ { key: 'creator', label: '등록자', className: 'px-2 w-[80px]' },
+ ],
+ []
+ );
+
+ // 테이블 행
+ const renderTableRow = (
+ item: NonconformingListItem,
+ _index: number,
+ globalIndex: number,
+ handlers: { isSelected: boolean; onToggle: () => void }
+ ) => {
+ const { isSelected, onToggle } = handlers;
+ return (
+ handleView(item)}
+ >
+ e.stopPropagation()} className="text-center">
+
+
+ {globalIndex}
+
+
+ {item.nc_number}
+
+
+
+
+ {NC_TYPE_LABELS[item.nc_type] || item.nc_type}
+
+
+ {item.site_name || '-'}
+ {item.item?.name || '-'}
+ {item.occurred_at || '-'}
+ {formatAmount(item.total_cost)}
+ {getStatusBadge(item.status)}
+ {item.creator?.name || '-'}
+
+ );
+ };
+
+ // 모바일 카드
+ const renderMobileCard = (
+ item: NonconformingListItem,
+ _index: number,
+ globalIndex: number,
+ handlers: { isSelected: boolean; onToggle: () => void }
+ ) => {
+ const { isSelected, onToggle } = handlers;
+ return (
+ handleView(item)}
+ headerBadges={
+ <>
+
+ {globalIndex}
+
+
+ {item.nc_number}
+
+ >
+ }
+ title={item.site_name || '(현장명 없음)'}
+ statusBadge={getStatusBadge(item.status)}
+ infoGrid={
+
+
+
+
+
+
+ }
+ actions={
+ isSelected ? (
+
+
+
+ {item.status !== 'CLOSED' && (
+
+ )}
+
+ ) : undefined
+ }
+ />
+ );
+ };
+
+ // Config
+ const config: UniversalListConfig = {
+ title: '부적합관리',
+ description: '부적합 보고서 등록 및 관리',
+ icon: AlertTriangle,
+ basePath: BASE_PATH,
+ idField: 'id',
+
+ actions: {
+ getList: async () => ({
+ success: true,
+ data,
+ totalCount,
+ }),
+ deleteItem: async (id) => {
+ const result = await deleteNonconformingReport(id);
+ if (result.success) loadData();
+ return result;
+ },
+ },
+
+ columns: tableColumns,
+
+ computeStats: () => stats,
+
+ searchPlaceholder: '부적합번호, 현장명, 품목명 검색...',
+
+ dateRangeSelector: {
+ enabled: true,
+ showPresets: true,
+ presetsPosition: 'inline',
+ startDate,
+ endDate,
+ onStartDateChange: (d: string) => { setStartDate(d); setCurrentPage(1); },
+ onEndDateChange: (d: string) => { setEndDate(d); setCurrentPage(1); },
+ dateField: 'occurred_at',
+ },
+
+ itemsPerPage,
+
+ clientSideFiltering: false,
+
+ filterConfig,
+ initialFilters: filterValues,
+ filterTitle: '부적합 필터',
+
+ headerActions: () => (
+
+ ),
+
+ renderTableRow,
+ renderMobileCard,
+
+ renderDialogs: () => (
+
+ 선택한 부적합 보고서를 삭제하시겠습니까?
+
+
+ 삭제된 보고서는 복구할 수 없습니다.
+
+ >
+ }
+ loading={isDeleting}
+ />
+ ),
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ config={config}
+ initialData={data}
+ initialTotalCount={totalCount}
+ externalSelection={{
+ selectedItems,
+ onToggleSelection: toggleSelection,
+ onToggleSelectAll: toggleSelectAll,
+ setSelectedItems,
+ getItemId: (item) => String(item.id),
+ }}
+ externalPagination={{
+ currentPage,
+ totalPages: Math.ceil(totalCount / itemsPerPage),
+ totalItems: totalCount,
+ itemsPerPage,
+ onPageChange: (page: number) => setCurrentPage(page),
+ }}
+ onFilterChange={(newFilters) => {
+ setFilterValues(newFilters);
+ setCurrentPage(1);
+ }}
+ onSearchChange={(q: string) => {
+ setSearchTerm(q);
+ setCurrentPage(1);
+ }}
+ />
+ );
+}
diff --git a/src/components/material/nonconforming/actions.ts b/src/components/material/nonconforming/actions.ts
new file mode 100644
index 00000000..fe8e187c
--- /dev/null
+++ b/src/components/material/nonconforming/actions.ts
@@ -0,0 +1,115 @@
+/**
+ * 부적합관리 Server Actions
+ *
+ * API Endpoints:
+ * - GET /api/v1/material/nonconforming-reports 목록 조회
+ * - GET /api/v1/material/nonconforming-reports/stats 통계
+ * - GET /api/v1/material/nonconforming-reports/{id} 단건 조회
+ * - POST /api/v1/material/nonconforming-reports 등록
+ * - PUT /api/v1/material/nonconforming-reports/{id} 수정
+ * - DELETE /api/v1/material/nonconforming-reports/{id} 삭제
+ * - PATCH /api/v1/material/nonconforming-reports/{id}/status 상태 변경
+ * - POST /api/v1/material/nonconforming-reports/{id}/submit-approval 결재상신
+ */
+
+'use server';
+
+import { buildApiUrl } from '@/lib/api/query-params';
+import { executeServerAction } from '@/lib/api/execute-server-action';
+import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
+import type { NonconformingListItem } from './types';
+
+const BASE_PATH = '/api/v1/material/nonconforming-reports';
+
+// 목록 조회
+export async function getNonconformingReports(params?: {
+ page?: number;
+ per_page?: number;
+ search?: string;
+ status?: string;
+ nc_type?: string;
+ from_date?: string;
+ to_date?: string;
+}) {
+ return executePaginatedAction({
+ url: buildApiUrl(BASE_PATH, {
+ page: params?.page,
+ per_page: params?.per_page,
+ search: params?.search,
+ status: params?.status !== 'all' ? params?.status : undefined,
+ nc_type: params?.nc_type !== 'all' ? params?.nc_type : undefined,
+ from_date: params?.from_date,
+ to_date: params?.to_date,
+ }),
+ transform: (item) => item,
+ errorMessage: '부적합 목록 조회에 실패했습니다.',
+ });
+}
+
+// 통계
+export async function getNonconformingStats() {
+ return executeServerAction({
+ url: buildApiUrl(`${BASE_PATH}/stats`),
+ errorMessage: '부적합 통계 조회에 실패했습니다.',
+ });
+}
+
+// 단건 조회
+export async function getNonconformingReport(id: number | string) {
+ return executeServerAction({
+ url: buildApiUrl(`${BASE_PATH}/${id}`),
+ errorMessage: '부적합 상세 조회에 실패했습니다.',
+ });
+}
+
+// 등록
+export async function createNonconformingReport(data: Record) {
+ return executeServerAction({
+ url: buildApiUrl(BASE_PATH),
+ method: 'POST',
+ body: data,
+ errorMessage: '부적합 등록에 실패했습니다.',
+ });
+}
+
+// 수정
+export async function updateNonconformingReport(id: number | string, data: Record) {
+ return executeServerAction({
+ url: buildApiUrl(`${BASE_PATH}/${id}`),
+ method: 'PUT',
+ body: data,
+ errorMessage: '부적합 수정에 실패했습니다.',
+ });
+}
+
+// 삭제
+export async function deleteNonconformingReport(id: number | string) {
+ return executeServerAction({
+ url: buildApiUrl(`${BASE_PATH}/${id}`),
+ method: 'DELETE',
+ errorMessage: '부적합 삭제에 실패했습니다.',
+ });
+}
+
+// 상태 변경
+export async function changeNonconformingStatus(id: number | string, status: string) {
+ return executeServerAction({
+ url: buildApiUrl(`${BASE_PATH}/${id}/status`),
+ method: 'PATCH',
+ body: { status },
+ errorMessage: '상태 변경에 실패했습니다.',
+ });
+}
+
+// 결재상신
+export async function submitNonconformingApproval(
+ id: number | string,
+ data: { title?: string; form_id?: number; steps: Array<{ approver_id: number; step_type?: string }> }
+) {
+ return executeServerAction({
+ url: buildApiUrl(`${BASE_PATH}/${id}/submit-approval`),
+ method: 'POST',
+ body: data,
+ errorMessage: '결재상신에 실패했습니다.',
+ });
+}
diff --git a/src/components/material/nonconforming/types.ts b/src/components/material/nonconforming/types.ts
new file mode 100644
index 00000000..943cf1b3
--- /dev/null
+++ b/src/components/material/nonconforming/types.ts
@@ -0,0 +1,190 @@
+/**
+ * 부적합관리 타입 정의
+ */
+
+// ===== 코드 상수 =====
+
+export type NcType = 'material' | 'process' | 'construction' | 'other';
+export type NcStatus = 'RECEIVED' | 'ANALYZING' | 'RESOLVED' | 'CLOSED';
+
+export const NC_TYPE_OPTIONS: { value: NcType; label: string }[] = [
+ { value: 'material', label: '자재불량' },
+ { value: 'process', label: '공정불량' },
+ { value: 'construction', label: '시공불량' },
+ { value: 'other', label: '기타' },
+];
+
+export const NC_STATUS_OPTIONS: { value: NcStatus; label: string }[] = [
+ { value: 'RECEIVED', label: '접수' },
+ { value: 'ANALYZING', label: '분석중' },
+ { value: 'RESOLVED', label: '조치완료' },
+ { value: 'CLOSED', label: '종결' },
+];
+
+export const NC_STATUS_CONFIG: Record = {
+ RECEIVED: { label: '접수', className: 'bg-gray-100 text-gray-700 border-gray-200' },
+ ANALYZING: { label: '분석중', className: 'bg-yellow-100 text-yellow-700 border-yellow-200' },
+ RESOLVED: { label: '조치완료', className: 'bg-blue-100 text-blue-700 border-blue-200' },
+ CLOSED: { label: '종결', className: 'bg-green-100 text-green-700 border-green-200' },
+};
+
+export const NC_TYPE_LABELS: Record = {
+ material: '자재불량',
+ process: '공정불량',
+ construction: '시공불량',
+ other: '기타',
+};
+
+// ===== 목록 아이템 =====
+
+export interface NonconformingListItem {
+ id: number;
+ nc_number: string;
+ status: NcStatus;
+ nc_type: NcType;
+ occurred_at: string;
+ confirmed_at: string | null;
+ site_name: string | null;
+ total_cost: number;
+ creator: { id: number; name: string };
+ item: { id: number; name: string } | null;
+ order: { id: number; order_number: string } | null;
+}
+
+// ===== 통계 =====
+
+export interface NonconformingStats {
+ by_status: Record;
+ total_count: number;
+ total_cost: number;
+}
+
+// ===== 자재 상세 아이템 =====
+
+export interface NonconformingReportItem {
+ id?: number;
+ item_id?: number | null;
+ item_name: string;
+ specification: string;
+ quantity: number;
+ unit_price: number;
+ amount: number;
+ sort_order: number;
+ remarks: string;
+}
+
+// ===== 결재 정보 =====
+
+export interface ApprovalStep {
+ id: number;
+ step_order: number;
+ step_type: string;
+ approver_id: number;
+ status: string;
+ comment: string | null;
+ acted_at: string | null;
+ approver: { id: number; name: string };
+}
+
+export interface ApprovalInfo {
+ id: number;
+ document_number: string;
+ status: string;
+ completed_at: string | null;
+ steps: ApprovalStep[];
+}
+
+// ===== 단건 상세 =====
+
+export interface NonconformingDetail {
+ id: number;
+ nc_number: string;
+ status: NcStatus;
+ nc_type: NcType;
+ occurred_at: string;
+ confirmed_at: string | null;
+ site_name: string | null;
+ department: { id: number; name: string } | null;
+ order: { id: number; order_number: string; site_name: string } | null;
+ item: { id: number; name: string } | null;
+ defect_quantity: number | null;
+ unit: string | null;
+ defect_description: string | null;
+ cause_analysis: string | null;
+ corrective_action: string | null;
+ action_completed_at: string | null;
+ action_manager: { id: number; name: string } | null;
+ related_employee: { id: number; name: string } | null;
+ material_cost: number;
+ shipping_cost: number;
+ construction_cost: number;
+ other_cost: number;
+ total_cost: number;
+ remarks: string | null;
+ drawing_location: string | null;
+ items: NonconformingReportItem[];
+ files: unknown[];
+ approval: ApprovalInfo | null;
+ creator: { id: number; name: string };
+ created_at: string;
+ updated_at: string;
+}
+
+// ===== 폼 데이터 =====
+
+export interface NonconformingFormData {
+ nc_type: NcType;
+ occurred_at: string;
+ confirmed_at: string;
+ site_name: string;
+ department_id: number | null;
+ order_id: number | null;
+ item_id: number | null;
+ defect_quantity: number | string;
+ unit: string;
+ defect_description: string;
+ cause_analysis: string;
+ corrective_action: string;
+ action_manager_id: number | null;
+ related_employee_id: number | null;
+ shipping_cost: number | string;
+ construction_cost: number | string;
+ other_cost: number | string;
+ drawing_location: string;
+ remarks: string;
+ items: NonconformingReportItem[];
+}
+
+export const INITIAL_FORM_DATA: NonconformingFormData = {
+ nc_type: 'material',
+ occurred_at: '',
+ confirmed_at: '',
+ site_name: '',
+ department_id: null,
+ order_id: null,
+ item_id: null,
+ defect_quantity: '',
+ unit: 'EA',
+ defect_description: '',
+ cause_analysis: '',
+ corrective_action: '',
+ action_manager_id: null,
+ related_employee_id: null,
+ shipping_cost: 0,
+ construction_cost: 0,
+ other_cost: 0,
+ drawing_location: '',
+ remarks: '',
+ items: [],
+};
+
+export const EMPTY_ITEM: NonconformingReportItem = {
+ item_id: null,
+ item_name: '',
+ specification: '',
+ quantity: 0,
+ unit_price: 0,
+ amount: 0,
+ sort_order: 0,
+ remarks: '',
+};