feat(WEB): 공정 관리 UI 개선
- ProcessDetail: 공정 상세 정보 표시 개선 - ProcessForm: 공정 등록/수정 폼 유효성 검사 강화 - RuleModal: 공정 규칙 설정 모달 리팩토링
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { List, Edit, Wrench } from 'lucide-react';
|
||||
import { List, Edit, Wrench, Package } 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';
|
||||
import { MATCHING_TYPE_OPTIONS } from '@/types/process';
|
||||
|
||||
interface ProcessDetailProps {
|
||||
process: Process;
|
||||
@@ -18,6 +19,23 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
const router = useRouter();
|
||||
const [workLogModalOpen, setWorkLogModalOpen] = useState(false);
|
||||
|
||||
// 패턴 규칙과 개별 품목 분리
|
||||
const { patternRules, individualItems } = useMemo(() => {
|
||||
const patterns = process.classificationRules.filter(
|
||||
(rule) => rule.registrationType === 'pattern'
|
||||
);
|
||||
const individuals = process.classificationRules.filter(
|
||||
(rule) => rule.registrationType === 'individual'
|
||||
);
|
||||
return { patternRules: patterns, individualItems: individuals };
|
||||
}, [process.classificationRules]);
|
||||
|
||||
// 매칭 타입 라벨
|
||||
const getMatchingTypeLabel = (type: string) => {
|
||||
const option = MATCHING_TYPE_OPTIONS.find((opt) => opt.value === type);
|
||||
return option?.label || type;
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/master-data/process-management/${process.id}/edit`);
|
||||
};
|
||||
@@ -110,20 +128,23 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자동 분류 규칙 */}
|
||||
{/* 자동 분류 규칙 (패턴 기반) */}
|
||||
<Card>
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="text-base">자동 분류 규칙</CardTitle>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4" />
|
||||
자동 분류 규칙
|
||||
</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>
|
||||
{patternRules.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Wrench className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">등록된 자동 분류 규칙이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{process.classificationRules.map((rule) => (
|
||||
{patternRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
@@ -134,7 +155,51 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{rule.ruleType} - "{rule.conditionValue}"
|
||||
{rule.ruleType} · {getMatchingTypeLabel(rule.matchingType)} · "{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 flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
개별 품목
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{individualItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Package className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">등록된 개별 품목이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{individualItems.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.conditionValue}
|
||||
</div>
|
||||
{rule.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { X, Save, Plus, Wrench, Trash2, Loader2, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -22,16 +22,7 @@ import { RuleModal } from './RuleModal';
|
||||
import { toast } from 'sonner';
|
||||
import type { Process, ClassificationRule, ProcessType } from '@/types/process';
|
||||
import { PROCESS_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process';
|
||||
import { createProcess, updateProcess } from './actions';
|
||||
|
||||
// 담당부서 옵션 (추후 API 연동 가능)
|
||||
const DEPARTMENT_OPTIONS = [
|
||||
{ value: '스크린생산부서', label: '스크린생산부서' },
|
||||
{ value: '절곡생산부서', label: '절곡생산부서' },
|
||||
{ value: '슬랫생산부서', label: '슬랫생산부서' },
|
||||
{ value: '품질관리부서', label: '품질관리부서' },
|
||||
{ value: '포장/출하부서', label: '포장/출하부서' },
|
||||
];
|
||||
import { createProcess, updateProcess, getDepartmentOptions, type DepartmentOption } from './actions';
|
||||
|
||||
// 작업일지 양식 옵션 (추후 API 연동 가능)
|
||||
const WORK_LOG_OPTIONS = [
|
||||
@@ -72,10 +63,27 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
const [isActive, setIsActive] = useState(initialData ? initialData.status === '사용중' : true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 부서 목록 상태
|
||||
const [departmentOptions, setDepartmentOptions] = useState<DepartmentOption[]>([]);
|
||||
const [isDepartmentsLoading, setIsDepartmentsLoading] = useState(true);
|
||||
|
||||
// 규칙 모달 상태
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<ClassificationRule | undefined>(undefined);
|
||||
|
||||
// 부서 목록 로드
|
||||
useEffect(() => {
|
||||
const loadDepartments = async () => {
|
||||
setIsDepartmentsLoading(true);
|
||||
const result = await getDepartmentOptions();
|
||||
if (result.success && result.data) {
|
||||
setDepartmentOptions(result.data);
|
||||
}
|
||||
setIsDepartmentsLoading(false);
|
||||
};
|
||||
loadDepartments();
|
||||
}, []);
|
||||
|
||||
// 규칙 추가/수정
|
||||
const handleSaveRule = useCallback(
|
||||
(ruleData: Omit<ClassificationRule, 'id' | 'createdAt'>) => {
|
||||
@@ -242,14 +250,14 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>담당부서 *</Label>
|
||||
<Select value={department} onValueChange={setDepartment}>
|
||||
<Select value={department} onValueChange={setDepartment} disabled={isDepartmentsLoading}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
<SelectValue placeholder={isDepartmentsLoading ? "로딩 중..." : "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPARTMENT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{departmentOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={opt.name}>
|
||||
{opt.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getItemList, type ItemOption } from './actions';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -47,20 +48,6 @@ const ITEM_TYPE_OPTIONS = [
|
||||
{ value: '부자재', label: '부자재' },
|
||||
];
|
||||
|
||||
// Mock 품목 데이터 (추후 API 연동)
|
||||
const MOCK_ITEMS = [
|
||||
{ code: '스크린원단-0.3T', name: '스크린원단', type: '부자재' },
|
||||
{ code: '스크린원단-0.5T', name: '스크린원단', type: '부자재' },
|
||||
{ code: '슬랫-75mm', name: '슬랫', type: '부자재' },
|
||||
{ code: '슬랫-100mm', name: '슬랫', type: '부자재' },
|
||||
{ code: '가이드레일-60×60', name: '가이드레일', type: '부자재' },
|
||||
{ code: '가이드레일-80×80', name: '가이드레일', type: '부자재' },
|
||||
{ code: 'SCR-001', name: '스크린A', type: '제품' },
|
||||
{ code: 'SCR-002', name: '스크린B', type: '제품' },
|
||||
{ code: 'STEEL-001', name: '철판A', type: '원자재' },
|
||||
{ code: 'STEEL-002', name: '철판B', type: '원자재' },
|
||||
];
|
||||
|
||||
interface RuleModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -89,15 +76,41 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
const [selectedItemType, setSelectedItemType] = useState('all');
|
||||
const [selectedItemCodes, setSelectedItemCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
// 필터된 품목 목록
|
||||
const filteredItems = MOCK_ITEMS.filter((item) => {
|
||||
const matchesType = selectedItemType === 'all' || item.type === selectedItemType;
|
||||
const matchesSearch =
|
||||
!searchKeyword ||
|
||||
item.code.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
item.name.toLowerCase().includes(searchKeyword.toLowerCase());
|
||||
return matchesType && matchesSearch;
|
||||
});
|
||||
// 품목 목록 API 상태
|
||||
const [itemList, setItemList] = useState<ItemOption[]>([]);
|
||||
const [isItemsLoading, setIsItemsLoading] = useState(false);
|
||||
|
||||
// 품목 목록 로드 (debounced)
|
||||
const loadItems = useCallback(async (q?: string, itemType?: string) => {
|
||||
setIsItemsLoading(true);
|
||||
const result = await getItemList({
|
||||
q: q || undefined,
|
||||
itemType: itemType === 'all' ? undefined : itemType,
|
||||
size: 100,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
setItemList(result.data);
|
||||
}
|
||||
setIsItemsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 검색어/품목유형 변경 시 API 호출 (debounce)
|
||||
useEffect(() => {
|
||||
if (registrationType !== 'individual') return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
loadItems(searchKeyword, selectedItemType);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchKeyword, selectedItemType, registrationType, loadItems]);
|
||||
|
||||
// 모달 열릴 때 품목 목록 초기 로드
|
||||
useEffect(() => {
|
||||
if (open && registrationType === 'individual') {
|
||||
loadItems('', 'all');
|
||||
}
|
||||
}, [open, registrationType, loadItems]);
|
||||
|
||||
// 체크박스 토글
|
||||
const handleToggleItem = (code: string) => {
|
||||
@@ -114,7 +127,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
|
||||
// 전체 선택
|
||||
const handleSelectAll = () => {
|
||||
const allCodes = filteredItems.map((item) => item.code);
|
||||
const allCodes = itemList.map((item) => item.code);
|
||||
setSelectedItemCodes(new Set(allCodes));
|
||||
};
|
||||
|
||||
@@ -207,12 +220,12 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={registrationType === 'individual' ? 'max-w-xl' : 'max-w-md'}>
|
||||
<DialogContent className={registrationType === 'individual' ? 'max-w-xl max-h-[90vh] flex flex-col' : 'max-w-md'}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editRule ? '규칙 수정' : '규칙 추가'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-4 py-4 overflow-y-auto flex-1">
|
||||
{/* 등록 방식 */}
|
||||
<div className="space-y-3">
|
||||
<Label>등록 방식 *</Label>
|
||||
@@ -375,7 +388,11 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Package className="h-4 w-4" />
|
||||
<span>
|
||||
품목 목록 ({filteredItems.length}개) | 선택됨 ({selectedItemCodes.size}개)
|
||||
{isItemsLoading ? (
|
||||
'로딩 중...'
|
||||
) : (
|
||||
<>품목 목록 ({itemList.length}개) | 선택됨 ({selectedItemCodes.size}개)</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -385,6 +402,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs h-7"
|
||||
disabled={isItemsLoading || itemList.length === 0}
|
||||
>
|
||||
전체 선택
|
||||
</Button>
|
||||
@@ -395,6 +413,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
size="sm"
|
||||
onClick={handleResetSelection}
|
||||
className="text-xs h-7"
|
||||
disabled={selectedItemCodes.size === 0}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
@@ -413,30 +432,37 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.map((item) => (
|
||||
<TableRow
|
||||
key={item.code}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggleItem(item.code)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedItemCodes.has(item.code)}
|
||||
onCheckedChange={() => handleToggleItem(item.code)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isItemsLoading ? (
|
||||
<TableRow key="loading">
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||
품목 목록을 불러오는 중...
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<TableRow>
|
||||
) : itemList.length === 0 ? (
|
||||
<TableRow key="empty">
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||
검색 결과가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
itemList.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggleItem(item.code)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedItemCodes.has(item.code)}
|
||||
onCheckedChange={() => handleToggleItem(item.code)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.fullName}</TableCell>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
Reference in New Issue
Block a user