feat: [material] 부적합품관리 페이지 신규 추가
This commit is contained in:
@@ -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" />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
357
src/components/material/nonconforming/NonconformingDetail.tsx
Normal file
357
src/components/material/nonconforming/NonconformingDetail.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
437
src/components/material/nonconforming/NonconformingForm.tsx
Normal file
437
src/components/material/nonconforming/NonconformingForm.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
201
src/components/material/nonconforming/NonconformingItemTable.tsx
Normal file
201
src/components/material/nonconforming/NonconformingItemTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
459
src/components/material/nonconforming/NonconformingList.tsx
Normal file
459
src/components/material/nonconforming/NonconformingList.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
115
src/components/material/nonconforming/actions.ts
Normal file
115
src/components/material/nonconforming/actions.ts
Normal 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: '결재상신에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
190
src/components/material/nonconforming/types.ts
Normal file
190
src/components/material/nonconforming/types.ts
Normal 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: '',
|
||||
};
|
||||
Reference in New Issue
Block a user