Files
sam-react-prod/src/components/production/WorkOrders/WorkOrderCreate.tsx
유병철 62ef2b1ff9 feat(WEB): IntegratedDetailTemplate 통합 템플릿 구현 및 Phase 1~8 마이그레이션
- Phase 1: 기안함(DocumentCreate) 마이그레이션
- Phase 2: 작업지시(WorkOrderCreate/Edit) 마이그레이션
- Phase 3: 출하(ShipmentCreate/Edit) 마이그레이션
- Phase 4: 사원(EmployeeForm) 마이그레이션
- Phase 5: 게시판(BoardForm) 마이그레이션
- Phase 6: 1:1문의(InquiryForm) 마이그레이션
- Phase 7: 공정(ProcessForm) 마이그레이션
- Phase 8: 수입검사/품질검사(InspectionCreate) 마이그레이션
- DetailActions에 showSave 옵션 추가
- 각 도메인별 config 파일 생성

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 19:31:07 +09:00

513 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* 작업지시 등록 페이지
* API 연동 완료 (2025-12-26)
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { X, Edit2, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { createWorkOrder, getProcessOptions, type ProcessOption } from './actions';
import { type SalesOrder } from './types';
import { workOrderCreateConfig } from './workOrderConfig';
// Validation 에러 타입
interface ValidationErrors {
[key: string]: string;
}
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
selectedOrder: '수주',
client: '발주처',
projectName: '현장명',
processId: '공정',
shipmentDate: '출고예정일',
};
type RegistrationMode = 'linked' | 'manual';
interface FormData {
// 수주 정보
selectedOrder: SalesOrder | null;
splitOption: 'all' | 'partial';
// 기본 정보
client: string;
projectName: string;
orderNo: string;
itemCount: number;
// 작업지시 정보
processId: number | null; // 공정 ID (FK → processes.id)
shipmentDate: string;
priority: number;
assignees: string[];
// 비고
note: string;
}
const initialFormData: FormData = {
selectedOrder: null,
splitOption: 'all',
client: '',
projectName: '',
orderNo: '',
itemCount: 0,
processId: null,
shipmentDate: '',
priority: 5,
assignees: [],
note: '',
};
export function WorkOrderCreate() {
const router = useRouter();
const [mode, setMode] = useState<RegistrationMode>('linked');
const [formData, setFormData] = useState<FormData>(initialFormData);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
// 공정 옵션 로드
useEffect(() => {
async function loadProcessOptions() {
setIsLoadingProcesses(true);
const result = await getProcessOptions();
if (result.success) {
setProcessOptions(result.data);
// 첫 번째 공정을 기본값으로 설정
if (result.data.length > 0 && !formData.processId) {
setFormData(prev => ({ ...prev, processId: result.data[0].id }));
}
} else {
toast.error(result.error || '공정 목록을 불러오는데 실패했습니다.');
}
setIsLoadingProcesses(false);
}
loadProcessOptions();
}, []);
// 수주 선택 핸들러
const handleSelectOrder = (order: SalesOrder) => {
setFormData({
...formData,
selectedOrder: order,
client: order.client,
projectName: order.projectName,
orderNo: order.orderNo,
itemCount: order.itemCount,
});
};
// 수주 해제
const handleClearOrder = () => {
setFormData({
...initialFormData,
processId: formData.processId,
shipmentDate: formData.shipmentDate,
priority: formData.priority,
});
};
// 폼 제출
const handleSubmit = async () => {
// Validation 체크
const errors: ValidationErrors = {};
if (mode === 'linked') {
if (!formData.selectedOrder) {
errors.selectedOrder = '수주를 선택해주세요';
}
} else {
if (!formData.client) {
errors.client = '발주처를 입력해주세요';
}
if (!formData.projectName) {
errors.projectName = '현장명을 입력해주세요';
}
}
if (!formData.processId) {
errors.processId = '공정을 선택해주세요';
}
if (!formData.shipmentDate) {
errors.shipmentDate = '출고예정일을 선택해주세요';
}
// 에러가 있으면 상태 업데이트 후 리턴
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
// 에러 초기화
setValidationErrors({});
setIsSubmitting(true);
try {
// API 호출
const result = await createWorkOrder({
salesOrderId: formData.selectedOrder?.id ? parseInt(formData.selectedOrder.id) : undefined,
projectName: formData.projectName,
processId: formData.processId!, // 공정 ID (FK → processes.id)
scheduledDate: formData.shipmentDate,
assigneeIds: formData.assignees.map(id => parseInt(id)),
memo: formData.note || undefined,
});
if (result.success) {
toast.success('작업지시가 등록되었습니다.');
router.push('/production/work-orders');
} else {
toast.error(result.error || '작업지시 등록에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderCreate] handleSubmit error:', error);
toast.error('작업지시 등록 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
};
// 취소
const handleCancel = () => {
router.back();
};
// 선택된 공정의 코드 가져오기
const getSelectedProcessCode = (): string => {
const selectedProcess = processOptions.find(p => p.id === formData.processId);
return selectedProcess?.processCode || '-';
};
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(validationErrors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 등록 방식 */}
<section className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as RegistrationMode)}
className="flex gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="linked" id="linked" />
<Label htmlFor="linked" className="cursor-pointer">
{' '}
<span className="text-muted-foreground text-sm">
( )
</span>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="manual" id="manual" />
<Label htmlFor="manual" className="cursor-pointer">
{' '}
<span className="text-muted-foreground text-sm">
()
</span>
</Label>
</div>
</RadioGroup>
</section>
{/* 수주 정보 (연동 모드) */}
{mode === 'linked' && (
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
{!formData.selectedOrder ? (
<div className="flex items-center justify-between p-4 bg-white border rounded-lg">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<div>
<p className="font-medium"> </p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
<Button variant="outline" onClick={() => setIsModalOpen(true)}>
<FileText className="w-4 h-4 mr-1.5" />
</Button>
</div>
) : (
<div className="p-4 bg-white border rounded-lg">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">{formData.selectedOrder.orderNo}</span>
<span className="text-sm text-muted-foreground">
{formData.selectedOrder.status}
</span>
</div>
<p className="text-sm text-muted-foreground">
{formData.selectedOrder.client} / {formData.selectedOrder.projectName} / {formData.selectedOrder.itemCount}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={handleClearOrder}>
<X className="w-4 h-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={() => setIsModalOpen(true)}>
<Edit2 className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 분할 선택 */}
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-2"> </h4>
<RadioGroup
value={formData.splitOption}
onValueChange={(value) =>
setFormData({ ...formData, splitOption: value as 'all' | 'partial' })
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all" id="split-all" />
<Label htmlFor="split-all" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="partial" id="split-partial" />
<Label htmlFor="split-partial" className="cursor-pointer">
{formData.selectedOrder.orderNo}-01
</Label>
</div>
</RadioGroup>
</div>
</div>
)}
</section>
)}
{/* 기본 정보 */}
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.client}
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '발주처 입력'}
disabled={mode === 'linked'}
className="bg-white"
/>
</div>
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.projectName}
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '현장명 입력'}
disabled={mode === 'linked'}
className="bg-white"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.orderNo}
onChange={(e) => setFormData({ ...formData, orderNo: e.target.value })}
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '수주번호 입력'}
disabled={mode === 'linked'}
className="bg-white"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.itemCount || ''}
onChange={(e) => setFormData({ ...formData, itemCount: parseInt(e.target.value) || 0 })}
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '품목수 입력'}
disabled={mode === 'linked'}
className="bg-white"
/>
</div>
</div>
</section>
{/* 작업지시 정보 */}
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="space-y-4">
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.processId?.toString() || ''}
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
disabled={isLoadingProcesses}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
</SelectTrigger>
<SelectContent>
{processOptions.map((process) => (
<SelectItem key={process.id} value={process.id.toString()}>
{process.processName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
: {getSelectedProcessCode()}
</p>
</div>
<div className="space-y-2">
<Label> *</Label>
<Input
type="date"
value={formData.shipmentDate}
onChange={(e) => setFormData({ ...formData, shipmentDate: e.target.value })}
className="bg-white"
/>
</div>
<div className="space-y-2">
<Label> (1=, 9=)</Label>
<Select
value={formData.priority.toString()}
onValueChange={(value) => setFormData({ ...formData, priority: parseInt(value) })}
>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<SelectItem key={n} value={n.toString()}>
{n} {n === 5 ? '(일반)' : n === 1 ? '(긴급)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> ( )</Label>
<div
onClick={() => setIsAssigneeModalOpen(true)}
className="flex min-h-10 w-full cursor-pointer items-center rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50"
>
{assigneeNames.length > 0 ? (
<span>{assigneeNames.join(', ')}</span>
) : (
<span className="text-muted-foreground"> (/)</span>
)}
</div>
</div>
</div>
</section>
{/* 비고 */}
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"></h3>
<Textarea
value={formData.note}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
placeholder="특이사항이나 메모를 입력하세요"
rows={4}
className="bg-white"
/>
</section>
</div>
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode]);
return (
<>
<IntegratedDetailTemplate
config={workOrderCreateConfig}
mode="create"
isSubmitting={isSubmitting}
onBack={handleCancel}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
{/* 수주 선택 모달 */}
<SalesOrderSelectModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
onSelect={handleSelectOrder}
/>
{/* 담당자 선택 모달 */}
<AssigneeSelectModal
open={isAssigneeModalOpen}
onOpenChange={setIsAssigneeModalOpen}
selectedIds={formData.assignees}
onSelect={(ids, names) => {
setFormData({ ...formData, assignees: ids });
setAssigneeNames(names);
}}
/>
</>
);
}