feat: 공정관리 페이지 및 컴포넌트 추가

- 공정관리 목록/상세/등록/수정 페이지 구현
- ProcessListClient, ProcessDetail, ProcessForm 컴포넌트 추가
- ProcessWorkLogPreviewModal, RuleModal 추가
- MobileCard 공통 컴포넌트 추가
- WorkLogModal.tsx 개선
- .gitignore 업데이트

🤖 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-26 15:48:08 +09:00
parent 41ef0bdd86
commit f0c0de2ecd
14 changed files with 1801 additions and 18 deletions

4
.gitignore vendored
View File

@@ -109,3 +109,7 @@ playwright.config.ts
playwright-report/
test-results/
.playwright/
# 로컬 테스트/개발용 폴더
src/app/\[locale\]/(protected)/dev/
src/components/common/EditableTable/

View File

@@ -0,0 +1,86 @@
/**
* 공정 수정 페이지
*/
'use client';
import { use } from 'react';
import { notFound } from 'next/navigation';
import { ProcessForm } from '@/components/process-management';
import type { Process } from '@/types/process';
// Mock 데이터
const mockProcesses: Process[] = [
{
id: '1',
processCode: 'P-004',
processName: '재고(포밍)',
description: '철판을 포밍하여 절곡 부품(반제품) 생산 후 재고 입고',
processType: '생산',
department: '포밍생산부서',
workLogTemplate: '재고생산 작업일지',
classificationRules: [],
requiredWorkers: 2,
workSteps: ['포밍', '검사', '포장'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
},
{
id: '2',
processCode: 'P-003',
processName: '슬랫',
description: '슬랫 코일 절단 및 성형',
processType: '생산',
department: '슬랫생산부서',
classificationRules: [],
requiredWorkers: 3,
workSteps: ['절단', '성형', '검사'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-10',
},
{
id: '3',
processCode: 'P-002',
processName: '절곡',
description: '가이드레일, 케이스, 하단마감재 제작',
processType: '생산',
department: '절곡생산부서',
classificationRules: [],
requiredWorkers: 4,
workSteps: ['절단', '절곡', '용접', '검사'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-08',
},
{
id: '4',
processCode: 'P-001',
processName: '스크린',
description: '방화스크린 원단 가공 및 조립',
processType: '생산',
department: '스크린생산부서',
classificationRules: [],
requiredWorkers: 3,
workSteps: ['원단가공', '조립', '검사', '포장'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-05',
},
];
export default function EditProcessPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const process = mockProcesses.find((p) => p.id === id);
if (!process) {
notFound();
}
return <ProcessForm mode="edit" initialData={process} />;
}

View File

@@ -0,0 +1,114 @@
/**
* 공정 상세 페이지
*/
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { ProcessDetail } from '@/components/process-management';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Process } from '@/types/process';
// Mock 데이터
const mockProcesses: Process[] = [
{
id: '1',
processCode: 'P-004',
processName: '재고(포밍)',
description: '철판을 포밍하여 절곡 부품(반제품) 생산 후 재고 입고',
processType: '생산',
department: '포밍생산부서',
workLogTemplate: '재고생산 작업일지',
classificationRules: [],
requiredWorkers: 2,
workSteps: ['포밍', '검사', '포장'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
},
{
id: '2',
processCode: 'P-003',
processName: '슬랫',
description: '슬랫 코일 절단 및 성형',
processType: '생산',
department: '슬랫생산부서',
classificationRules: [],
requiredWorkers: 3,
workSteps: ['절단', '성형', '검사'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-10',
},
{
id: '3',
processCode: 'P-002',
processName: '절곡',
description: '가이드레일, 케이스, 하단마감재 제작',
processType: '생산',
department: '절곡생산부서',
classificationRules: [],
requiredWorkers: 4,
workSteps: ['절단', '절곡', '용접', '검사'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-08',
},
{
id: '4',
processCode: 'P-001',
processName: '스크린',
description: '방화스크린 원단 가공 및 조립',
processType: '생산',
department: '스크린생산부서',
classificationRules: [],
requiredWorkers: 3,
workSteps: ['원단가공', '조립', '검사', '포장'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-05',
},
];
async function getProcessById(id: string): Promise<Process | null> {
const process = mockProcesses.find((p) => p.id === id);
return process || null;
}
export default async function ProcessDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const process = await getProcessById(id);
if (!process) {
notFound();
}
return (
<Suspense fallback={<ContentLoadingSpinner text="공정 정보를 불러오는 중..." />}>
<ProcessDetail process={process} />
</Suspense>
);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const process = await getProcessById(id);
if (!process) {
return {
title: '공정을 찾을 수 없습니다',
};
}
return {
title: `${process.processName} - 공정 상세`,
description: `${process.processCode} 공정 정보`,
};
}

View File

@@ -0,0 +1,11 @@
/**
* 공정 등록 페이지
*/
'use client';
import { ProcessForm } from '@/components/process-management';
export default function CreateProcessPage() {
return <ProcessForm mode="create" />;
}

View File

@@ -0,0 +1,21 @@
/**
* 공정 목록 페이지
*/
import { Suspense } from 'react';
import ProcessListClient from '@/components/process-management/ProcessListClient';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '공정 목록',
description: '공정 관리 - 생산 공정 목록 조회 및 관리',
};
export default function ProcessManagementPage() {
return (
<Suspense fallback={<ContentLoadingSpinner text="공정 목록을 불러오는 중..." />}>
<ProcessListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import { ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
interface MobileCardDetail {
label: string;
value: string | ReactNode;
}
interface MobileCardProps {
title: string;
subtitle?: string;
description?: string;
badge?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
isSelected?: boolean;
onToggle?: () => void;
onClick?: () => void;
details?: MobileCardDetail[];
actions?: ReactNode;
className?: string;
}
export function MobileCard({
title,
subtitle,
description,
badge,
badgeVariant = 'default',
isSelected = false,
onToggle,
onClick,
details = [],
actions,
className,
}: MobileCardProps) {
return (
<Card
className={cn(
'cursor-pointer transition-colors hover:bg-muted/50',
isSelected && 'ring-2 ring-primary',
className
)}
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{onToggle && (
<div onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</div>
)}
<div className="flex-1 min-w-0">
{/* 헤더 */}
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<div className="font-medium truncate">{title}</div>
{subtitle && (
<div className="text-sm text-muted-foreground">{subtitle}</div>
)}
</div>
{badge && <Badge variant={badgeVariant}>{badge}</Badge>}
</div>
{/* 설명 */}
{description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{description}
</p>
)}
{/* 상세 정보 */}
{details.length > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
{details.map((detail, index) => (
<div key={index}>
<span className="text-muted-foreground">{detail.label}: </span>
<span className="font-medium">{detail.value}</span>
</div>
))}
</div>
)}
{/* 액션 */}
{actions && (
<div className="mt-3 pt-3 border-t" onClick={(e) => e.stopPropagation()}>
{actions}
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { List, Edit, Wrench } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal';
import type { Process } from '@/types/process';
interface ProcessDetailProps {
process: Process;
}
export function ProcessDetail({ process }: ProcessDetailProps) {
const router = useRouter();
const [workLogModalOpen, setWorkLogModalOpen] = useState(false);
const handleEdit = () => {
router.push(`/ko/master-data/process-management/${process.id}/edit`);
};
const handleList = () => {
router.push('/ko/master-data/process-management');
};
const handleViewWorkLog = () => {
setWorkLogModalOpen(true);
};
return (
<PageLayout>
{/* 헤더 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Wrench className="h-6 w-6" />
<h1 className="text-xl font-semibold"> </h1>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleList}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{process.processCode}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{process.processName}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<Badge variant="secondary">{process.processType}</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{process.department}</div>
</div>
<div className="md:col-span-2">
<div className="text-sm text-muted-foreground mb-1"> </div>
<div className="flex items-center gap-2">
<span className="font-medium">
{process.workLogTemplate || '-'}
</span>
{process.workLogTemplate && (
<Button variant="outline" size="sm" onClick={handleViewWorkLog}>
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 등록 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{process.createdAt}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{process.updatedAt}</div>
</div>
</div>
</CardContent>
</Card>
{/* 자동 분류 규칙 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{process.classificationRules.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Wrench className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p> </p>
</div>
) : (
<div className="space-y-3">
{process.classificationRules.map((rule) => (
<div
key={rule.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-4">
<Badge variant={rule.isActive ? 'default' : 'secondary'}>
{rule.isActive ? '활성' : '비활성'}
</Badge>
<div>
<div className="font-medium">
{rule.ruleType} - "{rule.conditionValue}"
</div>
{rule.description && (
<div className="text-sm text-muted-foreground">
{rule.description}
</div>
)}
</div>
</div>
<Badge variant="outline">: {rule.priority}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 세부 작업단계 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{process.workSteps.length > 0 ? (
<div className="flex items-center gap-2 flex-wrap">
{process.workSteps.map((step, index) => (
<div key={index} className="flex items-center gap-2">
<Badge
variant="outline"
className="px-3 py-1.5 bg-muted/50"
>
<span className="bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center text-xs mr-2">
{index + 1}
</span>
{step}
</Badge>
{index < process.workSteps.length - 1 && (
<span className="text-muted-foreground">{'>'}</span>
)}
</div>
))}
</div>
) : (
<div className="text-muted-foreground">-</div>
)}
</CardContent>
</Card>
{/* 작업 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{process.requiredWorkers}</div>
</div>
{process.equipmentInfo && (
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{process.equipmentInfo}</div>
</div>
)}
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{process.description || '-'}</div>
</div>
</CardContent>
</Card>
</div>
{/* 작업일지 양식 미리보기 모달 */}
<ProcessWorkLogPreviewModal
open={workLogModalOpen}
onOpenChange={setWorkLogModalOpen}
process={process}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,359 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { X, Save, Plus, Wrench, Trash2 } 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 { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageLayout } from '@/components/organisms/PageLayout';
import { RuleModal } from './RuleModal';
import type { Process, ClassificationRule, ProcessType } from '@/types/process';
import { PROCESS_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process';
// Mock 담당부서 옵션
const DEPARTMENT_OPTIONS = [
{ value: '스크린생산부서', label: '스크린생산부서' },
{ value: '절곡생산부서', label: '절곡생산부서' },
{ value: '슬랫생산부서', label: '슬랫생산부서' },
{ value: '품질관리부서', label: '품질관리부서' },
{ value: '포장/출하부서', label: '포장/출하부서' },
];
// Mock 작업일지 양식 옵션
const WORK_LOG_OPTIONS = [
{ value: '스크린 작업일지', label: '스크린 작업일지' },
{ value: '절곡 작업일지', label: '절곡 작업일지' },
{ value: '슬랫 작업일지', label: '슬랫 작업일지' },
{ value: '재고생산 작업일지', label: '재고생산 작업일지' },
{ value: '포장 작업일지', label: '포장 작업일지' },
];
interface ProcessFormProps {
mode: 'create' | 'edit';
initialData?: Process;
}
export function ProcessForm({ mode, initialData }: ProcessFormProps) {
const router = useRouter();
const isEdit = mode === 'edit';
// 폼 상태
const [processName, setProcessName] = useState(initialData?.processName || '');
const [processType, setProcessType] = useState<ProcessType>(
initialData?.processType || '생산'
);
const [department, setDepartment] = useState(initialData?.department || '');
const [workLogTemplate, setWorkLogTemplate] = useState(
initialData?.workLogTemplate || ''
);
const [classificationRules, setClassificationRules] = useState<ClassificationRule[]>(
initialData?.classificationRules || []
);
const [requiredWorkers, setRequiredWorkers] = useState(
initialData?.requiredWorkers || 1
);
const [equipmentInfo, setEquipmentInfo] = useState(initialData?.equipmentInfo || '');
const [workSteps, setWorkSteps] = useState(initialData?.workSteps?.join(', ') || '');
const [note, setNote] = useState(initialData?.note || '');
const [isActive, setIsActive] = useState(initialData?.status === '사용중' ?? true);
// 규칙 모달 상태
const [ruleModalOpen, setRuleModalOpen] = useState(false);
// 규칙 추가
const handleAddRule = useCallback(
(ruleData: Omit<ClassificationRule, 'id' | 'createdAt'>) => {
const newRule: ClassificationRule = {
...ruleData,
id: `rule-${Date.now()}`,
createdAt: new Date().toISOString(),
};
setClassificationRules((prev) => [...prev, newRule]);
},
[]
);
// 규칙 삭제
const handleDeleteRule = useCallback((ruleId: string) => {
setClassificationRules((prev) => prev.filter((r) => r.id !== ruleId));
}, []);
// 제출
const handleSubmit = () => {
if (!processName.trim()) {
alert('공정명을 입력해주세요.');
return;
}
if (!department) {
alert('담당부서를 선택해주세요.');
return;
}
const formData = {
processName: processName.trim(),
processType,
department,
workLogTemplate: workLogTemplate || undefined,
classificationRules,
requiredWorkers,
equipmentInfo: equipmentInfo.trim() || undefined,
workSteps: workSteps
.split(',')
.map((s) => s.trim())
.filter(Boolean),
note: note.trim() || undefined,
isActive,
};
console.log(isEdit ? '공정 수정 데이터:' : '공정 등록 데이터:', formData);
alert(`공정이 ${isEdit ? '수정' : '등록'}되었습니다.`);
router.push('/ko/master-data/process-management');
};
// 취소
const handleCancel = () => {
router.back();
};
return (
<PageLayout>
{/* 헤더 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold"> {isEdit ? '수정' : '등록'}</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
<X className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleSubmit}>
<Save className="h-4 w-4 mr-2" />
{isEdit ? '수정' : '등록'}
</Button>
</div>
</div>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="processName"> *</Label>
<Input
id="processName"
value={processName}
onChange={(e) => setProcessName(e.target.value)}
placeholder="예: 스크린"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={processType}
onValueChange={(v) => setProcessType(v as ProcessType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROCESS_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select value={department} onValueChange={setDepartment}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{DEPARTMENT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select value={workLogTemplate} onValueChange={setWorkLogTemplate}>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{WORK_LOG_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 자동 분류 규칙 */}
<Card>
<CardHeader className="bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base"> </CardTitle>
<p className="text-sm text-muted-foreground mt-1">
.
</p>
</div>
<Button onClick={() => setRuleModalOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent className="pt-6">
{classificationRules.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Wrench className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="font-medium"> </p>
<p className="text-sm mt-1">
</p>
</div>
) : (
<div className="space-y-3">
{classificationRules.map((rule) => (
<div
key={rule.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-4">
<Badge variant={rule.isActive ? 'default' : 'secondary'}>
{rule.isActive ? '활성' : '비활성'}
</Badge>
<div>
<div className="font-medium">
{rule.ruleType}{' '}
{
MATCHING_TYPE_OPTIONS.find(
(o) => o.value === rule.matchingType
)?.label
}{' '}
"{rule.conditionValue}"
</div>
{rule.description && (
<div className="text-sm text-muted-foreground">
{rule.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">: {rule.priority}</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteRule(rule.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 작업 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-6">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={requiredWorkers}
onChange={(e) => setRequiredWorkers(Number(e.target.value))}
min={1}
className="w-32"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={equipmentInfo}
onChange={(e) => setEquipmentInfo(e.target.value)}
placeholder="예: 미싱기 3대, 절단기 1대"
/>
</div>
<div className="space-y-2">
<Label> ( )</Label>
<Input
value={workSteps}
onChange={(e) => setWorkSteps(e.target.value)}
placeholder="예: 원단절단, 미싱, 핸드작업, 중간검사, 포장"
/>
</div>
</CardContent>
</Card>
{/* 설명 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-6">
<div className="space-y-2">
<Label></Label>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="공정에 대한 설명"
rows={4}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={isActive}
onCheckedChange={(checked) => setIsActive(checked as boolean)}
/>
<Label htmlFor="isActive" className="font-normal">
</Label>
</div>
</CardContent>
</Card>
</div>
{/* 규칙 추가 모달 */}
<RuleModal
open={ruleModalOpen}
onOpenChange={setRuleModalOpen}
onAdd={handleAddRule}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,324 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Wrench, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { IntegratedListTemplateV2, TabOption, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import type { Process, ProcessStatus } from '@/types/process';
// Mock 데이터
const mockProcesses: Process[] = [
{
id: '1',
processCode: 'P-004',
processName: '재고(포밍)',
description: '철판을 포밍하여 절곡 부품(반제품) 생산 후 재고 입고',
processType: '생산',
department: '포밍생산부서',
workLogTemplate: '재고생산 작업일지',
classificationRules: [],
requiredWorkers: 2,
workSteps: ['포밍', '검사', '포장'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
},
{
id: '2',
processCode: 'P-003',
processName: '슬랫',
description: '슬랫 코일 절단 및 성형',
processType: '생산',
department: '슬랫생산부서',
classificationRules: [],
requiredWorkers: 3,
workSteps: ['절단', '성형', '검사'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-10',
},
{
id: '3',
processCode: 'P-002',
processName: '절곡',
description: '가이드레일, 케이스, 하단마감재 제작',
processType: '생산',
department: '절곡생산부서',
classificationRules: [],
requiredWorkers: 4,
workSteps: ['절단', '절곡', '용접', '검사'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-08',
},
{
id: '4',
processCode: 'P-001',
processName: '스크린',
description: '방화스크린 원단 가공 및 조립',
processType: '생산',
department: '스크린생산부서',
classificationRules: [],
requiredWorkers: 3,
workSteps: ['원단가공', '조립', '검사', '포장'],
status: '사용중',
createdAt: '2025-01-01',
updatedAt: '2025-01-05',
},
];
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'processCode', label: '공정코드', className: 'w-[100px]' },
{ key: 'processName', label: '공정명', className: 'min-w-[250px]' },
{ key: 'processType', label: '구분', className: 'w-[80px] text-center' },
{ key: 'department', label: '담당부서', className: 'w-[120px]' },
{ key: 'classificationRules', label: '분류규칙', className: 'w-[80px] text-center' },
{ key: 'requiredWorkers', label: '인원', className: 'w-[60px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
];
// 탭 옵션 계산
function getTabOptions(processes: Process[]): TabOption[] {
const total = processes.length;
const active = processes.filter(p => p.status === '사용중').length;
const inactive = processes.filter(p => p.status === '미사용').length;
return [
{ value: 'all', label: '전체', count: total },
{ value: '사용중', label: '사용중', count: active },
{ value: '미사용', label: '미사용', count: inactive },
];
}
export default function ProcessListClient() {
const router = useRouter();
// 상태
const [processes] = useState<Process[]>(mockProcesses);
const [activeTab, setActiveTab] = useState('all');
const [searchValue, setSearchValue] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 필터링된 데이터
const filteredProcesses = useMemo(() => {
return processes.filter(process => {
// 탭 필터
if (activeTab !== 'all' && process.status !== activeTab) {
return false;
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
process.processCode.toLowerCase().includes(search) ||
process.processName.toLowerCase().includes(search) ||
process.department.toLowerCase().includes(search)
);
}
return true;
});
}, [processes, activeTab, searchValue]);
// 페이지네이션
const totalPages = Math.ceil(filteredProcesses.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return filteredProcesses.slice(start, start + itemsPerPage);
}, [filteredProcesses, currentPage, itemsPerPage]);
// 탭 옵션
const tabOptions = useMemo(() => getTabOptions(processes), [processes]);
// 핸들러
const handleTabChange = useCallback((value: string) => {
setActiveTab(value);
setCurrentPage(1);
}, []);
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map(p => p.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback((process: Process) => {
router.push(`/ko/master-data/process-management/${process.id}`);
}, [router]);
const handleCreate = useCallback(() => {
router.push('/ko/master-data/process-management/new');
}, [router]);
const handleEdit = useCallback((e: React.MouseEvent, processId: string) => {
e.stopPropagation();
router.push(`/ko/master-data/process-management/${processId}/edit`);
}, [router]);
const handleDelete = useCallback((e: React.MouseEvent, processId: string) => {
e.stopPropagation();
// TODO: 삭제 확인 모달 및 API 연동
console.log('Delete process:', processId);
}, []);
// 테이블 행 렌더링
const renderTableRow = useCallback((process: Process, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(process.id);
return (
<TableRow
key={process.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(process)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(process.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{process.processCode}</TableCell>
<TableCell>
<div>
<div className="font-medium">{process.processName}</div>
{process.description && (
<div className="text-sm text-muted-foreground">{process.description}</div>
)}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{process.processType}</Badge>
</TableCell>
<TableCell>{process.department}</TableCell>
<TableCell className="text-center">
{process.classificationRules.length > 0 ? (
<Badge variant="outline">{process.classificationRules.length}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-center">{process.requiredWorkers}</TableCell>
<TableCell className="text-center">
<Badge variant={process.status === '사용중' ? 'default' : 'secondary'}>
{process.status}
</Badge>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, process.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDelete(e, process.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
}, [selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDelete]);
// 모바일 카드 렌더링
const renderMobileCard = useCallback((
process: Process,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<MobileCard
title={process.processName}
subtitle={process.processCode}
description={process.description}
badge={process.status}
badgeVariant={process.status === '사용중' ? 'default' : 'secondary'}
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(process)}
details={[
{ label: '구분', value: process.processType },
{ label: '담당부서', value: process.department },
{ label: '인원', value: `${process.requiredWorkers}` },
]}
/>
);
}, [handleRowClick]);
return (
<IntegratedListTemplateV2
title="공정 목록"
icon={Wrench}
headerActions={
<div className="flex justify-end w-full">
<Button onClick={handleCreate} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="공정코드, 공정명, 담당부서 검색"
tabs={tabOptions}
activeTab={activeTab}
onTabChange={handleTabChange}
tableColumns={tableColumns}
data={paginatedData}
allData={filteredProcesses}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: filteredProcesses.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
/**
* 공정 상세 - 작업일지 양식 미리보기 모달
*
* 기획서 기준 양식:
* - 신청업체/신청내용 테이블
* - 순번/작업항목/규격/수량/단위/작업자/비고 테이블
*/
import { Printer, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Button } from '@/components/ui/button';
import type { Process } from '@/types/process';
interface ProcessWorkLogPreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
process: Process;
}
// 공정명을 문서 코드로 매핑
const getDocumentCode = (processName: string): string => {
if (processName.includes('스크린')) return 'WL-SCR';
if (processName.includes('슬랫')) return 'WL-SLT';
if (processName.includes('절곡') || processName.includes('포밍')) return 'WL-BEN';
return 'WL-STK';
};
// 공정명을 부서명으로 매핑
const getDepartmentName = (processName: string): string => {
if (processName.includes('스크린')) return '스크린 생산부서';
if (processName.includes('슬랫')) return '슬랫 생산부서';
if (processName.includes('절곡')) return '절곡 생산부서';
if (processName.includes('포밍')) return '포밍 생산부서';
return process?.department || '생산부서';
};
export function ProcessWorkLogPreviewModal({
open,
onOpenChange,
process
}: ProcessWorkLogPreviewModalProps) {
const handlePrint = () => {
window.print();
};
const documentCode = getDocumentCode(process.processName);
const departmentName = getDepartmentName(process.processName);
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
// 샘플 작업항목 데이터 (기획서 기준)
const workItems = [
{ no: 1, name: '원단 재단', spec: 'W2900 × H3900', qty: 9, unit: 'EA', worker: '이작업', note: '' },
{ no: 2, name: '미싱 작업', spec: 'W2800 × H3800', qty: 8, unit: 'EA', worker: '김작업', note: '' },
{ no: 3, name: '앤드락 조립', spec: 'W2700 × H3700', qty: 7, unit: 'EA', worker: '이작업', note: '' },
{ no: 4, name: '검수', spec: 'W2600 × H3600', qty: 6, unit: 'EA', worker: '김작업', note: '' },
{ no: 5, name: '포장', spec: 'W2500 × H3500', qty: 5, unit: 'EA', worker: '이작업', note: '' },
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
{/* 접근성을 위한 숨겨진 타이틀 */}
<VisuallyHidden>
<DialogTitle>{process.workLogTemplate} </DialogTitle>
</VisuallyHidden>
{/* 모달 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
<div className="flex items-center gap-3">
<span className="font-semibold text-lg">{process.workLogTemplate} </span>
<span className="text-sm text-muted-foreground">({documentCode})</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="w-4 h-4 mr-1.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOpenChange(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* 문서 본문 */}
<div className="m-6 p-6 bg-white rounded-lg shadow-sm">
{/* 문서 헤더: 로고 + 제목 + 결재라인 */}
<div className="flex border border-gray-300 mb-6">
{/* 좌측: 로고 영역 */}
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3 shrink-0">
<div className="text-2xl font-bold">KD</div>
<div className="text-xs text-gray-500"></div>
</div>
{/* 중앙: 문서 제목 */}
<div className="flex-1 border-r border-gray-300 flex flex-col items-center justify-center p-3">
<h1 className="text-xl font-bold tracking-widest mb-1"> </h1>
<p className="text-xs text-gray-500">{documentCode}</p>
<p className="text-sm font-medium mt-1">{departmentName}</p>
</div>
{/* 우측: 결재라인 */}
<div className="shrink-0">
<table className="border-collapse text-xs h-full">
<tbody>
<tr>
<td rowSpan={3} className="w-8 border-r border-gray-300 text-center align-middle bg-gray-100 font-medium">
<div></div>
<div></div>
</td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300"></td>
</tr>
<tr>
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
<div></div>
<div className="text-[10px] text-gray-500">12/17</div>
</td>
<td className="w-16 p-2 text-center border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center border-b border-gray-300"></td>
</tr>
<tr>
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">/</td>
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300"></td>
<td className="w-16 p-2 text-center bg-gray-50"></td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 신청업체 / 신청내용 테이블 */}
<table className="w-full border-collapse border border-gray-300 mb-6 text-sm">
<thead>
<tr>
<th colSpan={2} className="bg-gray-800 text-white p-2.5 font-medium border-r border-gray-300">
</th>
<th colSpan={2} className="bg-gray-800 text-white p-2.5 font-medium">
</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-gray-300">
<td className="w-24 bg-gray-100 p-3 font-medium border-r border-gray-300"> </td>
<td className="p-3 border-r border-gray-300">{today}</td>
<td className="w-24 bg-gray-100 p-3 font-medium border-r border-gray-300"> </td>
<td className="p-3"> A동</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300"> </td>
<td className="p-3 border-r border-gray-300">()</td>
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300"></td>
<td className="p-3">{today}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300"> </td>
<td className="p-3 border-r border-gray-300"></td>
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300"> LOT NO.</td>
<td className="p-3">KD-TS-251217-01-01</td>
</tr>
<tr>
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300"></td>
<td className="p-3 border-r border-gray-300">SH3040 &nbsp; W3000×H4000</td>
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300"></td>
<td className="p-3"> &nbsp; </td>
</tr>
</tbody>
</table>
{/* 작업항목 테이블 */}
<table className="w-full border-collapse border border-gray-300 text-sm">
<thead>
<tr className="bg-gray-800 text-white">
<th className="p-2.5 font-medium border-r border-gray-600 w-16"></th>
<th className="p-2.5 font-medium border-r border-gray-600"></th>
<th className="p-2.5 font-medium border-r border-gray-600 w-40"></th>
<th className="p-2.5 font-medium border-r border-gray-600 w-20"></th>
<th className="p-2.5 font-medium border-r border-gray-600 w-16"></th>
<th className="p-2.5 font-medium border-r border-gray-600 w-20"></th>
<th className="p-2.5 font-medium w-24"></th>
</tr>
</thead>
<tbody>
{workItems.map((item, index) => (
<tr key={item.no} className={index < workItems.length - 1 ? 'border-b border-gray-300' : ''}>
<td className="p-3 text-center border-r border-gray-300">{item.no}</td>
<td className="p-3 border-r border-gray-300">{item.name}</td>
<td className="p-3 text-center border-r border-gray-300">{item.spec}</td>
<td className="p-3 text-center border-r border-gray-300">{item.qty}</td>
<td className="p-3 text-center border-r border-gray-300">{item.unit}</td>
<td className="p-3 text-center border-r border-gray-300">{item.worker}</td>
<td className="p-3 text-center">{item.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Search } from 'lucide-react';
import type {
ClassificationRule,
RuleRegistrationType,
RuleType,
MatchingType,
} from '@/types/process';
import { RULE_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process';
interface RuleModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (rule: Omit<ClassificationRule, 'id' | 'createdAt'>) => void;
editRule?: ClassificationRule;
}
export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProps) {
const [registrationType, setRegistrationType] = useState<RuleRegistrationType>(
editRule?.registrationType || 'pattern'
);
const [ruleType, setRuleType] = useState<RuleType>(editRule?.ruleType || '품목코드');
const [matchingType, setMatchingType] = useState<MatchingType>(
editRule?.matchingType || 'startsWith'
);
const [conditionValue, setConditionValue] = useState(editRule?.conditionValue || '');
const [priority, setPriority] = useState(editRule?.priority || 10);
const [description, setDescription] = useState(editRule?.description || '');
const [isActive, setIsActive] = useState(editRule?.isActive ?? true);
const handleSubmit = () => {
if (!conditionValue.trim()) {
alert('조건 값을 입력해주세요.');
return;
}
onAdd({
registrationType,
ruleType,
matchingType,
conditionValue: conditionValue.trim(),
priority,
description: description.trim() || undefined,
isActive,
});
// Reset form
setRegistrationType('pattern');
setRuleType('품목코드');
setMatchingType('startsWith');
setConditionValue('');
setPriority(10);
setDescription('');
setIsActive(true);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 등록 방식 */}
<div className="space-y-3">
<Label> *</Label>
<RadioGroup
value={registrationType}
onValueChange={(v) => setRegistrationType(v as RuleRegistrationType)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pattern" id="pattern" />
<Label htmlFor="pattern" className="font-normal">
(/ )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual" className="font-normal">
( )
</Label>
</div>
</RadioGroup>
</div>
{/* 규칙 유형 */}
<div className="space-y-2">
<Label> *</Label>
<Select value={ruleType} onValueChange={(v) => setRuleType(v as RuleType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RULE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 매칭 방식 */}
<div className="space-y-2">
<Label> *</Label>
<Select
value={matchingType}
onValueChange={(v) => setMatchingType(v as MatchingType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MATCHING_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조건 값 */}
<div className="space-y-2">
<Label> *</Label>
<div className="flex gap-2">
<Input
value={conditionValue}
onChange={(e) => setConditionValue(e.target.value)}
placeholder="예: SCR-, E-, STEEL-"
className="flex-1"
/>
<Button variant="outline" size="icon">
<Search className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Enter
</p>
</div>
{/* 우선순위 */}
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
min={1}
max={100}
className="w-24"
/>
<p className="text-xs text-muted-foreground"> </p>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="규칙에 대한 설명"
/>
</div>
{/* 활성 상태 */}
<div className="flex items-center justify-between">
<Label> </Label>
<Switch checked={isActive} onCheckedChange={setIsActive} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,5 @@
export { default as ProcessListClient } from './ProcessListClient';
export { ProcessForm } from './ProcessForm';
export { ProcessDetail } from './ProcessDetail';
export { RuleModal } from './RuleModal';
export { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal';

View File

@@ -113,24 +113,39 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
{/* 우측: 결재라인 */}
<div className="shrink-0 text-xs">
{/* 첫 번째 행: 작성/검토/승인 */}
<div className="grid grid-cols-3 border-b border-gray-300">
<div className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-gray-300"></div>
<div className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-gray-300"></div>
<div className="w-16 p-2 text-center font-medium bg-gray-100"></div>
<table className="border-collapse">
<tbody>
{/* 첫 번째 행: 결재 + 작성/검토/승인 */}
<tr>
<td rowSpan={3} className="w-8 text-center font-medium bg-gray-100 border-r border-b border-gray-300 align-middle">
<div className="flex flex-col items-center">
<span></span>
<span></span>
</div>
{/* 두 번째 행: 이름 */}
<div className="grid grid-cols-3 border-b border-gray-300">
<div className="w-16 p-2 text-center border-r border-gray-300">{order.assignees[0] || '-'}</div>
<div className="w-16 p-2 text-center border-r border-gray-300"></div>
<div className="w-16 p-2 text-center"></div>
</td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300"></td>
</tr>
{/* 두 번째 행: 이름 + 날짜 */}
<tr>
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
<div>{order.assignees[0] || '-'}</div>
<div className="text-[10px] text-gray-500">
{new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', '')}
</div>
</td>
<td className="w-16 p-2 text-center border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center border-b border-gray-300"></td>
</tr>
{/* 세 번째 행: 부서 */}
<div className="grid grid-cols-3">
<div className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300"></div>
<div className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300"></div>
<div className="w-16 p-2 text-center bg-gray-50"></div>
</div>
<tr>
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">/</td>
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300"></td>
<td className="w-16 p-2 text-center bg-gray-50"></td>
</tr>
</tbody>
</table>
</div>
</div>

103
src/types/process.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* 공정관리 타입 정의
*/
// 공정 구분
export type ProcessType = '생산' | '검사' | '포장' | '조립';
// 공정 상태
export type ProcessStatus = '사용중' | '미사용';
// 자동 분류 규칙 등록 방식
export type RuleRegistrationType = 'pattern' | 'individual';
// 규칙 유형
export type RuleType = '품목코드' | '품목명' | '품목구분';
// 매칭 방식
export type MatchingType = 'startsWith' | 'endsWith' | 'contains' | 'equals';
// 자동 분류 규칙
export interface ClassificationRule {
id: string;
registrationType: RuleRegistrationType; // 패턴 규칙 or 개별 품목
ruleType: RuleType;
matchingType: MatchingType;
conditionValue: string;
priority: number;
description?: string;
isActive: boolean;
createdAt: string;
}
// 공정 기본 정보
export interface Process {
id: string;
processCode: string; // P-001, P-002 등
processName: string;
description?: string; // 공정 설명 (테이블에 표시)
processType: ProcessType; // 생산, 검사 등
department: string; // 담당부서
workLogTemplate?: string; // 작업일지 양식
// 자동 분류 규칙
classificationRules: ClassificationRule[];
// 작업 정보
requiredWorkers: number; // 필요인원
equipmentInfo?: string; // 설비정보
workSteps: string[]; // 세부 작업단계 (포밍, 검사, 포장 등)
// 설명
note?: string;
// 상태
status: ProcessStatus;
// 메타 정보
createdAt: string;
updatedAt: string;
}
// 공정 등록/수정 폼 데이터
export interface ProcessFormData {
processName: string;
processType: ProcessType;
department: string;
workLogTemplate?: string;
classificationRules: ClassificationRule[];
requiredWorkers: number;
equipmentInfo?: string;
workSteps: string; // 쉼표로 구분된 문자열
note?: string;
isActive: boolean;
}
// 공정 목록 필터
export interface ProcessFilter {
status: 'all' | '사용중' | '미사용';
search: string;
}
// 매칭 방식 옵션
export const MATCHING_TYPE_OPTIONS: { value: MatchingType; label: string }[] = [
{ value: 'startsWith', label: '~로 시작' },
{ value: 'endsWith', label: '~로 끝남' },
{ value: 'contains', label: '~를 포함' },
{ value: 'equals', label: '정확히 일치' },
];
// 규칙 유형 옵션
export const RULE_TYPE_OPTIONS: { value: RuleType; label: string }[] = [
{ value: '품목코드', label: '품목코드' },
{ value: '품목명', label: '품목명' },
{ value: '품목구분', label: '품목구분' },
];
// 공정 구분 옵션
export const PROCESS_TYPE_OPTIONS: { value: ProcessType; label: string }[] = [
{ value: '생산', label: '생산' },
{ value: '검사', label: '검사' },
{ value: '포장', label: '포장' },
{ value: '조립', label: '조립' },
];