jskim-node #396

Merged
kjs merged 44 commits from jskim-node into main 2026-02-28 14:37:11 +09:00
6 changed files with 154 additions and 54 deletions
Showing only changes of commit 38ade7562e - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

@ -17,8 +17,9 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const [items, setItems] = useState<ItemData[]>([]);
const [routings, setRoutings] = useState<RoutingVersion[]>([]);
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState<WorkItemDetail[]>([]);
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
// 섹션(phase)별 독립적인 선택 상태 관리
const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState<Record<string, string | null>>({});
const [selectedDetailsByPhase, setSelectedDetailsByPhase] = useState<Record<string, WorkItemDetail[]>>({});
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@ -101,15 +102,15 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
}
}, []);
// 작업 항목 상세 조회
const fetchWorkItemDetails = useCallback(async (workItemId: string) => {
// 작업 항목 상세 조회 (phase별 독립 저장)
const fetchWorkItemDetails = useCallback(async (workItemId: string, phaseKey: string) => {
try {
const res = await apiClient.get(
`${API_BASE}/work-items/${workItemId}/details`
);
if (res.data?.success) {
setSelectedWorkItemDetails(res.data.data);
setSelectedWorkItemId(workItemId);
setSelectedDetailsByPhase(prev => ({ ...prev, [phaseKey]: res.data.data }));
setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: workItemId }));
}
} catch (err) {
console.error("상세 조회 실패", err);
@ -129,8 +130,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
processName: null,
}));
setWorkItems([]);
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
setSelectedDetailsByPhase({});
setSelectedWorkItemIdByPhase({});
await fetchRoutings(itemCode);
},
[fetchRoutings]
@ -151,8 +152,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
routingDetailId,
processName,
}));
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
setSelectedDetailsByPhase({});
setSelectedWorkItemIdByPhase({});
await fetchWorkItems(routingDetailId);
},
[fetchWorkItems]
@ -233,28 +234,43 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const res = await apiClient.delete(`${API_BASE}/work-items/${id}`);
if (res.data?.success && selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
if (selectedWorkItemId === id) {
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
// 삭제된 항목이 선택되어 있던 phase의 선택 상태 초기화
setSelectedWorkItemIdByPhase(prev => {
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) {
console.error("작업 항목 삭제 실패", err);
}
},
[selection.routingDetailId, selectedWorkItemId, fetchWorkItems]
[selection.routingDetailId, selectedWorkItemIdByPhase, fetchWorkItems]
);
// 상세 추가
const createDetail = useCallback(
async (workItemId: string, data: Partial<WorkItemDetail>) => {
async (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
try {
const res = await apiClient.post(`${API_BASE}/work-item-details`, {
work_item_id: workItemId,
...data,
});
if (res.data?.success) {
await fetchWorkItemDetails(workItemId);
await fetchWorkItemDetails(workItemId, phaseKey);
if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
}
@ -268,32 +284,36 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
// 상세 수정
const updateDetail = useCallback(
async (id: string, data: Partial<WorkItemDetail>) => {
async (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
try {
const res = await apiClient.put(
`${API_BASE}/work-item-details/${id}`,
data
);
if (res.data?.success && selectedWorkItemId) {
await fetchWorkItemDetails(selectedWorkItemId);
if (res.data?.success) {
const workItemId = selectedWorkItemIdByPhase[phaseKey];
if (workItemId) {
await fetchWorkItemDetails(workItemId, phaseKey);
}
}
} catch (err) {
console.error("상세 수정 실패", err);
}
},
[selectedWorkItemId, fetchWorkItemDetails]
[selectedWorkItemIdByPhase, fetchWorkItemDetails]
);
// 상세 삭제
const deleteDetail = useCallback(
async (id: string) => {
async (id: string, phaseKey: string) => {
try {
const res = await apiClient.delete(
`${API_BASE}/work-item-details/${id}`
);
if (res.data?.success) {
if (selectedWorkItemId) {
await fetchWorkItemDetails(selectedWorkItemId);
const workItemId = selectedWorkItemIdByPhase[phaseKey];
if (workItemId) {
await fetchWorkItemDetails(workItemId, phaseKey);
}
if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
@ -304,7 +324,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
}
},
[
selectedWorkItemId,
selectedWorkItemIdByPhase,
selection.routingDetailId,
fetchWorkItemDetails,
fetchWorkItems,
@ -315,8 +335,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
items,
routings,
workItems,
selectedWorkItemDetails,
selectedWorkItemId,
selectedWorkItemIdByPhase,
selectedDetailsByPhase,
selection,
loading,
saving,
@ -325,7 +345,6 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
selectProcess,
fetchWorkItems,
fetchWorkItemDetails,
setSelectedWorkItemId,
createWorkItem,
updateWorkItem,
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) {
return (
<div className="overflow-auto">
@ -3385,6 +3389,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label}
</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>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
@ -3399,7 +3407,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
@ -3417,6 +3425,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
</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>
);
})}
@ -3429,6 +3465,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
// 🔧 일반 테이블 렌더링 (그룹화 없음)
const hasLeftTableActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
(componentConfig.leftPanel?.showDelete !== false)
);
return (
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
@ -3447,6 +3487,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label}
</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>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
@ -3461,7 +3505,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
@ -3479,6 +3523,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
</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>
);
})}