Files
sam-react-prod/src/components/production/WorkOrders/WorkOrderEdit.tsx
유병철 f6551c7e8b feat(WEB): 전체 페이지 ?mode= URL 네비게이션 패턴 적용
- 등록(?mode=new), 상세(?mode=view), 수정(?mode=edit) URL 패턴 일괄 적용
- 중복 패턴 제거: /edit?mode=edit → ?mode=edit (16개 파일)
- 제목 일관성: {기능} 등록/상세/수정 패턴 적용
- 검수 체크리스트 문서 추가 (79개 페이지)
- UniversalListPage, IntegratedDetailTemplate 공통 컴포넌트 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:27:43 +09:00

390 lines
13 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';
/**
* 작업지시 수정 페이지
* WorkOrderCreate 패턴 기반
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getWorkOrderById, updateWorkOrder, getProcessOptions, type ProcessOption } from './actions';
import type { WorkOrder } from './types';
import { workOrderEditConfig } from './workOrderConfig';
// Validation 에러 타입
interface ValidationErrors {
[key: string]: string;
}
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
processId: '공정',
scheduledDate: '출고예정일',
};
interface FormData {
// 기본 정보 (읽기 전용)
client: string;
projectName: string;
orderNo: string;
itemCount: number;
// 수정 가능 정보
processId: number | null;
scheduledDate: string;
priority: number;
assignees: string[];
// 비고
note: string;
}
interface WorkOrderEditProps {
orderId: string;
}
export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
const router = useRouter();
const [workOrder, setWorkOrder] = useState<WorkOrder | null>(null);
const [formData, setFormData] = useState<FormData>({
client: '',
projectName: '',
orderNo: '',
itemCount: 0,
processId: null,
scheduledDate: '',
priority: 5,
assignees: [],
note: '',
});
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [orderResult, processResult] = await Promise.all([
getWorkOrderById(orderId),
getProcessOptions(),
]);
if (orderResult.success && orderResult.data) {
const order = orderResult.data;
setWorkOrder(order);
setFormData({
client: order.client,
projectName: order.projectName,
orderNo: order.lotNo,
itemCount: order.items?.length || 0,
processId: order.processId,
scheduledDate: order.scheduledDate || '',
priority: order.priority || 5,
assignees: order.assignees?.map(a => a.id) || [],
note: order.note || '',
});
// 담당자 이름 설정
if (order.assignees) {
setAssigneeNames(order.assignees.map(a => a.name));
}
} else {
toast.error(orderResult.error || '작업지시 조회에 실패했습니다.');
router.push('/production/work-orders');
return;
}
if (processResult.success) {
setProcessOptions(processResult.data);
} else {
toast.error(processResult.error || '공정 목록을 불러오는데 실패했습니다.');
}
setIsLoadingProcesses(false);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderEdit] loadData error:', error);
toast.error('데이터 로드 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [orderId, router]);
useEffect(() => {
loadData();
}, [loadData]);
// 폼 제출
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
// Validation 체크
const errors: ValidationErrors = {};
if (!formData.processId) {
errors.processId = '공정을 선택해주세요';
}
if (!formData.scheduledDate) {
errors.scheduledDate = '출고예정일을 선택해주세요';
}
// 에러가 있으면 상태 업데이트 후 리턴
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
window.scrollTo({ top: 0, behavior: 'smooth' });
return { success: false, error: '입력 정보를 확인해주세요.' };
}
// 에러 초기화
setValidationErrors({});
setIsSubmitting(true);
try {
// 담당자 ID 배열 변환 (string[] → number[])
const assigneeIds = formData.assignees
.map(id => parseInt(id, 10))
.filter(id => !isNaN(id));
const result = await updateWorkOrder(orderId, {
projectName: formData.projectName,
processId: formData.processId!,
scheduledDate: formData.scheduledDate,
priority: formData.priority,
assigneeIds: assigneeIds.length > 0 ? assigneeIds : undefined,
note: formData.note || undefined,
});
if (result.success) {
toast.success('작업지시가 수정되었습니다.');
router.push(`/production/work-orders/${orderId}?mode=view`);
return { success: true };
} else {
return { success: false, error: result.error || '작업지시 수정에 실패했습니다.' };
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderEdit] handleSubmit error:', error);
return { success: false, error: '작업지시 수정 중 오류가 발생했습니다.' };
} finally {
setIsSubmitting(false);
}
};
// 취소
const handleCancel = () => {
router.back();
};
// 선택된 공정의 코드 가져오기
const getSelectedProcessCode = (): string => {
const selectedProcess = processOptions.find(p => p.id === formData.processId);
return selectedProcess?.processCode || '-';
};
// 동적 config (작업지시 번호 포함)
const dynamicConfig = {
...workOrderEditConfig,
title: workOrder ? `작업지시 (${workOrder.workOrderNo})` : '작업지시',
};
// 폼 컨텐츠 렌더링
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-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}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.projectName}
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
className="bg-white"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.orderNo}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.itemCount || '-'}
disabled
className="bg-muted"
/>
</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.scheduledDate}
onChange={(e) => setFormData({ ...formData, scheduledDate: 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>
), [formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode]);
return (
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode="edit"
isLoading={isLoading}
isSubmitting={isSubmitting}
onBack={handleCancel}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
{/* 담당자 선택 모달 */}
<AssigneeSelectModal
open={isAssigneeModalOpen}
onOpenChange={setIsAssigneeModalOpen}
selectedIds={formData.assignees}
onSelect={(ids, names) => {
setFormData({ ...formData, assignees: ids });
setAssigneeNames(names);
}}
/>
</>
);
}