feat: 생산/품질/자재/출고/주문 관리 페이지 구현

- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면
- 품질관리: 검사관리 (리스트/등록/상세)
- 자재관리: 입고관리, 재고현황
- 출고관리: 출하관리 (리스트/등록/상세/수정)
- 주문관리: 수주관리, 생산의뢰
- 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration
- IntegratedListTemplateV2 개선
- 공통 컴포넌트 분석 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-23 21:13:07 +09:00
parent 2ebcea0255
commit f0e8e51d06
108 changed files with 21156 additions and 84 deletions

View File

@@ -0,0 +1,469 @@
'use client';
/**
* 작업지시 등록 페이지
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, FileText, X, Edit2 } 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 { PageLayout } from '@/components/organisms/PageLayout';
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
// Validation 에러 타입
interface ValidationErrors {
[key: string]: string;
}
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
selectedOrder: '수주',
client: '발주처',
projectName: '현장명',
shipmentDate: '출고예정일',
};
type RegistrationMode = 'linked' | 'manual';
interface FormData {
// 수주 정보
selectedOrder: SalesOrder | null;
splitOption: 'all' | 'partial';
// 기본 정보
client: string;
projectName: string;
orderNo: string;
itemCount: number;
// 작업지시 정보
processType: ProcessType;
shipmentDate: string;
priority: number;
assignees: string[];
// 비고
note: string;
}
const initialFormData: FormData = {
selectedOrder: null,
splitOption: 'all',
client: '',
projectName: '',
orderNo: '',
itemCount: 0,
processType: 'screen',
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 handleSelectOrder = (order: SalesOrder) => {
setFormData({
...formData,
selectedOrder: order,
client: order.client,
projectName: order.projectName,
orderNo: order.orderNo,
itemCount: order.itemCount,
});
};
// 수주 해제
const handleClearOrder = () => {
setFormData({
...initialFormData,
processType: formData.processType,
shipmentDate: formData.shipmentDate,
priority: formData.priority,
});
};
// 폼 제출
const handleSubmit = () => {
// 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.shipmentDate) {
errors.shipmentDate = '출고예정일을 선택해주세요';
}
// 에러가 있으면 상태 업데이트 후 리턴
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
// 에러 초기화
setValidationErrors({});
// TODO: API 호출
console.log('Submit:', formData);
router.push('/production/work-orders');
};
// 취소
const handleCancel = () => {
router.back();
};
// 공정 코드 표시
const getProcessCode = (type: ProcessType) => {
const codes: Record<ProcessType, string> = {
screen: 'P-001 | 작업일지: WL-SCR',
slat: 'P-002 | 작업일지: WL-SLT',
bending: 'P-003 | 작업일지: WL-FLD',
};
return codes[type];
};
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>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSubmit}>
</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-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.processType}
onValueChange={(value) => setFormData({ ...formData, processType: value as ProcessType })}
>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PROCESS_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
: {getProcessCode(formData.processType)}
</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>
{/* 수주 선택 모달 */}
<SalesOrderSelectModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
onSelect={handleSelectOrder}
/>
{/* 담당자 선택 모달 */}
<AssigneeSelectModal
open={isAssigneeModalOpen}
onOpenChange={setIsAssigneeModalOpen}
selectedIds={formData.assignees}
onSelect={(ids, names) => {
setFormData({ ...formData, assignees: ids });
setAssigneeNames(names);
}}
/>
</PageLayout>
);
}