Files
sam-react-prod/src/components/production/WorkOrders/WorkOrderCreate.tsx
유병철 c2ed71540f feat(WEB): DatePicker 공통화 및 공정관리/작업자화면 대폭 개선
DatePicker 공통화:
- date-picker.tsx 공통 컴포넌트 신규 추가
- 전체 폼 컴포넌트 DatePicker 통일 적용 (50+ 파일)
- DateRangeSelector 개선

공정관리:
- RuleModal 대폭 리팩토링 (-592줄 → 간소화)
- ProcessForm, StepForm 개선
- ProcessDetail 수정, actions 확장

작업자화면:
- WorkerScreen 기능 대폭 확장 (+543줄)
- WorkItemCard 개선
- types 확장

회계/인사/영업/품질:
- BadDebtDetail, BillDetail, DepositDetail, SalesDetail 등 DatePicker 적용
- EmployeeForm, VacationDialog 등 DatePicker 적용
- OrderRegistration, QuoteRegistration DatePicker 적용
- InspectionCreate, InspectionDetail DatePicker 적용

공사관리/CEO대시보드:
- BiddingDetail, ContractDetail, HandoverReport 등 DatePicker 적용
- ScheduleDetailModal, TodayIssueSection 개선

기타:
- WorkOrderCreate/Edit/Detail/List 개선
- ShipmentCreate/Edit, ReceivingDetail 개선
- calendar, calendarEvents 수정
- datepicker 마이그레이션 체크리스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:48:00 +09:00

547 lines
20 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 { ArrowLeft, FileText, X, Edit2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
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 { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
import { workOrderCreateConfig } from './workOrderConfig';
import { useDevFill } from '@/components/dev';
import { generateWorkOrderData } from '@/components/dev/generators/workOrderData';
// 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();
}, []);
// DevToolbar 자동 채우기
useDevFill(
'workOrder',
useCallback(async () => {
// 공정 옵션 직접 가져오기 (state가 아직 로딩 전일 수 있음)
const processResult = await getProcessOptions();
const processes = processResult.success ? processResult.data : [];
const sampleData = generateWorkOrderData({ processOptions: processes });
// 수동 등록 모드로 변경
setMode('manual');
// 폼 데이터 채우기
setFormData(prev => ({
...prev,
client: '테스트 거래처',
projectName: '테스트 현장',
orderNo: '',
itemCount: 0,
processId: sampleData.processId,
shipmentDate: sampleData.shipmentDate,
priority: sampleData.priority,
note: sampleData.note,
}));
toast.success('[Dev] 작업지시 폼이 자동으로 채워졌습니다.');
}, [])
);
// 수주 선택 핸들러
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)),
note: 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>
<DatePicker
value={formData.shipmentDate}
onChange={(date) => setFormData({ ...formData, shipmentDate: date })}
/>
</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"
isLoading={isLoadingProcesses}
onCancel={handleCancel}
onSubmit={async (_data: Record<string, unknown>) => {
await handleSubmit();
return { success: true };
}}
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);
}}
/>
</>
);
}