feat: [material] 부적합품관리 페이지 신규 추가

This commit is contained in:
유병철
2026-03-19 15:44:13 +09:00
parent 42e50c78a6
commit cb95285a8f
8 changed files with 1793 additions and 0 deletions

View File

@@ -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 <NonconformingForm id={id} mode="edit" />;
}
return <NonconformingDetailView id={id} mode="view" />;
}

View File

@@ -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 <NonconformingForm mode="new" />;
}
return <NonconformingList />;
}

View File

@@ -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 (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
<div className="text-sm min-h-[1.5rem]">{value || '-'}</div>
</div>
);
}
export function NonconformingDetailView({ id, mode }: Props) {
const router = useRouter();
const [data, setData] = useState<DetailType | null>(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 (
<>
<IntegratedDetailTemplate
config={detailConfig}
mode="view"
isLoading={isLoading}
initialData={data as unknown as Record<string, unknown>}
onBack={() => router.push(BASE_PATH)}
headerActionItems={headerActionItems}
headerActions={
data ? (
<BadgeSm className={NC_STATUS_CONFIG[data.status].className}>
{NC_STATUS_CONFIG[data.status].label}
</BadgeSm>
) : null
}
renderView={() =>
data ? (
<div className="space-y-6">
{/* 섹션 1: 기본 정보 */}
<FormSection title="기본 정보" icon={Info}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ReadonlyField label="부적합번호" value={
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
{data.nc_number}
</code>
} />
<ReadonlyField label="부적합 유형" value={NC_TYPE_LABELS[data.nc_type]} />
<ReadonlyField label="발생일" value={data.occurred_at} />
<ReadonlyField label="불량확인일" value={data.confirmed_at} />
<ReadonlyField label="현장명" value={data.site_name} />
<ReadonlyField label="부서" value={data.department?.name} />
<ReadonlyField label="관련 수주" value={data.order?.order_number} />
<ReadonlyField label="등록자" value={data.creator?.name} />
<ReadonlyField label="관련 직원" value={data.related_employee?.name} />
<ReadonlyField label="단위" value={data.unit} />
</div>
</FormSection>
{/* 섹션 2: 불량 내역 */}
<FormSection title="불량 내역" icon={AlertCircle} variant="highlighted">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ReadonlyField label="관련 품목" value={data.item?.name} />
<ReadonlyField label="불량 수량" value={data.defect_quantity} />
</div>
<div className="mt-4">
<ReadonlyField label="불량 상세 설명" value={
data.defect_description ? (
<p className="whitespace-pre-wrap text-sm">{data.defect_description}</p>
) : null
} />
</div>
</FormSection>
{/* 섹션 3: 원인 분석 및 처리 방안 */}
<FormSection title="원인 분석 및 처리 방안" icon={Wrench} variant="highlighted">
<div className="space-y-4">
<ReadonlyField label="불량 발생 원인 및 분석" value={
data.cause_analysis ? (
<p className="whitespace-pre-wrap text-sm">{data.cause_analysis}</p>
) : null
} />
<ReadonlyField label="처리 방안 및 개선 사항" value={
data.corrective_action ? (
<p className="whitespace-pre-wrap text-sm">{data.corrective_action}</p>
) : null
} />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ReadonlyField label="조치 완료일" value={data.action_completed_at} />
<ReadonlyField label="조치 담당자" value={data.action_manager?.name} />
</div>
</div>
</FormSection>
{/* 섹션 4: 자재 내역 및 비용 */}
<FormSection title="자재 내역 및 비용" icon={Package} variant="highlighted">
<NonconformingItemTable items={data.items || []} onChange={() => {}} disabled />
{/* 비용 요약 */}
<div className="mt-6 border rounded-md p-4 bg-muted/30">
<h4 className="text-sm font-semibold mb-3"> </h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<ReadonlyField label="자재 비용" value={formatAmount(materialCost)} />
<ReadonlyField label="운송 비용" value={formatAmount(data.shipping_cost)} />
<ReadonlyField label="시공 비용" value={formatAmount(data.construction_cost)} />
<ReadonlyField label="기타 비용" value={formatAmount(data.other_cost)} />
<div className="space-y-1">
<Label className="text-xs text-muted-foreground font-bold"> </Label>
<div className="text-sm font-bold text-blue-700">
{formatAmount(data.total_cost)}
</div>
</div>
</div>
</div>
</FormSection>
{/* 섹션 5: 비고 */}
<FormSection title="비고" icon={MessageSquare}>
<div className="space-y-4">
<ReadonlyField label="비고" value={
data.remarks ? <p className="whitespace-pre-wrap text-sm">{data.remarks}</p> : null
} />
<ReadonlyField label="도면 저장 위치" value={data.drawing_location} />
</div>
</FormSection>
{/* 결재 정보 */}
{data.approval && (
<FormSection title="결재 정보" icon={Send}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ReadonlyField label="결재번호" value={data.approval.document_number} />
<ReadonlyField label="결재상태" value={data.approval.status} />
<ReadonlyField label="완료일" value={data.approval.completed_at} />
</div>
{data.approval.steps?.length > 0 && (
<div className="mt-3">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex gap-2 mt-1">
{data.approval.steps.map((step) => (
<BadgeSm
key={step.id}
className={
step.status === 'approved'
? 'bg-green-100 text-green-700'
: step.status === 'rejected'
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-700'
}
>
{step.approver?.name} ({step.step_type})
</BadgeSm>
))}
</div>
</div>
)}
</FormSection>
)}
</div>
) : null
}
/>
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDelete}
title="부적합 보고서 삭제"
description="이 부적합 보고서를 삭제하시겠습니까? 삭제된 보고서는 복구할 수 없습니다."
loading={isDeleting}
/>
</>
);
}

