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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -109,3 +109,7 @@ playwright.config.ts
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
.playwright/
|
.playwright/
|
||||||
|
|
||||||
|
# 로컬 테스트/개발용 폴더
|
||||||
|
src/app/\[locale\]/(protected)/dev/
|
||||||
|
src/components/common/EditableTable/
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} 공정 정보`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 공정 등록 페이지
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ProcessForm } from '@/components/process-management';
|
||||||
|
|
||||||
|
export default function CreateProcessPage() {
|
||||||
|
return <ProcessForm mode="create" />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/components/molecules/MobileCard.tsx
Normal file
99
src/components/molecules/MobileCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
src/components/process-management/ProcessDetail.tsx
Normal file
217
src/components/process-management/ProcessDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
359
src/components/process-management/ProcessForm.tsx
Normal file
359
src/components/process-management/ProcessForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
src/components/process-management/ProcessListClient.tsx
Normal file
324
src/components/process-management/ProcessListClient.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/components/process-management/ProcessWorkLogPreviewModal.tsx
Normal file
219
src/components/process-management/ProcessWorkLogPreviewModal.tsx
Normal 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 방화셔터 W3000×H4000</td>
|
||||||
|
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">마감유형</td>
|
||||||
|
<td className="p-3">스크린 그레이</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/components/process-management/RuleModal.tsx
Normal file
206
src/components/process-management/RuleModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/process-management/index.ts
Normal file
5
src/components/process-management/index.ts
Normal 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';
|
||||||
@@ -113,24 +113,39 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
|
|||||||
|
|
||||||
{/* 우측: 결재라인 */}
|
{/* 우측: 결재라인 */}
|
||||||
<div className="shrink-0 text-xs">
|
<div className="shrink-0 text-xs">
|
||||||
{/* 첫 번째 행: 작성/검토/승인 */}
|
<table className="border-collapse">
|
||||||
<div className="grid grid-cols-3 border-b border-gray-300">
|
<tbody>
|
||||||
<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>
|
<tr>
|
||||||
<div className="w-16 p-2 text-center font-medium bg-gray-100">승인</div>
|
<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>
|
||||||
{/* 두 번째 행: 이름 */}
|
</td>
|
||||||
<div className="grid grid-cols-3 border-b border-gray-300">
|
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">작성</td>
|
||||||
<div className="w-16 p-2 text-center border-r border-gray-300">{order.assignees[0] || '-'}</div>
|
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">검토</td>
|
||||||
<div className="w-16 p-2 text-center border-r border-gray-300"></div>
|
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300">승인</td>
|
||||||
<div className="w-16 p-2 text-center"></div>
|
</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>
|
</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">
|
<tr>
|
||||||
<div className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">판매</div>
|
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">판매/전진</td>
|
||||||
<div className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">생산</div>
|
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">생산</td>
|
||||||
<div className="w-16 p-2 text-center bg-gray-50">품질</div>
|
<td className="w-16 p-2 text-center bg-gray-50">품질</td>
|
||||||
</div>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
103
src/types/process.ts
Normal file
103
src/types/process.ts
Normal 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: '조립' },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user