feat: [공정관리] React UI 트리 구조 + 공정 복제 기능
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Copy, Edit, GripVertical, Plus, Trash2 } from 'lucide-react';
|
||||
import { ArrowLeft, Copy, Edit, GripVertical, Plus, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { ReorderButtons } from '@/components/molecules';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -229,8 +229,23 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
||||
<div className="font-medium">{process.manager || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2: 구분 | 생산일자 | 상태 */}
|
||||
{/* Row 2: 부모공정 | 구분 | 생산일자 | 상태 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4 md:mt-6">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">부모 공정</div>
|
||||
{process.parentId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/ko/master-data/process-management/${process.parentId}?mode=view`)}
|
||||
className="font-medium text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
[{process.parentProcessCode}] {process.parentProcessName}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="font-medium text-muted-foreground">없음 (루트 공정)</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">구분</div>
|
||||
<div className="font-medium">{process.processCategory || '없음'}</div>
|
||||
@@ -278,6 +293,38 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 하위 공정 (자식이 있을 때만 표시) */}
|
||||
{process.children && process.children.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="bg-muted/50 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">하위 공정</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{process.children.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{process.children.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
onClick={() => router.push(`/ko/master-data/process-management/${child.id}?mode=view`)}
|
||||
className="flex items-center gap-4 px-4 py-3 cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm font-mono text-muted-foreground">{child.processCode}</span>
|
||||
<span className="text-sm font-medium flex-1">{child.processName}</span>
|
||||
<Badge variant={child.status === '사용중' ? 'default' : 'secondary'} className="text-xs">
|
||||
{child.status === '사용중' ? '사용' : '미사용'}
|
||||
</Badge>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 품목 설정 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-muted/50 py-3">
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
getDepartmentOptions,
|
||||
getProcessSteps,
|
||||
getDocumentTemplates,
|
||||
getRootProcessOptions,
|
||||
type DepartmentOption,
|
||||
type DocumentTemplateOption,
|
||||
} from './actions';
|
||||
@@ -53,6 +54,12 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
const router = useRouter();
|
||||
const isEdit = mode === 'edit';
|
||||
|
||||
// 부모 공정 상태
|
||||
const [parentId, setParentId] = useState<number | undefined>(
|
||||
initialData?.parentId ? Number(initialData.parentId) : undefined
|
||||
);
|
||||
const [rootProcessOptions, setRootProcessOptions] = useState<Array<{ id: string; processCode: string; processName: string }>>([]);
|
||||
|
||||
// 기본 정보 상태
|
||||
const [processName, setProcessName] = useState(initialData?.processName || '');
|
||||
const [processType, _setProcessType] = useState<ProcessType>(
|
||||
@@ -152,18 +159,26 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
setShowRemoveAllDialog(false);
|
||||
}, []);
|
||||
|
||||
// 부서 목록 + 문서양식 목록 로드
|
||||
// 부서 목록 + 문서양식 목록 + 루트 공정 목록 로드
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
setIsDepartmentsLoading(true);
|
||||
const [departments, templates] = await Promise.all([
|
||||
const [departments, templates, rootProcesses] = await Promise.all([
|
||||
getDepartmentOptions(),
|
||||
getDocumentTemplates(),
|
||||
getRootProcessOptions(),
|
||||
]);
|
||||
setDepartmentOptions(departments);
|
||||
if (templates.success && templates.data) {
|
||||
setDocumentTemplates(templates.data);
|
||||
}
|
||||
if (rootProcesses.success && rootProcesses.data) {
|
||||
// 수정 모드에서 자기 자신은 제외
|
||||
const filtered = isEdit && initialData?.id
|
||||
? rootProcesses.data.filter(p => p.id !== initialData.id)
|
||||
: rootProcesses.data;
|
||||
setRootProcessOptions(filtered);
|
||||
}
|
||||
setIsDepartmentsLoading(false);
|
||||
};
|
||||
loadInitialData();
|
||||
@@ -340,6 +355,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
}
|
||||
|
||||
const formData = {
|
||||
parentId: parentId || undefined,
|
||||
processName: processName.trim(),
|
||||
processType,
|
||||
processCategory: processCategory || undefined,
|
||||
@@ -459,8 +475,28 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2: 구분 | 생산일자 | 상태 */}
|
||||
{/* Row 2: 부모공정 | 구분 | 생산일자 | 상태 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4 md:mt-6">
|
||||
<div className="space-y-2">
|
||||
<Label>부모 공정</Label>
|
||||
<Select
|
||||
key={`parent-${parentId ?? 'none'}`}
|
||||
value={parentId ? String(parentId) : 'none'}
|
||||
onValueChange={(v) => setParentId(v === 'none' ? undefined : Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="없음 (루트 공정)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음 (루트 공정)</SelectItem>
|
||||
{rootProcessOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={opt.id}>
|
||||
[{opt.processCode}] {opt.processName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>구분</Label>
|
||||
<Select
|
||||
@@ -825,6 +861,8 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
</>
|
||||
),
|
||||
[
|
||||
parentId,
|
||||
rootProcessOptions,
|
||||
processName,
|
||||
processType,
|
||||
processCategory,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import { Wrench, Plus, GripVertical } from 'lucide-react';
|
||||
import { Wrench, Plus, GripVertical, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -51,6 +51,55 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
// 검색어 상태
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 트리 접기/펼치기 상태 (부모 공정 ID → expanded 여부)
|
||||
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = useCallback((parentId: string) => {
|
||||
setExpandedParents(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(parentId)) next.delete(parentId);
|
||||
else next.add(parentId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 트리 구조 정렬: 부모 바로 뒤에 자식을 배치
|
||||
const treeOrderedProcesses = useMemo(() => {
|
||||
const childrenMap = new Map<string, Process[]>();
|
||||
const roots: Process[] = [];
|
||||
|
||||
for (const p of allProcesses) {
|
||||
if (p.parentId) {
|
||||
const siblings = childrenMap.get(p.parentId) || [];
|
||||
siblings.push(p);
|
||||
childrenMap.set(p.parentId, siblings);
|
||||
} else {
|
||||
roots.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
const result: Process[] = [];
|
||||
for (const root of roots) {
|
||||
result.push(root);
|
||||
const children = childrenMap.get(root.id) || [];
|
||||
if (children.length > 0 && expandedParents.has(root.id)) {
|
||||
result.push(...children);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [allProcesses, expandedParents]);
|
||||
|
||||
// 초기 로드 시 자식이 있는 부모는 자동 펼침
|
||||
useEffect(() => {
|
||||
const parentsWithChildren = new Set<string>();
|
||||
for (const p of allProcesses) {
|
||||
if (p.parentId) parentsWithChildren.add(p.parentId);
|
||||
}
|
||||
if (parentsWithChildren.size > 0) {
|
||||
setExpandedParents(parentsWithChildren);
|
||||
}
|
||||
}, [allProcesses]);
|
||||
|
||||
// 드래그&드롭 순서 변경 상태
|
||||
const [isOrderChanged, setIsOrderChanged] = useState(false);
|
||||
const dragProcessIdRef = useRef<string | null>(null);
|
||||
@@ -425,23 +474,26 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
const itemCount = process.classificationRules
|
||||
.filter(r => r.registrationType === 'individual')
|
||||
.reduce((sum, r) => sum + (r.items?.length || 0), 0);
|
||||
const isChild = !!process.parentId;
|
||||
const hasChildren = allProcesses.some(p => p.parentId === process.id);
|
||||
const isExpanded = expandedParents.has(process.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={process.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, process.id)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleProcessDrop(e, process.id)}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
draggable={!isChild}
|
||||
onDragStart={!isChild ? (e) => handleDragStart(e, process.id) : undefined}
|
||||
onDragOver={!isChild ? handleDragOver : undefined}
|
||||
onDragLeave={!isChild ? handleDragLeave : undefined}
|
||||
onDragEnd={!isChild ? handleDragEnd : undefined}
|
||||
onDrop={!isChild ? (e) => handleProcessDrop(e, process.id) : undefined}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''} ${isChild ? 'bg-muted/20' : ''}`}
|
||||
onClick={() => handleRowClick(process)}
|
||||
>
|
||||
<TableCell
|
||||
className="text-center cursor-grab active:cursor-grabbing"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
|
||||
{!isChild && <GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />}
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
@@ -450,8 +502,31 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{process.processCode}</TableCell>
|
||||
<TableCell>{process.processName}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{isChild && <span className="text-muted-foreground mr-1">└</span>}
|
||||
{process.processCode}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`flex items-center gap-1 ${isChild ? 'pl-6' : ''}`}>
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(process.id); }}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-muted"
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
)}
|
||||
<span>{process.processName}</span>
|
||||
{hasChildren && (
|
||||
<Badge variant="outline" className="text-[10px] ml-1">
|
||||
{allProcesses.filter(p => p.parentId === process.id).length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{process.department}</TableCell>
|
||||
<TableCell className="text-center">{itemCount > 0 ? itemCount : '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
@@ -487,50 +562,53 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
const itemCount = process.classificationRules
|
||||
.filter(r => r.registrationType === 'individual')
|
||||
.reduce((sum, r) => sum + (r.items?.length || 0), 0);
|
||||
const isChild = !!process.parentId;
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={process.id}
|
||||
id={process.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleRowClick(process)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">{process.processCode}</Badge>
|
||||
</>
|
||||
}
|
||||
title={process.processName}
|
||||
statusBadge={
|
||||
<Badge
|
||||
variant={process.status === '사용중' ? 'default' : 'secondary'}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleStatus(process.id);
|
||||
}}
|
||||
>
|
||||
{process.status === '사용중' ? '사용' : '미사용'}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="담당부서" value={process.department} />
|
||||
<InfoField label="품목" value={itemCount > 0 ? `${itemCount}개` : '-'} />
|
||||
<InfoField label="중간검사" value={process.needsInspection ? '사용' : '미사용'} />
|
||||
<InfoField label="작업일지" value={process.needsWorkLog ? '사용' : '미사용'} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div key={process.id} className={isChild ? 'ml-4 border-l-2 border-muted' : ''}>
|
||||
<ListMobileCard
|
||||
id={process.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleRowClick(process)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">{process.processCode}</Badge>
|
||||
{isChild && <Badge variant="secondary" className="text-[10px]">하위</Badge>}
|
||||
</>
|
||||
}
|
||||
title={process.processName}
|
||||
statusBadge={
|
||||
<Badge
|
||||
variant={process.status === '사용중' ? 'default' : 'secondary'}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleStatus(process.id);
|
||||
}}
|
||||
>
|
||||
{process.status === '사용중' ? '사용' : '미사용'}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="담당부서" value={process.department} />
|
||||
<InfoField label="품목" value={itemCount > 0 ? `${itemCount}개` : '-'} />
|
||||
<InfoField label="중간검사" value={process.needsInspection ? '사용' : '미사용'} />
|
||||
<InfoField label="작업일지" value={process.needsWorkLog ? '사용' : '미사용'} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[handleCreate, handleRowClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery, isOrderChanged, handleSaveOrder, handleDragStart, handleDragOver, handleDragLeave, handleDragEnd, handleProcessDrop]
|
||||
[handleCreate, handleRowClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery, isOrderChanged, handleSaveOrder, handleDragStart, handleDragOver, handleDragLeave, handleDragEnd, handleProcessDrop, allProcesses, expandedParents, toggleExpand]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={allProcesses} onSearchChange={setSearchQuery} />
|
||||
<UniversalListPage config={config} initialData={treeOrderedProcesses} onSearchChange={setSearchQuery} />
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
|
||||
@@ -109,7 +109,7 @@ function transformApiToFrontend(apiData: ApiProcess): Process {
|
||||
parentId: apiData.parent_id ? String(apiData.parent_id) : undefined,
|
||||
parentProcessCode: apiData.parent?.process_code ?? undefined,
|
||||
parentProcessName: apiData.parent?.process_name ?? undefined,
|
||||
children: (apiData.children ?? []).map(transformProcessApiToFrontend),
|
||||
children: (apiData.children ?? []).map(transformApiToFrontend),
|
||||
classificationRules: [...patternRules, ...individualRules],
|
||||
requiredWorkers: apiData.required_workers,
|
||||
equipmentInfo: apiData.equipment_info ?? undefined,
|
||||
@@ -194,6 +194,7 @@ function transformFrontendToApi(data: ProcessFormData): Record<string, unknown>
|
||||
);
|
||||
|
||||
return {
|
||||
parent_id: data.parentId || null,
|
||||
process_name: data.processName,
|
||||
process_type: data.processType,
|
||||
department: data.department || null,
|
||||
@@ -426,6 +427,23 @@ export async function getProcessOptions(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 공정 목록 조회 (부모 공정 선택 드롭다운용)
|
||||
* parent_id가 null인 공정만 반환
|
||||
*/
|
||||
export async function getRootProcessOptions(): Promise<{
|
||||
success: boolean;
|
||||
data?: Array<{ id: string; processCode: string; processName: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await getProcessList({ size: 1000 });
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
const roots = result.data.items
|
||||
.filter(p => !p.parentId)
|
||||
.map(p => ({ id: p.id, processCode: p.processCode, processName: p.processName }));
|
||||
return { success: true, data: roots };
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 통계
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user