View File

@@ -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<NonconformingFormData>({ ...INITIAL_FORM_DATA });
const [isLoading, setIsLoading] = useState(!isNew);
const [isSaving, setIsSaving] = useState(false);
const [detailData, setDetailData] = useState<NonconformingDetail | null>(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(
<K extends keyof NonconformingFormData>(key: K, value: NonconformingFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
},
[]
);
// 저장 (IntegratedDetailTemplate의 onSubmit 시그니처에 맞춤)
const handleSubmit = useCallback(async (_data: Record<string, unknown>) => {
// 필수 체크
if (!form.nc_type) { toast.error('부적합 유형을 선택하세요.'); return { success: false, error: '' }; }
if (!form.occurred_at) { toast.error('발생일을 입력하세요.'); return { success: false, error: '' }; }
const payload: Record<string, unknown> = {
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 (
<IntegratedDetailTemplate
config={config}
mode={isNew ? 'create' : 'edit'}
isLoading={isLoading}
onSubmit={handleSubmit}
onCancel={handleBack}
stickyButtons
renderForm={() => (
<div className="space-y-6">
{/* 섹션 1: 기본 정보 */}
<FormSection title="기본 정보" icon={Info}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 부적합번호 (수정 시 읽기전용) */}
{!isNew && detailData && (
<div className="space-y-2">
<Label></Label>
<Input value={detailData.nc_number} disabled />
</div>
)}
{/* 상태 (수정 시 표시) */}
{!isNew && detailData && (
<div className="space-y-2">
<Label></Label>
<div className="flex items-center h-9">
<BadgeSm className={NC_STATUS_CONFIG[detailData.status].className}>
{NC_STATUS_CONFIG[detailData.status].label}
</BadgeSm>
</div>
</div>
)}
{/* 발생일 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={form.occurred_at}
onChange={(date) => handleChange('occurred_at', date)}
/>
</div>
{/* 확인일 */}
<div className="space-y-2">
<Label></Label>
<DatePicker
value={form.confirmed_at}
onChange={(date) => handleChange('confirmed_at', date)}
/>
</div>
{/* 부적합 유형 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`nc_type-${form.nc_type}`}
value={form.nc_type}
onValueChange={(v) => handleChange('nc_type', v as NcType)}
>
<SelectTrigger>
<SelectValue placeholder="유형 선택" />
</SelectTrigger>
<SelectContent>
{NC_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={form.site_name}
onChange={(e) => handleChange('site_name', e.target.value)}
placeholder="현장명"
/>
</div>
{/* 단위 */}
<div className="space-y-2">
<Label></Label>
<Input
value={form.unit}
onChange={(e) => handleChange('unit', e.target.value)}
placeholder="EA"
/>
</div>
</div>
</FormSection>
{/* 섹션 2: 불량 내역 */}
<FormSection title="불량 내역" icon={AlertCircle} variant="highlighted">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 불량 수량 */}
<div className="space-y-2">
<Label> </Label>
<NumberInput
value={form.defect_quantity}
onChange={(val) => handleChange('defect_quantity', val ?? '')}
allowDecimal
min={0}
placeholder="수량"
/>
</div>
</div>
<div className="mt-4 space-y-2">
<Label> </Label>
<Textarea
value={form.defect_description}
onChange={(e) => handleChange('defect_description', e.target.value)}
placeholder="불량 상세 설명을 입력하세요"
rows={4}
/>
</div>
</FormSection>
{/* 섹션 3: 원인 분석 및 처리 방안 */}
<FormSection title="원인 분석 및 처리 방안" icon={Wrench} variant="highlighted">
<div className="space-y-4">
<div className="space-y-2">
<Label> </Label>
<Textarea
value={form.cause_analysis}
onChange={(e) => handleChange('cause_analysis', e.target.value)}
placeholder="원인 분석 내용을 입력하세요"
rows={4}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Textarea
value={form.corrective_action}
onChange={(e) => handleChange('corrective_action', e.target.value)}
placeholder="처리 방안 내용을 입력하세요"
rows={4}
/>
</div>
</div>
</FormSection>
{/* 섹션 4: 자재 내역 및 비용 */}
<FormSection title="자재 내역 및 비용" icon={Package} variant="highlighted">
{/* 자재 상세 테이블 */}
<NonconformingItemTable
items={form.items}
onChange={(items) => handleChange('items', items)}
/>
{/* 비용 요약 */}
<div className="mt-6 border rounded-md p-4 bg-muted/30">
<h4 className="text-sm font-semibold mb-3"> </h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"> </Label>
<div className="h-9 flex items-center px-3 bg-muted rounded-md text-sm font-medium">
{formatAmount(materialCost)}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"> </Label>
<NumberInput
value={form.shipping_cost}
onChange={(val) => handleChange('shipping_cost', val ?? 0)}
min={0}
useComma
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"> </Label>
<NumberInput
value={form.construction_cost}
onChange={(val) => handleChange('construction_cost', val ?? 0)}
min={0}
useComma
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"> </Label>
<NumberInput
value={form.other_cost}
onChange={(val) => handleChange('other_cost', val ?? 0)}
min={0}
useComma
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground font-bold"> </Label>
<div className="h-9 flex items-center px-3 bg-blue-50 border border-blue-200 rounded-md text-sm font-bold text-blue-700">
{formatAmount(totalCost)}
</div>
</div>
</div>
</div>
</FormSection>
{/* 섹션 5: 비고 */}
<FormSection title="비고" icon={MessageSquare}>
<div className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Textarea
value={form.remarks}
onChange={(e) => handleChange('remarks', e.target.value)}
placeholder="비고 사항을 입력하세요"
rows={3}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={form.drawing_location}
onChange={(e) => handleChange('drawing_location', e.target.value)}
placeholder="예: nas2dual/도면/2026/03"
/>
</div>
</div>
</FormSection>
</div>
)}
/>
);
}

View File

@@ -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 (
<div className="space-y-3">
<div className="border rounded-md overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center w-[50px]">No</TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="text-right w-[100px]"></TableHead>
<TableHead className="text-right w-[110px]"></TableHead>
<TableHead className="text-right w-[110px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
{!disabled && <TableHead className="text-center w-[50px]" />}
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={disabled ? 7 : 8} className="text-center text-muted-foreground py-8">
. {!disabled && '아래 버튼으로 행을 추가하세요.'}
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={index}>
<TableCell className="text-center text-muted-foreground">{index + 1}</TableCell>
<TableCell>
{disabled ? (
<span>{item.item_name || '-'}</span>
) : (
<Input
value={item.item_name}
onChange={(e) => handleFieldChange(index, 'item_name', e.target.value)}
placeholder="품목명"
className="h-8"
/>
)}
</TableCell>
<TableCell>
{disabled ? (
<span>{item.specification || '-'}</span>
) : (
<Input
value={item.specification}
onChange={(e) => handleFieldChange(index, 'specification', e.target.value)}
placeholder="규격"
className="h-8"
/>
)}
</TableCell>
<TableCell>
{disabled ? (
<span className="block text-right">{item.quantity}</span>
) : (
<NumberInput
value={item.quantity}
onChange={(val) => handleFieldChange(index, 'quantity', val ?? 0)}
className="h-8 text-right"
min={0}
allowDecimal
/>
)}
</TableCell>
<TableCell>
{disabled ? (
<span className="block text-right">{formatAmount(item.unit_price)}</span>
) : (
<NumberInput
value={item.unit_price}
onChange={(val) => handleFieldChange(index, 'unit_price', val ?? 0)}
className="h-8 text-right"
min={0}
useComma
/>
)}
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.amount)}
</TableCell>
<TableCell>
{disabled ? (
<span>{item.remarks || '-'}</span>
) : (
<Input
value={item.remarks}
onChange={(e) => handleFieldChange(index, 'remarks', e.target.value)}
placeholder="비고"
className="h-8"
/>
)}
</TableCell>
{!disabled && (
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-700"
onClick={() => handleRemoveRow(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
{/* 합계 행 */}
{items.length > 0 && (
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={5} className="text-right">
</TableCell>
<TableCell className="text-right font-bold text-blue-700">
{formatAmount(totalAmount)}
</TableCell>
<TableCell colSpan={disabled ? 1 : 2} />
</TableRow>
)}
</TableBody>
</Table>
</div>
{!disabled && (
<Button variant="outline" size="sm" onClick={handleAddRow}>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</div>
);
}

View File

@@ -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 <BadgeSm className={config.className}>{config.label}</BadgeSm>;
}
export function NonconformingList() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 날짜 범위
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
// 필터 상태
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
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<string[]>([]);
const [isDeleting, setIsDeleting] = useState(false);
// API 상태
const [data, setData] = useState<NonconformingListItem[]>([]);
const [statsData, setStatsData] = useState<NonconformingStats | null>(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 (
<TableRow
key={item.id}
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
onClick={() => handleView(item)}
>
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
{item.nc_number}
</code>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="text-xs">
{NC_TYPE_LABELS[item.nc_type] || item.nc_type}
</Badge>
</TableCell>
<TableCell>{item.site_name || '-'}</TableCell>
<TableCell>{item.item?.name || '-'}</TableCell>
<TableCell>{item.occurred_at || '-'}</TableCell>
<TableCell className="text-right">{formatAmount(item.total_cost)}</TableCell>
<TableCell className="text-center">{getStatusBadge(item.status)}</TableCell>
<TableCell>{item.creator?.name || '-'}</TableCell>
</TableRow>
);
};
// 모바일 카드
const renderMobileCard = (
item: NonconformingListItem,
_index: number,
globalIndex: number,
handlers: { isSelected: boolean; onToggle: () => void }
) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
key={item.id}
id={String(item.id)}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => handleView(item)}
headerBadges={
<>
<Badge variant="outline" className="bg-gray-100 text-gray-700 font-mono text-xs">
{globalIndex}
</Badge>
<code className="inline-block text-xs bg-gray-100 text-gray-700 px-2.5 py-0.5 rounded-md font-mono whitespace-nowrap">
{item.nc_number}
</code>
</>
}
title={item.site_name || '(현장명 없음)'}
statusBadge={getStatusBadge(item.status)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="유형" value={NC_TYPE_LABELS[item.nc_type]} />
<InfoField label="품목명" value={item.item?.name || '-'} />
<InfoField label="발생일" value={item.occurred_at || '-'} />
<InfoField
label="비용합계"
value={formatAmount(item.total_cost)}
valueClassName="text-red-600"
/>
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleView(item); }}
>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
{item.status !== 'CLOSED' && (
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300"
onClick={(e) => { e.stopPropagation(); handleDelete(String(item.id)); }}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
)}
</div>
) : undefined
}
/>
);
};
// Config
const config: UniversalListConfig<NonconformingListItem> = {
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: () => (
<Button onClick={() => router.push(`${BASE_PATH}?mode=new`)}>
<Plus className="w-4 h-4 mr-2" />
</Button>
),
renderTableRow,
renderMobileCard,
renderDialogs: () => (
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="삭제 확인"
description={
<>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
loading={isDeleting}
/>
),
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<UniversalListPage<NonconformingListItem>
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);
}}
/>
);
}

View File

@@ -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<NonconformingListItem, NonconformingListItem>({
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<string, unknown>) {
return executeServerAction({
url: buildApiUrl(BASE_PATH),
method: 'POST',
body: data,
errorMessage: '부적합 등록에 실패했습니다.',
});
}
// 수정
export async function updateNonconformingReport(id: number | string, data: Record<string, unknown>) {
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: '결재상신에 실패했습니다.',
});
}

View File

@@ -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<NcStatus, { label: string; className: string }> = {
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<NcType, string> = {
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<NcStatus, number>;
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: '',
};