jskim-node #394

Merged
kjs merged 18 commits from jskim-node into main 2026-02-26 13:48:08 +09:00
13 changed files with 1084 additions and 347 deletions
Showing only changes of commit cb4fa2aaba - Show all commits

View File

@ -39,16 +39,18 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
if (search) params.push(`%${search}%`); if (search) params.push(`%${search}%`);
const query = ` const query = `
SELECT DISTINCT SELECT
i.id, i.id,
i.${nameColumn} AS item_name, i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code i.${codeColumn} AS item_code,
COUNT(rv.id) AS routing_count
FROM ${tableName} i FROM ${tableName} i
INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code AND rv.company_code = i.company_code
WHERE i.company_code = $1 WHERE i.company_code = $1
${searchCondition} ${searchCondition}
ORDER BY i.${codeColumn} GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
ORDER BY i.created_date DESC NULLS LAST
`; `;
const result = await getPool().query(query, params); const result = await getPool().query(query, params);
@ -82,10 +84,10 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
// 라우팅 버전 목록 // 라우팅 버전 목록
const versionsQuery = ` const versionsQuery = `
SELECT id, version_name, description, created_date SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
FROM ${routingVersionTable} FROM ${routingVersionTable}
WHERE ${routingFkColumn} = $1 AND company_code = $2 WHERE ${routingFkColumn} = $1 AND company_code = $2
ORDER BY created_date DESC ORDER BY is_default DESC, created_date DESC
`; `;
const versionsResult = await getPool().query(versionsQuery, [ const versionsResult = await getPool().query(versionsQuery, [
itemCode, itemCode,
@ -127,6 +129,92 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
} }
} }
// ============================================================
// 기본 버전 설정
// ============================================================
/**
*
*
*/
export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { versionId } = req.params;
const {
routingVersionTable = "item_routing_version",
routingFkColumn = "item_code",
} = req.body;
await client.query("BEGIN");
const versionResult = await client.query(
`SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
if (versionResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
}
const itemCode = versionResult.rows[0].item_code;
await client.query(
`UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`,
[itemCode, companyCode]
);
await client.query(
`UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
await client.query("COMMIT");
logger.info("기본 버전 설정", { companyCode, versionId, itemCode });
return res.json({ success: true, message: "기본 버전이 설정되었습니다" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("기본 버전 설정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
/**
*
*/
export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { versionId } = req.params;
const { routingVersionTable = "item_routing_version" } = req.body;
await getPool().query(
`UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
logger.info("기본 버전 해제", { companyCode, versionId });
return res.json({ success: true, message: "기본 버전이 해제되었습니다" });
} catch (error: any) {
logger.error("기본 버전 해제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================ // ============================================================
// 작업 항목 CRUD // 작업 항목 CRUD
// ============================================================ // ============================================================
@ -330,7 +418,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
const { workItemId } = req.params; const { workItemId } = req.params;
const query = ` const query = `
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
created_date
FROM process_work_item_detail FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2 WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date ORDER BY sort_order, created_date
@ -355,7 +446,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
return res.status(401).json({ success: false, message: "인증 필요" }); return res.status(401).json({ success: false, message: "인증 필요" });
} }
const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body; const {
work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
} = req.body;
if (!work_item_id || !content) { if (!work_item_id || !content) {
return res.status(400).json({ return res.status(400).json({
@ -375,8 +470,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
const query = ` const query = `
INSERT INTO process_work_item_detail INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING * RETURNING *
`; `;
@ -389,6 +486,15 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
sort_order || 0, sort_order || 0,
remark || null, remark || null,
writer, writer,
inspection_code || null,
inspection_method || null,
unit || null,
lower_limit || null,
upper_limit || null,
duration_minutes || null,
input_type || null,
lookup_target || null,
display_fields || null,
]); ]);
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id }); logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
@ -410,7 +516,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
} }
const { id } = req.params; const { id } = req.params;
const { detail_type, content, is_required, sort_order, remark } = req.body; const {
detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
} = req.body;
const query = ` const query = `
UPDATE process_work_item_detail UPDATE process_work_item_detail
@ -419,6 +529,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
is_required = COALESCE($3, is_required), is_required = COALESCE($3, is_required),
sort_order = COALESCE($4, sort_order), sort_order = COALESCE($4, sort_order),
remark = COALESCE($5, remark), remark = COALESCE($5, remark),
inspection_code = $8,
inspection_method = $9,
unit = $10,
lower_limit = $11,
upper_limit = $12,
duration_minutes = $13,
input_type = $14,
lookup_target = $15,
display_fields = $16,
updated_date = NOW() updated_date = NOW()
WHERE id = $6 AND company_code = $7 WHERE id = $6 AND company_code = $7
RETURNING * RETURNING *
@ -432,6 +551,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
remark, remark,
id, id,
companyCode, companyCode,
inspection_code || null,
inspection_method || null,
unit || null,
lower_limit || null,
upper_limit || null,
duration_minutes || null,
input_type || null,
lookup_target || null,
display_fields || null,
]); ]);
if (result.rowCount === 0) { if (result.rowCount === 0) {
@ -544,8 +672,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
for (const detail of item.details) { for (const detail of item.details) {
await client.query( await client.query(
`INSERT INTO process_work_item_detail `INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[ [
companyCode, companyCode,
workItemId, workItemId,
@ -555,6 +685,15 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
detail.sort_order || 0, detail.sort_order || 0,
detail.remark || null, detail.remark || null,
writer, writer,
detail.inspection_code || null,
detail.inspection_method || null,
detail.unit || null,
detail.lower_limit || null,
detail.upper_limit || null,
detail.duration_minutes || null,
detail.input_type || null,
detail.lookup_target || null,
detail.display_fields || null,
] ]
); );
} }

View File

@ -14,6 +14,10 @@ router.use(authenticateToken);
router.get("/items", ctrl.getItemsWithRouting); router.get("/items", ctrl.getItemsWithRouting);
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
// 기본 버전 설정/해제
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
// 작업 항목 CRUD // 작업 항목 CRUD
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems); router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
router.post("/work-items", ctrl.createWorkItem); router.post("/work-items", ctrl.createWorkItem);

View File

@ -2534,14 +2534,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{group.items.map((item, idx) => { {group.items.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || idx; const itemId = item[sourceColumn] || item.id || item.ID;
const isSelected = const isSelected =
selectedLeftItem && selectedLeftItem &&
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return ( return (
<tr <tr
key={itemId} key={itemId != null ? `${itemId}-${idx}` : idx}
onClick={() => handleLeftItemSelect(item)} onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${ className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : "" isSelected ? "bg-primary/10" : ""
@ -2596,14 +2596,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => { {filteredData.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || idx; const itemId = item[sourceColumn] || item.id || item.ID;
const isSelected = const isSelected =
selectedLeftItem && selectedLeftItem &&
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return ( return (
<tr <tr
key={itemId} key={itemId != null ? `${itemId}-${idx}` : idx}
onClick={() => handleLeftItemSelect(item)} onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${ className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : "" isSelected ? "bg-primary/10" : ""
@ -2698,7 +2698,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 재귀 렌더링 함수 // 재귀 렌더링 함수
const renderTreeItem = (item: any, index: number): React.ReactNode => { const renderTreeItem = (item: any, index: number): React.ReactNode => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || index; const rawItemId = item[sourceColumn] || item.id || item.ID;
const itemId = rawItemId != null ? rawItemId : index;
const isSelected = const isSelected =
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
@ -2749,7 +2750,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const displaySubtitle = displayFields[1]?.value || null; const displaySubtitle = displayFields[1]?.value || null;
return ( return (
<React.Fragment key={itemId}> <React.Fragment key={`${itemId}-${index}`}>
{/* 현재 항목 */} {/* 현재 항목 */}
<div <div
className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${ className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${
@ -3081,7 +3082,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{currentTabData.map((item: any, idx: number) => { {currentTabData.map((item: any, idx: number) => {
const itemId = item.id || idx; const itemId = item.id ?? idx;
const isExpanded = expandedRightItems.has(itemId); const isExpanded = expandedRightItems.has(itemId);
// 표시할 컬럼 결정 // 표시할 컬럼 결정
@ -3097,7 +3098,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const detailColumns = columnsToShow.slice(summaryCount); const detailColumns = columnsToShow.slice(summaryCount);
return ( return (
<div key={itemId} className="rounded-lg border bg-white p-3"> <div key={`${itemId}-${idx}`} className="rounded-lg border bg-white p-3">
<div <div
className="flex cursor-pointer items-start justify-between" className="flex cursor-pointer items-start justify-between"
onClick={() => toggleRightItemExpansion(itemId)} onClick={() => toggleRightItemExpansion(itemId)}
@ -3287,10 +3288,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => { {filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx; const itemId = item.id || item.ID;
return ( return (
<tr key={itemId} className="hover:bg-accent transition-colors"> <tr key={itemId != null ? `${itemId}-${idx}` : idx} className="hover:bg-accent transition-colors">
{columnsToShow.map((col, colIdx) => ( {columnsToShow.map((col, colIdx) => (
<td <td
key={colIdx} key={colIdx}
@ -3404,7 +3405,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return ( return (
<div <div
key={itemId} key={`${itemId}-${index}`}
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md" className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
> >
{/* 요약 정보 */} {/* 요약 정보 */}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Search, Plus, Trash2, Edit, ListOrdered, Package } from "lucide-react"; import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } 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";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -51,6 +51,8 @@ export function ItemRoutingComponent({
refreshDetails, refreshDetails,
deleteDetail, deleteDetail,
deleteVersion, deleteVersion,
setDefaultVersion,
unsetDefaultVersion,
} = useItemRouting(configProp || {}); } = useItemRouting(configProp || {});
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
@ -70,16 +72,21 @@ export function ItemRoutingComponent({
}, [fetchItems]); }, [fetchItems]);
// 모달 저장 성공 감지 -> 데이터 새로고침 // 모달 저장 성공 감지 -> 데이터 새로고침
const refreshVersionsRef = React.useRef(refreshVersions);
const refreshDetailsRef = React.useRef(refreshDetails);
refreshVersionsRef.current = refreshVersions;
refreshDetailsRef.current = refreshDetails;
useEffect(() => { useEffect(() => {
const handleSaveSuccess = () => { const handleSaveSuccess = () => {
refreshVersions(); refreshVersionsRef.current();
refreshDetails(); refreshDetailsRef.current();
}; };
window.addEventListener("saveSuccessInModal", handleSaveSuccess); window.addEventListener("saveSuccessInModal", handleSaveSuccess);
return () => { return () => {
window.removeEventListener("saveSuccessInModal", handleSaveSuccess); window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
}; };
}, [refreshVersions, refreshDetails]); }, []);
// 품목 검색 // 품목 검색
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
@ -156,6 +163,24 @@ export function ItemRoutingComponent({
[config] [config]
); );
// 기본 버전 토글
const handleToggleDefault = useCallback(
async (versionId: string, currentIsDefault: boolean) => {
let success: boolean;
if (currentIsDefault) {
success = await unsetDefaultVersion(versionId);
if (success) toast({ title: "기본 버전이 해제되었습니다" });
} else {
success = await setDefaultVersion(versionId);
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
}
if (!success) {
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
}
},
[setDefaultVersion, unsetDefaultVersion, toast]
);
// 삭제 확인 // 삭제 확인
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
@ -175,12 +200,6 @@ export function ItemRoutingComponent({
setDeleteTarget(null); setDeleteTarget(null);
}, [deleteTarget, deleteVersion, deleteDetail, toast]); }, [deleteTarget, deleteVersion, deleteDetail, toast]);
// entity join으로 가져온 공정명 컬럼 이름 추정
const processNameKey = useMemo(() => {
const ds = config.dataSource;
return `${ds.processTable}_${ds.processNameColumn}`;
}, [config.dataSource]);
const splitRatio = config.splitRatio || 40; const splitRatio = config.splitRatio || 40;
if (isPreview) { if (isPreview) {
@ -295,19 +314,40 @@ export function ItemRoutingComponent({
<span className="mr-1 text-xs text-muted-foreground">:</span> <span className="mr-1 text-xs text-muted-foreground">:</span>
{versions.map((ver) => { {versions.map((ver) => {
const isActive = selectedVersionId === ver.id; const isActive = selectedVersionId === ver.id;
const isDefault = ver.is_default === true;
return ( return (
<div key={ver.id} className="flex items-center gap-0.5"> <div key={ver.id} className="flex items-center gap-0.5">
<Badge <Badge
variant={isActive ? "default" : "outline"} variant={isActive ? "default" : "outline"}
className={cn( className={cn(
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors", "cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
isActive && "bg-primary text-primary-foreground" isActive && "bg-primary text-primary-foreground",
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
)} )}
onClick={() => selectVersion(ver.id)} onClick={() => selectVersion(ver.id)}
> >
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id} {ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
</Badge> </Badge>
{!config.readonly && ( {!config.readonly && (
<>
<Button
variant="ghost"
size="icon"
className={cn(
"h-5 w-5",
isDefault
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground hover:text-amber-500"
)}
onClick={(e) => {
e.stopPropagation();
handleToggleDefault(ver.id, isDefault);
}}
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
>
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -323,6 +363,7 @@ export function ItemRoutingComponent({
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</>
)} )}
</div> </div>
); );
@ -394,11 +435,11 @@ export function ItemRoutingComponent({
<TableRow key={detail.id}> <TableRow key={detail.id}>
{config.processColumns.map((col) => { {config.processColumns.map((col) => {
let cellValue = detail[col.name]; let cellValue = detail[col.name];
if ( if (cellValue == null) {
col.name === "process_code" && const aliasKey = Object.keys(detail).find(
detail[processNameKey] (k) => k.endsWith(`_${col.name}`)
) { );
cellValue = `${detail[col.name]} (${detail[processNameKey]})`; if (aliasKey) cellValue = detail[aliasKey];
} }
return ( return (
<TableCell <TableCell

View File

@ -94,27 +94,29 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[configKey] [configKey]
); );
// 공정 상세 목록 조회 (특정 버전의 공정들) // 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
const fetchDetails = useCallback( const fetchDetails = useCallback(
async (versionId: string) => { async (versionId: string) => {
try { try {
setLoading(true); setLoading(true);
const ds = configRef.current.dataSource; const ds = configRef.current.dataSource;
const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", { const searchConditions = {
params: { [ds.routingDetailFkColumn]: { value: versionId, operator: "equals" },
tableName: ds.routingDetailTable, };
searchConditions: JSON.stringify({ const params = new URLSearchParams({
[ds.routingDetailFkColumn]: { page: "1",
value: versionId, size: "1000",
operator: "equals", search: JSON.stringify(searchConditions),
}, sortBy: "seq_no",
}), sortOrder: "ASC",
sortColumn: "seq_no", enableEntityJoin: "true",
sortDirection: "ASC",
},
}); });
const res = await apiClient.get(
`/table-management/tables/${ds.routingDetailTable}/data-with-joins?${params}`
);
if (res.data?.success) { if (res.data?.success) {
setDetails(res.data.data || []); const result = res.data.data;
setDetails(Array.isArray(result) ? result : result?.data || []);
} }
} catch (err) { } catch (err) {
console.error("공정 상세 조회 실패", err); console.error("공정 상세 조회 실패", err);
@ -136,14 +138,17 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
const versionList = await fetchVersions(itemCode); const versionList = await fetchVersions(itemCode);
// 첫번째 버전 자동 선택 if (versionList.length > 0) {
if (config.autoSelectFirstVersion && versionList.length > 0) { // 기본 버전 우선, 없으면 첫번째 버전 선택
const firstVersion = versionList[0]; const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
setSelectedVersionId(firstVersion.id); const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
await fetchDetails(firstVersion.id); if (targetVersion) {
setSelectedVersionId(targetVersion.id);
await fetchDetails(targetVersion.id);
}
} }
}, },
[fetchVersions, fetchDetails, config.autoSelectFirstVersion] [fetchVersions, fetchDetails]
); );
// 버전 선택 // 버전 선택
@ -181,7 +186,8 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
try { try {
const ds = configRef.current.dataSource; const ds = configRef.current.dataSource;
const res = await apiClient.delete( const res = await apiClient.delete(
`/table-data/${ds.routingDetailTable}/${detailId}` `/table-management/tables/${ds.routingDetailTable}/delete`,
{ data: [{ id: detailId }] }
); );
if (res.data?.success) { if (res.data?.success) {
await refreshDetails(); await refreshDetails();
@ -201,7 +207,8 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
try { try {
const ds = configRef.current.dataSource; const ds = configRef.current.dataSource;
const res = await apiClient.delete( const res = await apiClient.delete(
`/table-data/${ds.routingVersionTable}/${versionId}` `/table-management/tables/${ds.routingVersionTable}/delete`,
{ data: [{ id: versionId }] }
); );
if (res.data?.success) { if (res.data?.success) {
if (selectedVersionId === versionId) { if (selectedVersionId === versionId) {
@ -219,6 +226,51 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[selectedVersionId, refreshVersions] [selectedVersionId, refreshVersions]
); );
// 기본 버전 설정
const setDefaultVersion = useCallback(
async (versionId: string) => {
try {
const ds = configRef.current.dataSource;
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/set-default`, {
routingVersionTable: ds.routingVersionTable,
routingFkColumn: ds.routingVersionFkColumn,
});
if (res.data?.success) {
if (selectedItemCode) {
await fetchVersions(selectedItemCode);
}
return true;
}
} catch (err) {
console.error("기본 버전 설정 실패", err);
}
return false;
},
[selectedItemCode, fetchVersions]
);
// 기본 버전 해제
const unsetDefaultVersion = useCallback(
async (versionId: string) => {
try {
const ds = configRef.current.dataSource;
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/unset-default`, {
routingVersionTable: ds.routingVersionTable,
});
if (res.data?.success) {
if (selectedItemCode) {
await fetchVersions(selectedItemCode);
}
return true;
}
} catch (err) {
console.error("기본 버전 해제 실패", err);
}
return false;
},
[selectedItemCode, fetchVersions]
);
return { return {
config, config,
items, items,
@ -235,5 +287,7 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
refreshDetails, refreshDetails,
deleteDetail, deleteDetail,
deleteVersion, deleteVersion,
setDefaultVersion,
unsetDefaultVersion,
}; };
} }

View File

@ -65,6 +65,7 @@ export interface ItemData {
export interface RoutingVersionData { export interface RoutingVersionData {
id: string; id: string;
version_name: string; version_name: string;
is_default?: boolean;
[key: string]: any; [key: string]: any;
} }

View File

@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
import { InspectionStandardLookup } from "./InspectionStandardLookup";
interface DetailFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: Partial<WorkItemDetail>) => void;
detailTypes: DetailTypeDefinition[];
editData?: WorkItemDetail | null;
mode: "add" | "edit";
}
const LOOKUP_TARGETS = [
{ value: "equipment", label: "설비정보" },
{ value: "material", label: "자재정보" },
{ value: "worker", label: "작업자정보" },
{ value: "tool", label: "공구정보" },
{ value: "document", label: "문서정보" },
];
const INPUT_TYPES = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "textarea", label: "장문텍스트" },
{ value: "select", label: "선택형" },
];
export function DetailFormModal({
open,
onClose,
onSubmit,
detailTypes,
editData,
mode,
}: DetailFormModalProps) {
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
const [selectedInspection, setSelectedInspection] = useState<InspectionStandard | null>(null);
useEffect(() => {
if (open) {
if (mode === "edit" && editData) {
setFormData({ ...editData });
if (editData.inspection_code) {
setSelectedInspection({
id: "",
inspection_code: editData.inspection_code,
inspection_item: editData.content || "",
inspection_method: editData.inspection_method || "",
unit: editData.unit || "",
lower_limit: editData.lower_limit || "",
upper_limit: editData.upper_limit || "",
});
}
} else {
setFormData({
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "Y",
});
setSelectedInspection(null);
}
}
}, [open, mode, editData, detailTypes]);
const updateField = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleInspectionSelect = (item: InspectionStandard) => {
setSelectedInspection(item);
setFormData((prev) => ({
...prev,
inspection_code: item.inspection_code,
content: item.inspection_item,
inspection_method: item.inspection_method,
unit: item.unit,
lower_limit: item.lower_limit || "",
upper_limit: item.upper_limit || "",
}));
};
const handleSubmit = () => {
if (!formData.detail_type) return;
const type = formData.detail_type;
if (type === "check" && !formData.content?.trim()) return;
if (type === "inspect" && !formData.content?.trim()) return;
if (type === "procedure" && !formData.content?.trim()) return;
if (type === "input" && !formData.content?.trim()) return;
if (type === "info" && !formData.lookup_target) return;
onSubmit(formData);
onClose();
};
const currentType = formData.detail_type || "";
return (
<>
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{mode === "add" ? "추가" : "수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 유형 선택 */}
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Select
value={currentType}
onValueChange={(v) => {
updateField("detail_type", v);
setSelectedInspection(null);
setFormData((prev) => ({
detail_type: v,
is_required: prev.is_required || "Y",
}));
}}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{detailTypes.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 체크리스트 */}
{currentType === "check" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="예: 전원 상태 확인"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</>
)}
{/* 검사항목 */}
{currentType === "inspect" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<div className="mt-1 flex gap-2">
<Select value="_placeholder" disabled>
<SelectTrigger className="h-8 flex-1 text-xs sm:h-10 sm:text-sm">
<SelectValue>
{selectedInspection
? `${selectedInspection.inspection_code} - ${selectedInspection.inspection_item}`
: "검사기준을 선택하세요"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="_placeholder"></SelectItem>
</SelectContent>
</Select>
<Button
variant="secondary"
className="h-8 shrink-0 gap-1 text-xs sm:h-10 sm:text-sm"
onClick={() => setInspectionLookupOpen(true)}
>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{selectedInspection && (
<div className="rounded border bg-muted/30 p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<p>
<strong>:</strong> {selectedInspection.inspection_code}
</p>
<p>
<strong>:</strong> {selectedInspection.inspection_item}
</p>
<p>
<strong>:</strong> {selectedInspection.inspection_method || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.unit || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.lower_limit || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.upper_limit || "-"}
</p>
</div>
</div>
)}
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="예: 외경 치수"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.inspection_method || ""}
onChange={(e) => updateField("inspection_method", e.target.value)}
placeholder="예: 마이크로미터"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.unit || ""}
onChange={(e) => updateField("unit", e.target.value)}
placeholder="예: mm"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.lower_limit || ""}
onChange={(e) => updateField("lower_limit", e.target.value)}
placeholder="예: 7.95"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.upper_limit || ""}
onChange={(e) => updateField("upper_limit", e.target.value)}
placeholder="예: 8.05"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
</>
)}
{/* 작업절차 */}
{currentType === "procedure" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="예: 자재 투입"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Input
type="number"
value={formData.duration_minutes ?? ""}
onChange={(e) =>
updateField(
"duration_minutes",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="예: 5"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</>
)}
{/* 직접입력 */}
{currentType === "input" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="예: 작업자 의견"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.input_type || "text"}
onValueChange={(v) => updateField("input_type", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* 정보조회 */}
{currentType === "info" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.lookup_target || ""}
onValueChange={(v) => updateField("lookup_target", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{LOOKUP_TARGETS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.display_fields || ""}
onChange={(e) => updateField("display_fields", e.target.value)}
placeholder="예: 설비명, 설비코드"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</>
)}
{/* 필수 여부 (모든 유형 공통) */}
{currentType && (
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.is_required || "Y"}
onValueChange={(v) => updateField("is_required", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{mode === "add" ? "추가" : "수정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<InspectionStandardLookup
open={inspectionLookupOpen}
onClose={() => setInspectionLookupOpen(false)}
onSelect={handleInspectionSelect}
/>
</>
);
}

View File

@ -0,0 +1,187 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { apiClient } from "@/lib/api/client";
import { InspectionStandard } from "../types";
interface InspectionStandardLookupProps {
open: boolean;
onClose: () => void;
onSelect: (item: InspectionStandard) => void;
}
export function InspectionStandardLookup({
open,
onClose,
onSelect,
}: InspectionStandardLookupProps) {
const [data, setData] = useState<any[]>([]);
const [searchText, setSearchText] = useState("");
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
if (searchText.trim()) {
search.inspection_item = searchText.trim();
search.inspection_code = searchText.trim();
}
const params = new URLSearchParams({
page: "1",
size: "100",
enableEntityJoin: "true",
...(searchText.trim() ? { search: JSON.stringify(search) } : {}),
});
const res = await apiClient.get(
`/table-management/tables/inspection_standard/data-with-joins?${params}`
);
if (res.data?.success) {
const result = res.data.data;
setData(Array.isArray(result) ? result : result?.data || []);
}
} catch (err) {
console.error("검사기준 조회 실패", err);
} finally {
setLoading(false);
}
}, [searchText]);
useEffect(() => {
if (open) {
fetchData();
}
}, [open, fetchData]);
const handleSelect = (item: any) => {
onSelect({
id: item.id,
inspection_code: item.inspection_code || "",
inspection_item: item.inspection_item || item.inspection_criteria || "",
inspection_method: item.inspection_method || "",
unit: item.unit || "",
lower_limit: item.lower_limit || "",
upper_limit: item.upper_limit || "",
});
onClose();
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Search className="h-5 w-5" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-2">
<Input
placeholder="검사항목명 또는 검사코드로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchData()}
className="h-9 text-sm"
/>
</div>
<div className="max-h-[400px] overflow-auto rounded border">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted">
<tr className="border-b">
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
</th>
<th className="w-16 px-3 py-2 text-center font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="py-8 text-center text-muted-foreground">
...
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-muted-foreground">
</td>
</tr>
) : (
data.map((item, idx) => (
<tr
key={item.id || idx}
className="border-b transition-colors hover:bg-muted/30"
>
<td className="px-3 py-2">{item.inspection_code || "-"}</td>
<td className="px-3 py-2">
{item.inspection_item || item.inspection_criteria || "-"}
</td>
<td className="px-3 py-2">{item.inspection_method || "-"}</td>
<td className="px-3 py-2 text-center">
{item.lower_limit || "-"}
</td>
<td className="px-3 py-2 text-center">
{item.upper_limit || "-"}
</td>
<td className="px-3 py-2 text-center">{item.unit || "-"}</td>
<td className="px-3 py-2 text-center">
<Button
size="sm"
className="h-7 px-3 text-xs"
onClick={() => handleSelect(item)}
>
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -87,7 +87,7 @@ export function ItemProcessSelector({
</div> </div>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<div key={item.item_code} className="mb-1"> <div key={item.id} className="mb-1">
{/* 품목 헤더 */} {/* 품목 헤더 */}
<button <button
onClick={() => toggleItem(item.item_code, item.item_name)} onClick={() => toggleItem(item.item_code, item.item_name)}

View File

@ -1,19 +1,12 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { Plus, Pencil, Trash2, Check, X, HandMetal } from "lucide-react"; import { Plus, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types"; import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
import { DetailFormModal } from "./DetailFormModal";
interface WorkItemDetailListProps { interface WorkItemDetailListProps {
workItem: WorkItem | null; workItem: WorkItem | null;
@ -34,20 +27,13 @@ export function WorkItemDetailList({
onUpdateDetail, onUpdateDetail,
onDeleteDetail, onDeleteDetail,
}: WorkItemDetailListProps) { }: WorkItemDetailListProps) {
const [editingId, setEditingId] = useState<string | null>(null); const [modalOpen, setModalOpen] = useState(false);
const [editData, setEditData] = useState<Partial<WorkItemDetail>>({}); const [modalMode, setModalMode] = useState<"add" | "edit">("add");
const [isAdding, setIsAdding] = useState(false); const [editTarget, setEditTarget] = useState<WorkItemDetail | null>(null);
const [newData, setNewData] = useState<Partial<WorkItemDetail>>({
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "N",
sort_order: 0,
});
if (!workItem) { if (!workItem) {
return ( return (
<div className="flex h-full flex-col items-center justify-center text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
<HandMetal className="mb-2 h-10 w-10 text-amber-400" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
</p> </p>
@ -58,25 +44,60 @@ export function WorkItemDetailList({
const getTypeLabel = (value?: string) => const getTypeLabel = (value?: string) =>
detailTypes.find((t) => t.value === value)?.label || value || "-"; detailTypes.find((t) => t.value === value)?.label || value || "-";
const handleAdd = () => { const handleOpenAdd = () => {
if (!newData.content?.trim()) return; setModalMode("add");
onCreateDetail({ setEditTarget(null);
...newData, setModalOpen(true);
sort_order: details.length + 1,
});
setNewData({
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "N",
sort_order: 0,
});
setIsAdding(false);
}; };
const handleSaveEdit = (id: string) => { const handleOpenEdit = (detail: WorkItemDetail) => {
onUpdateDetail(id, editData); setModalMode("edit");
setEditingId(null); setEditTarget(detail);
setEditData({}); setModalOpen(true);
};
const handleSubmit = (data: Partial<WorkItemDetail>) => {
if (modalMode === "add") {
onCreateDetail({ ...data, sort_order: details.length + 1 });
} else if (editTarget) {
onUpdateDetail(editTarget.id, data);
}
};
const getContentSummary = (detail: WorkItemDetail): string => {
const type = detail.detail_type;
if (type === "inspect" && detail.inspection_code) {
const parts = [detail.content];
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
if (detail.lower_limit || detail.upper_limit) {
parts.push(`(${detail.lower_limit || "-"} ~ ${detail.upper_limit || "-"} ${detail.unit || ""})`);
}
return parts.join(" ");
}
if (type === "procedure" && detail.duration_minutes) {
return `${detail.content} (${detail.duration_minutes}분)`;
}
if (type === "input" && detail.input_type) {
const typeMap: Record<string, string> = {
text: "텍스트",
number: "숫자",
date: "날짜",
textarea: "장문",
select: "선택형",
};
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
}
if (type === "info" && detail.lookup_target) {
const targetMap: Record<string, string> = {
equipment: "설비정보",
material: "자재정보",
worker: "작업자정보",
tool: "공구정보",
document: "문서정보",
};
return `${targetMap[detail.lookup_target] || detail.lookup_target} 조회`;
}
return detail.content || "-";
}; };
return ( return (
@ -94,7 +115,7 @@ export function WorkItemDetailList({
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 gap-1 text-xs" className="h-7 gap-1 text-xs"
onClick={() => setIsAdding(true)} onClick={handleOpenAdd}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
@ -132,113 +153,18 @@ export function WorkItemDetailList({
key={detail.id} key={detail.id}
className="border-b transition-colors hover:bg-muted/30" className="border-b transition-colors hover:bg-muted/30"
> >
{editingId === detail.id ? (
<>
<td className="px-2 py-1.5 text-center">{idx + 1}</td>
<td className="px-2 py-1.5">
<Select
value={editData.detail_type || detail.detail_type || ""}
onValueChange={(v) =>
setEditData((prev) => ({
...prev,
detail_type: v,
}))
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{detailTypes.map((t) => (
<SelectItem
key={t.value}
value={t.value}
className="text-xs"
>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5">
<Input
value={editData.content ?? detail.content}
onChange={(e) =>
setEditData((prev) => ({
...prev,
content: e.target.value,
}))
}
className="h-7 text-xs"
/>
</td>
<td className="px-2 py-1.5 text-center">
<Select
value={editData.is_required ?? detail.is_required}
onValueChange={(v) =>
setEditData((prev) => ({
...prev,
is_required: v,
}))
}
>
<SelectTrigger className="h-7 w-14 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs">
</SelectItem>
<SelectItem value="N" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-green-600"
onClick={() => handleSaveEdit(detail.id)}
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setEditingId(null);
setEditData({});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
</td>
</>
) : (
<>
<td className="px-2 py-1.5 text-center text-muted-foreground"> <td className="px-2 py-1.5 text-center text-muted-foreground">
{idx + 1} {idx + 1}
</td> </td>
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<Badge <Badge variant="outline" className="text-[10px] font-normal">
variant="outline"
className="text-[10px] font-normal"
>
{getTypeLabel(detail.detail_type)} {getTypeLabel(detail.detail_type)}
</Badge> </Badge>
</td> </td>
<td className="px-2 py-1.5">{detail.content}</td> <td className="px-2 py-1.5">{getContentSummary(detail)}</td>
<td className="px-2 py-1.5 text-center"> <td className="px-2 py-1.5 text-center">
<Badge <Badge
variant={ variant={detail.is_required === "Y" ? "default" : "secondary"}
detail.is_required === "Y" ? "default" : "secondary"
}
className="text-[10px] font-normal" className="text-[10px] font-normal"
> >
{detail.is_required === "Y" ? "필수" : "선택"} {detail.is_required === "Y" ? "필수" : "선택"}
@ -251,14 +177,7 @@ export function WorkItemDetailList({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={() => { onClick={() => handleOpenEdit(detail)}
setEditingId(detail.id);
setEditData({
detail_type: detail.detail_type,
content: detail.content,
is_required: detail.is_required,
});
}}
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</Button> </Button>
@ -273,101 +192,12 @@ export function WorkItemDetailList({
</div> </div>
</td> </td>
)} )}
</>
)}
</tr> </tr>
))} ))}
{/* 추가 행 */}
{isAdding && (
<tr className="border-b bg-primary/5">
<td className="px-2 py-1.5 text-center text-muted-foreground">
{details.length + 1}
</td>
<td className="px-2 py-1.5">
<Select
value={newData.detail_type || ""}
onValueChange={(v) =>
setNewData((prev) => ({ ...prev, detail_type: v }))
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{detailTypes.map((t) => (
<SelectItem
key={t.value}
value={t.value}
className="text-xs"
>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5">
<Input
autoFocus
placeholder="상세 내용 입력"
value={newData.content || ""}
onChange={(e) =>
setNewData((prev) => ({
...prev,
content: e.target.value,
}))
}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
className="h-7 text-xs"
/>
</td>
<td className="px-2 py-1.5 text-center">
<Select
value={newData.is_required || "N"}
onValueChange={(v) =>
setNewData((prev) => ({ ...prev, is_required: v }))
}
>
<SelectTrigger className="h-7 w-14 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs">
</SelectItem>
<SelectItem value="N" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-green-600"
onClick={handleAdd}
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsAdding(false)}
>
<X className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
{details.length === 0 && !isAdding && ( {details.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
. &quot; &quot; . . &quot; &quot; .
@ -375,6 +205,16 @@ export function WorkItemDetailList({
</div> </div>
)} )}
</div> </div>
{/* 추가/수정 모달 */}
<DetailFormModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSubmit={handleSubmit}
detailTypes={detailTypes}
editData={editTarget}
mode={modalMode}
/>
</div> </div>
); );
} }

View File

@ -23,9 +23,11 @@ export const defaultConfig: ProcessWorkStandardConfig = {
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 }, { key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
], ],
detailTypes: [ detailTypes: [
{ value: "CHECK", label: "체크" }, { value: "check", label: "체크리스트" },
{ value: "INSPECTION", label: "검사" }, { value: "inspect", label: "검사항목" },
{ value: "MEASUREMENT", label: "측정" }, { value: "procedure", label: "작업절차" },
{ value: "input", label: "직접입력" },
{ value: "info", label: "정보조회" },
], ],
splitRatio: 30, splitRatio: 30,
leftPanelTitle: "품목 및 공정 선택", leftPanelTitle: "품목 및 공정 선택",

View File

@ -11,7 +11,7 @@ import {
SelectionState, SelectionState,
} from "../types"; } from "../types";
const API_BASE = "/api/process-work-standard"; const API_BASE = "/process-work-standard";
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const [items, setItems] = useState<ItemData[]>([]); const [items, setItems] = useState<ItemData[]>([]);

View File

@ -87,6 +87,29 @@ export interface WorkItemDetail {
sort_order: number; sort_order: number;
remark?: string; remark?: string;
created_date?: string; created_date?: string;
// 검사항목 전용
inspection_code?: string;
inspection_method?: string;
unit?: string;
lower_limit?: string;
upper_limit?: string;
// 작업절차 전용
duration_minutes?: number;
// 직접입력 전용
input_type?: string;
// 정보조회 전용
lookup_target?: string;
display_fields?: string;
}
export interface InspectionStandard {
id: string;
inspection_code: string;
inspection_item: string;
inspection_method: string;
unit: string;
lower_limit?: string;
upper_limit?: string;
} }
// ============================================================ // ============================================================