Files
sam-react-prod/src/components/production/WorkOrders/WorkOrderEdit.tsx
권혁성 98b65a6ca4 feat(WEB): 작업지시 수정 페이지 및 생산 관리 기능 개선
신규 기능:
- 작업지시 수정 페이지 추가 (/production/work-orders/[id]/edit)
- WorkOrderEdit 컴포넌트 신규 생성
- bulk-actions.ts 일괄 작업 유틸리티 추가
- toast-utils.ts 알림 유틸리티 추가

기능 개선:
- ProductionDashboard 대시보드 액션 및 표시 개선
- WorkOrderCreate 생성 화면 개선
- WorkResultList 작업 결과 목록 타입 및 표시 개선
- EstimateDetailForm 견적 폼 개선
- QuoteRegistration 견적 등록 개선
- client-management-sales-admin 거래처 관리 개선
- error-handler.ts 에러 처리 개선
2026-01-16 15:39:02 +09:00

402 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 패턴 기반
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, FileText, Loader2 } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { PageLayout } from '@/components/organisms/PageLayout';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getWorkOrderById, updateWorkOrder, getProcessOptions, type ProcessOption } from './actions';
import type { WorkOrder } from './types';
// 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 () => {
// 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;
}
// 에러 초기화
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}`);
} else {
toast.error(result.error || '작업지시 수정에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderEdit] 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 || '-';
};
// 로딩 상태
if (isLoading) {
return <ContentLoadingSpinner text="작업지시 정보를 불러오는 중..." />;
}
if (!workOrder) {
return null;
}
return (
<PageLayout>
{/* 헤더 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleCancel}>
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileText className="w-5 h-5" />
</h1>
<span className="text-muted-foreground">({workOrder.workOrderNo})</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
</Button>
</div>
</div>
<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>
{/* 담당자 선택 모달 */}
<AssigneeSelectModal
open={isAssigneeModalOpen}
onOpenChange={setIsAssigneeModalOpen}
selectedIds={formData.assignees}
onSelect={(ids, names) => {
setFormData({ ...formData, assignees: ids });
setAssigneeNames(names);
}}
/>
</PageLayout>
);
}