feat: 공정관리 규칙 UI 개선 및 품질인정심사시스템 경로 이동

- 자동 분류 규칙 리스트 UI 기획서대로 수정
  - 번호, 개별 품목 지정 표시, 배지, 수정/삭제 버튼
- 규칙 수정 기능 추가 (기존 품목 체크 상태 유지)
- 개별 품목 모달 UI 기획서대로 재구현
  - 검색, 품목유형 필터, 체크박스 테이블
- 품질인정심사시스템 경로 이동 (dev → quality/qms)

🤖 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-29 17:54:27 +09:00
parent c749c09dea
commit 388b113b58
20 changed files with 473 additions and 150 deletions

View File

@@ -2,7 +2,7 @@
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { X, Save, Plus, Wrench, Trash2, Loader2 } from 'lucide-react';
import { X, Save, Plus, Wrench, Trash2, Loader2, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -74,25 +74,53 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
// 규칙 모달 상태
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<ClassificationRule | undefined>(undefined);
// 규칙 추가
const handleAddRule = useCallback(
// 규칙 추가/수정
const handleSaveRule = useCallback(
(ruleData: Omit<ClassificationRule, 'id' | 'createdAt'>) => {
const newRule: ClassificationRule = {
...ruleData,
id: `rule-${Date.now()}`,
createdAt: new Date().toISOString(),
};
setClassificationRules((prev) => [...prev, newRule]);
if (editingRule) {
// 수정 모드
setClassificationRules((prev) =>
prev.map((r) =>
r.id === editingRule.id
? { ...r, ...ruleData }
: r
)
);
} else {
// 추가 모드
const newRule: ClassificationRule = {
...ruleData,
id: `rule-${Date.now()}`,
createdAt: new Date().toISOString(),
};
setClassificationRules((prev) => [...prev, newRule]);
}
setEditingRule(undefined);
},
[]
[editingRule]
);
// 규칙 수정 모달 열기
const handleEditRule = useCallback((rule: ClassificationRule) => {
setEditingRule(rule);
setRuleModalOpen(true);
}, []);
// 규칙 삭제
const handleDeleteRule = useCallback((ruleId: string) => {
setClassificationRules((prev) => prev.filter((r) => r.id !== ruleId));
}, []);
// 모달 닫기
const handleModalClose = useCallback((open: boolean) => {
setRuleModalOpen(open);
if (!open) {
setEditingRule(undefined);
}
}, []);
// 제출
const handleSubmit = async () => {
if (!processName.trim()) {
@@ -273,45 +301,83 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
</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}
{classificationRules.map((rule, index) => {
// 개별 품목인 경우 품목 개수 계산
const isIndividual = rule.registrationType === 'individual';
const itemCount = isIndividual
? rule.conditionValue.split(',').filter(Boolean).length
: 0;
return (
<div
key={rule.id}
className="flex items-start justify-between p-4 border rounded-lg"
>
<div className="flex gap-3">
{/* 번호 */}
<span className="text-muted-foreground font-medium mt-0.5">
{index + 1}.
</span>
<div className="space-y-1">
{/* 제목 */}
<div className="font-medium">
{isIndividual ? (
<> - {itemCount} </>
) : (
<>
{rule.ruleType}{' '}
{
MATCHING_TYPE_OPTIONS.find(
(o) => o.value === rule.matchingType
)?.label
}{' '}
"{rule.conditionValue}"
</>
)}
</div>
)}
{/* 뱃지 + 우선순위 */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{isIndividual
? `${itemCount}개 품목 배정됨`
: rule.isActive
? '활성'
: '비활성'}
</Badge>
<span className="text-sm text-muted-foreground">
: {rule.priority}
</span>
</div>
{/* 설명 */}
<div className="text-sm text-muted-foreground">
{isIndividual
? `직접 선택한 품목 ${itemCount}`
: rule.description || ''}
</div>
</div>
</div>
{/* 수정/삭제 버튼 */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditRule(rule)}
className="h-8 w-8"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteRule(rule.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</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>
@@ -381,11 +447,12 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
</Card>
</div>
{/* 규칙 추가 모달 */}
{/* 규칙 추가/수정 모달 */}
<RuleModal
open={ruleModalOpen}
onOpenChange={setRuleModalOpen}
onAdd={handleAddRule}
onOpenChange={handleModalClose}
onAdd={handleSaveRule}
editRule={editingRule}
/>
</PageLayout>
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -13,6 +13,7 @@ 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 { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
@@ -20,7 +21,15 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Search } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Package } from 'lucide-react';
import type {
ClassificationRule,
RuleRegistrationType,
@@ -29,6 +38,29 @@ import type {
} from '@/types/process';
import { RULE_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process';
// 품목 유형 옵션
const ITEM_TYPE_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '제품', label: '제품' },
{ value: '반제품', label: '반제품' },
{ value: '원자재', label: '원자재' },
{ 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;
@@ -37,54 +69,150 @@ interface RuleModalProps {
}
export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProps) {
// 공통 상태
const [registrationType, setRegistrationType] = useState<RuleRegistrationType>(
editRule?.registrationType || 'pattern'
);
const [description, setDescription] = useState(editRule?.description || '');
// 패턴 규칙용 상태
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;
// 개별 품목용 상태
const [searchKeyword, setSearchKeyword] = useState('');
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;
});
// 체크박스 토글
const handleToggleItem = (code: string) => {
setSelectedItemCodes((prev) => {
const newSet = new Set(prev);
if (newSet.has(code)) {
newSet.delete(code);
} else {
newSet.add(code);
}
return newSet;
});
};
// 전체 선택
const handleSelectAll = () => {
const allCodes = filteredItems.map((item) => item.code);
setSelectedItemCodes(new Set(allCodes));
};
// 초기화
const handleResetSelection = () => {
setSelectedItemCodes(new Set());
};
// 모달 열릴 때 초기화 또는 수정 데이터 로드
useEffect(() => {
if (open) {
if (editRule) {
// 수정 모드: 기존 데이터 로드
setRegistrationType(editRule.registrationType);
setDescription(editRule.description || '');
setRuleType(editRule.ruleType);
setMatchingType(editRule.matchingType);
setConditionValue(editRule.conditionValue);
setPriority(editRule.priority);
setIsActive(editRule.isActive);
setSearchKeyword('');
setSelectedItemType('all');
// 개별 품목인 경우 선택된 품목 코드 설정
if (editRule.registrationType === 'individual') {
const codes = editRule.conditionValue.split(',').filter(Boolean);
setSelectedItemCodes(new Set(codes));
} else {
setSelectedItemCodes(new Set());
}
} else {
// 추가 모드: 초기화
setRegistrationType('pattern');
setDescription('');
setRuleType('품목코드');
setMatchingType('startsWith');
setConditionValue('');
setPriority(10);
setIsActive(true);
setSearchKeyword('');
setSelectedItemType('all');
setSelectedItemCodes(new Set());
}
}
}, [open, editRule]);
const handleSubmit = () => {
if (registrationType === 'pattern') {
if (!conditionValue.trim()) {
alert('조건 값을 입력해주세요.');
return;
}
} else {
if (selectedItemCodes.size === 0) {
alert('품목을 최소 1개 이상 선택해주세요.');
return;
}
}
// 개별 품목의 경우 conditionValue에 품목코드들을 저장
const finalConditionValue =
registrationType === 'individual'
? Array.from(selectedItemCodes).join(',')
: conditionValue.trim();
onAdd({
registrationType,
ruleType,
matchingType,
conditionValue: conditionValue.trim(),
priority,
ruleType: registrationType === 'individual' ? '품목코드' : ruleType,
matchingType: registrationType === 'individual' ? 'equals' : matchingType,
conditionValue: finalConditionValue,
priority: registrationType === 'individual' ? 10 : priority,
description: description.trim() || undefined,
isActive,
isActive: registrationType === 'individual' ? true : isActive,
});
// Reset form
setRegistrationType('pattern');
setDescription('');
setRuleType('품목코드');
setMatchingType('startsWith');
setConditionValue('');
setPriority(10);
setDescription('');
setIsActive(true);
setSearchKeyword('');
setSelectedItemType('all');
setSelectedItemCodes(new Set());
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogContent className={registrationType === 'individual' ? 'max-w-xl' : 'max-w-md'}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle>{editRule ? '규칙 수정' : '규칙 추가'}</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-4 py-4">
{/* 등록 방식 */}
<div className="space-y-3">
<Label> *</Label>
@@ -107,100 +235,228 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
</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>
{/* 패턴 규칙 UI */}
{registrationType === 'pattern' && (
<>
{/* 규칙 유형 */}
<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>
<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>
<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
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="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 className="flex items-center justify-between">
<Label> </Label>
<Switch checked={isActive} onCheckedChange={setIsActive} />
</div>
</>
)}
{/* 개별 품목 UI - 기획서 기준 */}
{registrationType === 'individual' && (
<>
{/* 설명 (선택) */}
<div className="space-y-2">
<Label> ()</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="이 품목 그룹에 대한 설명"
/>
</div>
{/* 품목 검색 + 품목 유형 필터 */}
<div className="flex gap-3">
<div className="flex-1">
<Label className="text-sm"> </Label>
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="품목코드 또는 품목명으로 검색..."
className="pl-9"
/>
</div>
</div>
<div className="w-32">
<Label className="text-sm"> </Label>
<Select value={selectedItemType} onValueChange={setSelectedItemType}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 품목 목록 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<Package className="h-4 w-4" />
<span>
({filteredItems.length}) | ({selectedItemCodes.size})
</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleSelectAll}
className="text-xs h-7"
>
</Button>
<span className="text-muted-foreground">|</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleResetSelection}
className="text-xs h-7"
>
</Button>
</div>
</div>
{/* 품목 테이블 */}
<div className="border rounded-lg max-h-[280px] overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[40px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
</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()}
/>
</TableCell>
<TableCell className="font-medium">{item.code}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.type}</TableCell>
</TableRow>
))}
{filteredItems.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 안내 문구 */}
<p className="text-xs text-muted-foreground">
. .
</p>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit}></Button>
<Button onClick={handleSubmit}>{editRule ? '수정' : '추가'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -7,9 +7,9 @@ export { QuoteManagementClient } from './QuoteManagementClient';
// 기존 컴포넌트
export { default as QuoteDocument } from './QuoteDocument';
export { default as QuoteRegistration } from './QuoteRegistration';
export { default as QuoteCalculationReport } from './QuoteCalculationReport';
export { default as PurchaseOrderDocument } from './PurchaseOrderDocument';
export { QuoteRegistration, INITIAL_QUOTE_FORM } from './QuoteRegistration';
export { QuoteCalculationReport } from './QuoteCalculationReport';
export { PurchaseOrderDocument } from './PurchaseOrderDocument';
// 타입
export type {