feat: [공정관리] React UI 트리 구조 + 공정 복제 기능

This commit is contained in:
김보곤
2026-03-21 21:20:43 +09:00
parent 59b45dc706
commit 2792cce733
4 changed files with 234 additions and 53 deletions

View File

@@ -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">

View File

@@ -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,

View File

@@ -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

View File

@@ -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 };
}
/**
* 공정 통계
*/