refactor: Update ProcessWorkStandard component to manage work item selection by phase

- Removed the "정보조회" option from the default configuration.
- Refactored the ProcessWorkStandardComponent to handle work item selection independently for each phase.
- Updated the WorkPhaseSection to pass phase-specific parameters for work item selection and detail management.
- Enhanced the useProcessWorkStandard hook to maintain separate states for selected work items and details by phase, improving data handling and user experience.
This commit is contained in:
kjs 2026-02-26 20:49:25 +09:00
parent 2335a413cb
commit 38ade7562e
6 changed files with 154 additions and 54 deletions

View File

@ -42,8 +42,8 @@ export function ProcessWorkStandardComponent({
items, items,
routings, routings,
workItems, workItems,
selectedWorkItemDetails, selectedWorkItemIdByPhase,
selectedWorkItemId, selectedDetailsByPhase,
selection, selection,
loading, loading,
fetchItems, fetchItems,
@ -105,8 +105,8 @@ export function ProcessWorkStandardComponent({
); );
const handleSelectWorkItem = useCallback( const handleSelectWorkItem = useCallback(
(workItemId: string) => { (workItemId: string, phaseKey: string) => {
fetchWorkItemDetails(workItemId); fetchWorkItemDetails(workItemId, phaseKey);
}, },
[fetchWorkItemDetails] [fetchWorkItemDetails]
); );
@ -191,8 +191,8 @@ export function ProcessWorkStandardComponent({
key={phase.key} key={phase.key}
phase={phase} phase={phase}
items={workItemsByPhase[phase.key] || []} items={workItemsByPhase[phase.key] || []}
selectedWorkItemId={selectedWorkItemId} selectedWorkItemId={selectedWorkItemIdByPhase[phase.key] || null}
selectedWorkItemDetails={selectedWorkItemDetails} selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
detailTypes={config.detailTypes} detailTypes={config.detailTypes}
readonly={config.readonly} readonly={config.readonly}
onSelectWorkItem={handleSelectWorkItem} onSelectWorkItem={handleSelectWorkItem}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -61,11 +61,24 @@ export function WorkItemAddModal({
detailTypes, detailTypes,
editItem, editItem,
}: WorkItemAddModalProps) { }: WorkItemAddModalProps) {
const [title, setTitle] = useState(editItem?.title || ""); const [title, setTitle] = useState("");
const [isRequired, setIsRequired] = useState(editItem?.is_required || "Y"); const [isRequired, setIsRequired] = useState("Y");
const [description, setDescription] = useState(editItem?.description || ""); const [description, setDescription] = useState("");
const [details, setDetails] = useState<ModalDetail[]>([]); const [details, setDetails] = useState<ModalDetail[]>([]);
useEffect(() => {
if (open && editItem) {
setTitle(editItem.title || "");
setIsRequired(editItem.is_required || "Y");
setDescription(editItem.description || "");
} else if (open && !editItem) {
setTitle("");
setIsRequired("Y");
setDescription("");
setDetails([]);
}
}, [open, editItem]);
const resetForm = () => { const resetForm = () => {
setTitle(""); setTitle("");
setIsRequired("Y"); setIsRequired("Y");

View File

@ -20,13 +20,13 @@ interface WorkPhaseSectionProps {
selectedWorkItemDetails: WorkItemDetail[]; selectedWorkItemDetails: WorkItemDetail[];
detailTypes: DetailTypeDefinition[]; detailTypes: DetailTypeDefinition[];
readonly?: boolean; readonly?: boolean;
onSelectWorkItem: (workItemId: string) => void; onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
onAddWorkItem: (phase: string) => void; onAddWorkItem: (phase: string) => void;
onEditWorkItem: (item: WorkItem) => void; onEditWorkItem: (item: WorkItem) => void;
onDeleteWorkItem: (id: string) => void; onDeleteWorkItem: (id: string) => void;
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>) => void; onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void; onUpdateDetail: (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
onDeleteDetail: (id: string) => void; onDeleteDetail: (id: string, phaseKey: string) => void;
} }
export function WorkPhaseSection({ export function WorkPhaseSection({
@ -45,9 +45,6 @@ export function WorkPhaseSection({
onDeleteDetail, onDeleteDetail,
}: WorkPhaseSectionProps) { }: WorkPhaseSectionProps) {
const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null; const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null;
const isThisSectionSelected = items.some(
(i) => i.id === selectedWorkItemId
);
return ( return (
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card">
@ -94,7 +91,7 @@ export function WorkPhaseSection({
item={item} item={item}
isSelected={selectedWorkItemId === item.id} isSelected={selectedWorkItemId === item.id}
readonly={readonly} readonly={readonly}
onClick={() => onSelectWorkItem(item.id)} onClick={() => onSelectWorkItem(item.id, phase.key)}
onEdit={() => onEditWorkItem(item)} onEdit={() => onEditWorkItem(item)}
onDelete={() => onDeleteWorkItem(item.id)} onDelete={() => onDeleteWorkItem(item.id)}
/> />
@ -106,15 +103,15 @@ export function WorkPhaseSection({
{/* 우측: 상세 리스트 */} {/* 우측: 상세 리스트 */}
<div className="flex-1"> <div className="flex-1">
<WorkItemDetailList <WorkItemDetailList
workItem={isThisSectionSelected ? selectedItem : null} workItem={selectedItem}
details={isThisSectionSelected ? selectedWorkItemDetails : []} details={selectedWorkItemDetails}
detailTypes={detailTypes} detailTypes={detailTypes}
readonly={readonly} readonly={readonly}
onCreateDetail={(data) => onCreateDetail={(data) =>
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data) selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
} }
onUpdateDetail={onUpdateDetail} onUpdateDetail={(id, data) => onUpdateDetail(id, data, phase.key)}
onDeleteDetail={onDeleteDetail} onDeleteDetail={(id) => onDeleteDetail(id, phase.key)}
/> />
</div> </div>
</div> </div>

View File

@ -27,7 +27,6 @@ export const defaultConfig: ProcessWorkStandardConfig = {
{ value: "inspect", label: "검사항목" }, { value: "inspect", label: "검사항목" },
{ value: "procedure", label: "작업절차" }, { value: "procedure", label: "작업절차" },
{ value: "input", label: "직접입력" }, { value: "input", label: "직접입력" },
{ value: "info", label: "정보조회" },
], ],
splitRatio: 30, splitRatio: 30,
leftPanelTitle: "품목 및 공정 선택", leftPanelTitle: "품목 및 공정 선택",

View File

@ -17,8 +17,9 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const [items, setItems] = useState<ItemData[]>([]); const [items, setItems] = useState<ItemData[]>([]);
const [routings, setRoutings] = useState<RoutingVersion[]>([]); const [routings, setRoutings] = useState<RoutingVersion[]>([]);
const [workItems, setWorkItems] = useState<WorkItem[]>([]); const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState<WorkItemDetail[]>([]); // 섹션(phase)별 독립적인 선택 상태 관리
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null); const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState<Record<string, string | null>>({});
const [selectedDetailsByPhase, setSelectedDetailsByPhase] = useState<Record<string, WorkItemDetail[]>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -101,15 +102,15 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
} }
}, []); }, []);
// 작업 항목 상세 조회 // 작업 항목 상세 조회 (phase별 독립 저장)
const fetchWorkItemDetails = useCallback(async (workItemId: string) => { const fetchWorkItemDetails = useCallback(async (workItemId: string, phaseKey: string) => {
try { try {
const res = await apiClient.get( const res = await apiClient.get(
`${API_BASE}/work-items/${workItemId}/details` `${API_BASE}/work-items/${workItemId}/details`
); );
if (res.data?.success) { if (res.data?.success) {
setSelectedWorkItemDetails(res.data.data); setSelectedDetailsByPhase(prev => ({ ...prev, [phaseKey]: res.data.data }));
setSelectedWorkItemId(workItemId); setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: workItemId }));
} }
} catch (err) { } catch (err) {
console.error("상세 조회 실패", err); console.error("상세 조회 실패", err);
@ -129,8 +130,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
processName: null, processName: null,
})); }));
setWorkItems([]); setWorkItems([]);
setSelectedWorkItemDetails([]); setSelectedDetailsByPhase({});
setSelectedWorkItemId(null); setSelectedWorkItemIdByPhase({});
await fetchRoutings(itemCode); await fetchRoutings(itemCode);
}, },
[fetchRoutings] [fetchRoutings]
@ -151,8 +152,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
routingDetailId, routingDetailId,
processName, processName,
})); }));
setSelectedWorkItemDetails([]); setSelectedDetailsByPhase({});
setSelectedWorkItemId(null); setSelectedWorkItemIdByPhase({});
await fetchWorkItems(routingDetailId); await fetchWorkItems(routingDetailId);
}, },
[fetchWorkItems] [fetchWorkItems]
@ -233,28 +234,43 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const res = await apiClient.delete(`${API_BASE}/work-items/${id}`); const res = await apiClient.delete(`${API_BASE}/work-items/${id}`);
if (res.data?.success && selection.routingDetailId) { if (res.data?.success && selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId); await fetchWorkItems(selection.routingDetailId);
if (selectedWorkItemId === id) { // 삭제된 항목이 선택되어 있던 phase의 선택 상태 초기화
setSelectedWorkItemDetails([]); setSelectedWorkItemIdByPhase(prev => {
setSelectedWorkItemId(null); const next = { ...prev };
for (const phaseKey of Object.keys(next)) {
if (next[phaseKey] === id) {
next[phaseKey] = null;
} }
} }
return next;
});
setSelectedDetailsByPhase(prev => {
const next = { ...prev };
for (const phaseKey of Object.keys(next)) {
if (selectedWorkItemIdByPhase[phaseKey] === id) {
next[phaseKey] = [];
}
}
return next;
});
}
} catch (err) { } catch (err) {
console.error("작업 항목 삭제 실패", err); console.error("작업 항목 삭제 실패", err);
} }
}, },
[selection.routingDetailId, selectedWorkItemId, fetchWorkItems] [selection.routingDetailId, selectedWorkItemIdByPhase, fetchWorkItems]
); );
// 상세 추가 // 상세 추가
const createDetail = useCallback( const createDetail = useCallback(
async (workItemId: string, data: Partial<WorkItemDetail>) => { async (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
try { try {
const res = await apiClient.post(`${API_BASE}/work-item-details`, { const res = await apiClient.post(`${API_BASE}/work-item-details`, {
work_item_id: workItemId, work_item_id: workItemId,
...data, ...data,
}); });
if (res.data?.success) { if (res.data?.success) {
await fetchWorkItemDetails(workItemId); await fetchWorkItemDetails(workItemId, phaseKey);
if (selection.routingDetailId) { if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId); await fetchWorkItems(selection.routingDetailId);
} }
@ -268,32 +284,36 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
// 상세 수정 // 상세 수정
const updateDetail = useCallback( const updateDetail = useCallback(
async (id: string, data: Partial<WorkItemDetail>) => { async (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
try { try {
const res = await apiClient.put( const res = await apiClient.put(
`${API_BASE}/work-item-details/${id}`, `${API_BASE}/work-item-details/${id}`,
data data
); );
if (res.data?.success && selectedWorkItemId) { if (res.data?.success) {
await fetchWorkItemDetails(selectedWorkItemId); const workItemId = selectedWorkItemIdByPhase[phaseKey];
if (workItemId) {
await fetchWorkItemDetails(workItemId, phaseKey);
}
} }
} catch (err) { } catch (err) {
console.error("상세 수정 실패", err); console.error("상세 수정 실패", err);
} }
}, },
[selectedWorkItemId, fetchWorkItemDetails] [selectedWorkItemIdByPhase, fetchWorkItemDetails]
); );
// 상세 삭제 // 상세 삭제
const deleteDetail = useCallback( const deleteDetail = useCallback(
async (id: string) => { async (id: string, phaseKey: string) => {
try { try {
const res = await apiClient.delete( const res = await apiClient.delete(
`${API_BASE}/work-item-details/${id}` `${API_BASE}/work-item-details/${id}`
); );
if (res.data?.success) { if (res.data?.success) {
if (selectedWorkItemId) { const workItemId = selectedWorkItemIdByPhase[phaseKey];
await fetchWorkItemDetails(selectedWorkItemId); if (workItemId) {
await fetchWorkItemDetails(workItemId, phaseKey);
} }
if (selection.routingDetailId) { if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId); await fetchWorkItems(selection.routingDetailId);
@ -304,7 +324,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
} }
}, },
[ [
selectedWorkItemId, selectedWorkItemIdByPhase,
selection.routingDetailId, selection.routingDetailId,
fetchWorkItemDetails, fetchWorkItemDetails,
fetchWorkItems, fetchWorkItems,
@ -315,8 +335,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
items, items,
routings, routings,
workItems, workItems,
selectedWorkItemDetails, selectedWorkItemIdByPhase,
selectedWorkItemId, selectedDetailsByPhase,
selection, selection,
loading, loading,
saving, saving,
@ -325,7 +345,6 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
selectProcess, selectProcess,
fetchWorkItems, fetchWorkItems,
fetchWorkItemDetails, fetchWorkItemDetails,
setSelectedWorkItemId,
createWorkItem, createWorkItem,
updateWorkItem, updateWorkItem,
deleteWorkItem, deleteWorkItem,

View File

@ -3361,6 +3361,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})); }));
// 🔧 그룹화된 데이터 렌더링 // 🔧 그룹화된 데이터 렌더링
const hasGroupedLeftActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
(componentConfig.leftPanel?.showDelete !== false)
);
if (groupedLeftData.length > 0) { if (groupedLeftData.length > 0) {
return ( return (
<div className="overflow-auto"> <div className="overflow-auto">
@ -3385,6 +3389,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label} {col.label}
</th> </th>
))} ))}
{hasGroupedLeftActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
@ -3399,7 +3407,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr <tr
key={itemId} key={itemId}
onClick={() => handleLeftItemSelect(item)} onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${ className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : "" isSelected ? "bg-primary/10" : ""
}`} }`}
> >
@ -3417,6 +3425,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)} )}
</td> </td>
))} ))}
{hasGroupedLeftActions && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
>
<Pencil className="h-3.5 w-3.5 text-gray-500" />
</button>
)}
{(componentConfig.leftPanel?.showDelete !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</button>
)}
</div>
</td>
)}
</tr> </tr>
); );
})} })}
@ -3429,6 +3465,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
// 🔧 일반 테이블 렌더링 (그룹화 없음) // 🔧 일반 테이블 렌더링 (그룹화 없음)
const hasLeftTableActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
(componentConfig.leftPanel?.showDelete !== false)
);
return ( return (
<div className="overflow-auto"> <div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
@ -3447,6 +3487,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label} {col.label}
</th> </th>
))} ))}
{hasLeftTableActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
@ -3461,7 +3505,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr <tr
key={itemId} key={itemId}
onClick={() => handleLeftItemSelect(item)} onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${ className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : "" isSelected ? "bg-primary/10" : ""
}`} }`}
> >
@ -3479,6 +3523,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)} )}
</td> </td>
))} ))}
{hasLeftTableActions && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
>
<Pencil className="h-3.5 w-3.5 text-gray-500" />
</button>
)}
{(componentConfig.leftPanel?.showDelete !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</button>
)}
</div>
</td>
)}
</tr> </tr>
); );
})} })}