feat(WEB): 생산/검사 기능 대폭 확장 및 작업자화면 검사입력 추가

생산관리:
- WipProductionModal 기능 개선
- WorkOrderDetail/Edit 확장 (+265줄)
- 검사성적서 콘텐츠 5종 대폭 확장 (벤딩/벤딩WIP/스크린/슬랫/슬랫조인트바)
- InspectionReportModal 기능 강화

작업자화면:
- WorkerScreen 기능 대폭 확장 (+211줄)
- WorkItemCard 개선
- InspectionInputModal 신규 추가 (작업자 검사입력)

공정관리:
- StepForm 검사항목 설정 기능 추가
- InspectionSettingModal 신규 추가
- InspectionPreviewModal 신규 추가
- process.ts 타입 확장 (+102줄)

자재관리:
- StockStatus 상세/목록/타입/목데이터 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-05 21:43:28 +09:00
parent 32d6e3bbbd
commit efcc645e24
21 changed files with 2559 additions and 328 deletions

View File

@@ -0,0 +1,282 @@
'use client';
/**
* 중간검사 미리보기 모달
*
* 설정된 검사 항목들로 실제 성적서가 어떻게 보일지 미리보기
*/
import { Fragment } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { InspectionSetting } from '@/types/process';
interface InspectionPreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
inspectionSetting?: InspectionSetting;
}
export function InspectionPreviewModal({
open,
onOpenChange,
inspectionSetting,
}: InspectionPreviewModalProps) {
if (!inspectionSetting) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="py-12 text-center text-muted-foreground">
. .
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// 활성화된 겉모양 항목들
const activeAppearanceItems = [
{ key: 'bendingStatus', label: '절곡상태', enabled: inspectionSetting.appearance.bendingStatus.enabled },
{ key: 'processingStatus', label: '가공상태', enabled: inspectionSetting.appearance.processingStatus.enabled },
{ key: 'sewingStatus', label: '재봉상태', enabled: inspectionSetting.appearance.sewingStatus.enabled },
{ key: 'assemblyStatus', label: '조립상태', enabled: inspectionSetting.appearance.assemblyStatus.enabled },
].filter((item) => item.enabled);
// 활성화된 치수 항목들
const activeDimensionItems = [
{ key: 'length', label: '길이', ...inspectionSetting.dimension.length },
{ key: 'width', label: '너비', ...inspectionSetting.dimension.width },
{ key: 'height1', label: '1 높이', ...inspectionSetting.dimension.height1 },
{ key: 'height2', label: '2 높이', ...inspectionSetting.dimension.height2 },
{ key: 'gap', label: '간격', ...inspectionSetting.dimension.gap },
].filter((item) => item.enabled);
// 샘플 데이터 (미리보기용)
const sampleRows = [1, 2, 3, 4, 5];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[1400px] sm:max-w-[1400px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* 헤더 정보 */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">:</span>
<Badge variant="outline">{inspectionSetting.standardName || '미설정'}</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> :</span>
<Badge>{activeAppearanceItems.length + activeDimensionItems.length}</Badge>
</div>
</div>
{/* 중간검사 기준서 */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
</div>
<div className="p-4 grid grid-cols-2 gap-4">
{/* 도해 이미지 영역 */}
<div className="border rounded-lg p-4 min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.schematicImage ? (
<img
src={inspectionSetting.schematicImage}
alt="도해 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<span className="text-muted-foreground text-sm"> </span>
)}
</div>
{/* 검사기준 이미지 또는 검사 항목 테이블 */}
<div className="border rounded-lg overflow-hidden min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.inspectionStandardImage ? (
<img
src={inspectionSetting.inspectionStandardImage}
alt="검사기준 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<div className="w-full">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
</tr>
</thead>
<tbody>
{activeAppearanceItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2"></td>
<td className="px-3 py-2">-</td>
</tr>
))}
{activeDimensionItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2">{item.method}</td>
<td className="px-3 py-2">{item.point}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* 중간검사 DATA */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
DATA
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b border-r px-3 py-2 text-center w-12">No.</th>
{/* 겉모양 항목들 */}
{activeAppearanceItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center min-w-[80px]"
>
{item.label}
</th>
))}
{/* 치수 항목들 */}
{activeDimensionItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center"
colSpan={2}
>
{item.label} (mm)
</th>
))}
{/* 판정 */}
{inspectionSetting.judgment && (
<th className="border-b px-3 py-2 text-center w-20">
<br />
<span className="text-xs">(/)</span>
</th>
)}
</tr>
{/* 치수 서브헤더 */}
{activeDimensionItems.length > 0 && (
<tr className="bg-muted/30">
<th className="border-b border-r px-3 py-1"></th>
{activeAppearanceItems.map((item) => (
<th key={item.key} className="border-b border-r px-3 py-1 text-xs">
/
</th>
))}
{activeDimensionItems.map((item) => (
<Fragment key={`${item.key}-header`}>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
</Fragment>
))}
{inspectionSetting.judgment && (
<th className="border-b px-3 py-1"></th>
)}
</tr>
)}
</thead>
<tbody>
{sampleRows.map((row) => (
<tr key={row} className="border-b last:border-b-0 hover:bg-muted/20">
<td className="border-r px-3 py-2 text-center">{row}</td>
{/* 겉모양 샘플 데이터 */}
{activeAppearanceItems.map((item) => (
<td key={item.key} className="border-r px-3 py-2 text-center">
<span className="text-muted-foreground"> </span>
<br />
<span className="text-muted-foreground"> </span>
</td>
))}
{/* 치수 샘플 데이터 */}
{activeDimensionItems.map((item) => (
<Fragment key={`${item.key}-data-${row}`}>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
</Fragment>
))}
{/* 판정 샘플 */}
{inspectionSetting.judgment && (
<td className="px-3 py-2 text-center">
<span className="text-muted-foreground">-</span>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 부적합 내용 */}
{inspectionSetting.nonConformingContent && (
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-2">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-r">
</div>
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm">
</div>
</div>
<div className="grid grid-cols-2">
<div className="px-4 py-3 border-r min-h-[60px] text-muted-foreground text-sm">
( )
</div>
<div className="px-4 py-3 text-center text-muted-foreground text-sm">
/
</div>
</div>
</div>
)}
</div>
{/* 버튼 영역 */}
<div className="flex justify-end mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,293 @@
'use client';
/**
* 중간검사 설정 모달
*
* 기획서 Page 9 기준:
* - 왼쪽: 기준서명, 도해 이미지, 검사기준 이미지, 겉모양 항목들 ON/OFF
* - 오른쪽: 치수 항목들 (포인트, 방법, ON/OFF), 판정, 부적합 내용
*/
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ImageUpload } from '@/components/ui/image-upload';
import type {
InspectionSetting,
InspectionPointType,
InspectionMethodType,
} from '@/types/process';
import {
INSPECTION_POINT_OPTIONS,
INSPECTION_METHOD_OPTIONS,
DEFAULT_INSPECTION_SETTING,
} from '@/types/process';
interface InspectionSettingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialData?: InspectionSetting;
onSave: (data: InspectionSetting) => void;
}
export function InspectionSettingModal({
open,
onOpenChange,
initialData,
onSave,
}: InspectionSettingModalProps) {
const [formData, setFormData] = useState<InspectionSetting>(
initialData || DEFAULT_INSPECTION_SETTING
);
useEffect(() => {
if (open) {
setFormData(initialData || DEFAULT_INSPECTION_SETTING);
}
}, [open, initialData]);
const handleSave = () => {
onSave(formData);
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
// 겉모양 항목 토글
const toggleAppearance = (key: keyof typeof formData.appearance) => {
setFormData((prev) => ({
...prev,
appearance: {
...prev.appearance,
[key]: { ...prev.appearance[key], enabled: !prev.appearance[key].enabled },
},
}));
};
// 치수 항목 토글
const toggleDimension = (key: keyof typeof formData.dimension) => {
setFormData((prev) => ({
...prev,
dimension: {
...prev.dimension,
[key]: { ...prev.dimension[key], enabled: !prev.dimension[key].enabled },
},
}));
};
// 치수 포인트 변경
const setDimensionPoint = (
key: keyof typeof formData.dimension,
point: InspectionPointType
) => {
setFormData((prev) => ({
...prev,
dimension: {
...prev.dimension,
[key]: { ...prev.dimension[key], point },
},
}));
};
// 치수 방법 변경
const setDimensionMethod = (
key: keyof typeof formData.dimension,
method: InspectionMethodType
) => {
setFormData((prev) => ({
...prev,
dimension: {
...prev.dimension,
[key]: { ...prev.dimension[key], method },
},
}));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[1200px] sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
{/* 왼쪽 패널 - 기본 정보 및 겉모양 */}
<div className="space-y-6 border rounded-lg p-4 bg-muted/30">
<h3 className="font-semibold text-sm border-b pb-2"> </h3>
{/* 기준서명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.standardName}
onChange={(e) =>
setFormData((prev) => ({ ...prev, standardName: e.target.value }))
}
placeholder="예: KDPS-20"
/>
</div>
{/* 중간검사 기준서 이미지 (통합) */}
<div className="space-y-2">
<Label> </Label>
<ImageUpload
value={formData.schematicImage}
onChange={(file) => {
const url = URL.createObjectURL(file);
// 두 필드 모두 동일 이미지로 설정 (호환성)
setFormData((prev) => ({
...prev,
schematicImage: url,
inspectionStandardImage: url,
}));
}}
onRemove={() => {
setFormData((prev) => ({
...prev,
schematicImage: undefined,
inspectionStandardImage: undefined,
}));
}}
aspectRatio="wide"
size="lg"
className="w-full"
hint="도해 및 검사기준이 포함된 통합 이미지를 업로드하세요"
/>
</div>
{/* 겉모양 항목들 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={formData.appearance.bendingStatus.enabled}
onCheckedChange={() => toggleAppearance('bendingStatus')}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={formData.appearance.processingStatus.enabled}
onCheckedChange={() => toggleAppearance('processingStatus')}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={formData.appearance.sewingStatus.enabled}
onCheckedChange={() => toggleAppearance('sewingStatus')}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={formData.appearance.assemblyStatus.enabled}
onCheckedChange={() => toggleAppearance('assemblyStatus')}
/>
</div>
</div>
</div>
{/* 오른쪽 패널 - 치수 및 기타 */}
<div className="space-y-6 border rounded-lg p-4 bg-muted/30">
<h3 className="font-semibold text-sm border-b pb-2"></h3>
{/* 치수 항목들 */}
{[
{ key: 'length' as const, label: '길이' },
{ key: 'width' as const, label: '너비' },
{ key: 'height1' as const, label: '1 높이' },
{ key: 'height2' as const, label: '2 높이' },
{ key: 'gap' as const, label: '간격' },
].map(({ key, label }) => (
<div key={key} className="grid grid-cols-[60px_1fr_100px_50px] items-center gap-3">
<Label className="shrink-0">{label}</Label>
<Select
value={formData.dimension[key].point}
onValueChange={(v) => setDimensionPoint(key, v as InspectionPointType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INSPECTION_POINT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={formData.dimension[key].method}
onValueChange={(v) => setDimensionMethod(key, v as InspectionMethodType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INSPECTION_METHOD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Switch
checked={formData.dimension[key].enabled}
onCheckedChange={() => toggleDimension(key)}
/>
</div>
))}
{/* 판정 */}
<div className="flex items-center justify-between pt-4 border-t">
<Label></Label>
<Switch
checked={formData.judgment}
onCheckedChange={(checked) =>
setFormData((prev) => ({ ...prev, judgment: checked }))
}
/>
</div>
{/* 부적합 내용 */}
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={formData.nonConformingContent}
onCheckedChange={(checked) =>
setFormData((prev) => ({ ...prev, nonConformingContent: checked }))
}
/>
</div>
</div>
</div>
{/* 버튼 영역 */}
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave}></Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -14,6 +14,7 @@ import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -23,14 +24,23 @@ import {
} from '@/components/ui/select';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { toast } from 'sonner';
import type { ProcessStep, StepConnectionType, StepCompletionType } from '@/types/process';
import { Settings, Eye } from 'lucide-react';
import type {
ProcessStep,
StepConnectionType,
StepCompletionType,
InspectionSetting,
} from '@/types/process';
import {
STEP_CONNECTION_TYPE_OPTIONS,
STEP_COMPLETION_TYPE_OPTIONS,
STEP_CONNECTION_TARGET_OPTIONS,
DEFAULT_INSPECTION_SETTING,
} from '@/types/process';
import { createProcessStep, updateProcessStep } from './actions';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
import { InspectionSettingModal } from './InspectionSettingModal';
import { InspectionPreviewModal } from './InspectionPreviewModal';
const stepCreateConfig: DetailConfig = {
title: '단계',
@@ -94,8 +104,20 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
initialData?.completionType || '클릭 시 완료'
);
// 검사 설정
const [inspectionSetting, setInspectionSetting] = useState<InspectionSetting>(
initialData?.inspectionSetting || DEFAULT_INSPECTION_SETTING
);
// 모달 상태
const [isInspectionSettingOpen, setIsInspectionSettingOpen] = useState(false);
const [isInspectionPreviewOpen, setIsInspectionPreviewOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// 검사여부가 "필요"인지 확인
const isInspectionEnabled = needsInspection === '필요';
// 제출
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
if (!stepName.trim()) {
@@ -114,6 +136,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
connectionType,
connectionTarget: connectionType === '팝업' ? connectionTarget : undefined,
completionType,
inspectionSetting: isInspectionEnabled ? inspectionSetting : undefined,
};
setIsLoading(true);
@@ -236,7 +259,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-end">
<div className="space-y-2">
<Label></Label>
<Select
@@ -270,6 +293,28 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
</SelectContent>
</Select>
</div>
{/* 검사여부가 "필요"일 때 버튼 표시 */}
{isInspectionEnabled && (
<>
<Button
type="button"
variant="default"
className="bg-amber-500 hover:bg-amber-600 text-white"
onClick={() => setIsInspectionSettingOpen(true)}
>
<Settings className="h-4 w-4 mr-2" />
</Button>
<Button
type="button"
variant="outline"
onClick={() => setIsInspectionPreviewOpen(true)}
>
<Eye className="h-4 w-4 mr-2" />
</Button>
</>
)}
</div>
</CardContent>
</Card>
@@ -314,19 +359,37 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
connectionTarget,
completionType,
initialData?.stepCode,
isInspectionEnabled,
]
);
const config = isEdit ? stepEditConfig : stepCreateConfig;
return (
<IntegratedDetailTemplate
config={config}
mode={isEdit ? 'edit' : 'create'}
isLoading={false}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
<>
<IntegratedDetailTemplate
config={config}
mode={isEdit ? 'edit' : 'create'}
isLoading={false}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
{/* 검사 설정 모달 */}
<InspectionSettingModal
open={isInspectionSettingOpen}
onOpenChange={setIsInspectionSettingOpen}
initialData={inspectionSetting}
onSave={setInspectionSetting}
/>
{/* 검사 미리보기 모달 */}
<InspectionPreviewModal
open={isInspectionPreviewOpen}
onOpenChange={setIsInspectionPreviewOpen}
inspectionSetting={isInspectionEnabled ? inspectionSetting : undefined}
/>
</>
);
}