feat: Implement default version management for routing versions
- Added functionality to set and unset default versions for routing items. - Introduced new API endpoints for setting and unsetting default versions. - Enhanced the ItemRoutingComponent to support toggling default versions with user feedback. - Updated database queries to handle default version logic effectively. - Improved the overall user experience by allowing easy management of routing versions.
This commit is contained in:
@@ -39,16 +39,18 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
||||
if (search) params.push(`%${search}%`);
|
||||
|
||||
const query = `
|
||||
SELECT DISTINCT
|
||||
SELECT
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code
|
||||
i.${codeColumn} AS item_code,
|
||||
COUNT(rv.id) AS routing_count
|
||||
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
|
||||
WHERE i.company_code = $1
|
||||
${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);
|
||||
@@ -82,10 +84,10 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
|
||||
|
||||
// 라우팅 버전 목록
|
||||
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}
|
||||
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, [
|
||||
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
|
||||
// ============================================================
|
||||
@@ -330,7 +418,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
|
||||
const { workItemId } = req.params;
|
||||
|
||||
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
|
||||
WHERE work_item_id = $1 AND company_code = $2
|
||||
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: "인증 필요" });
|
||||
}
|
||||
|
||||
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) {
|
||||
return res.status(400).json({
|
||||
@@ -375,8 +470,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
|
||||
const query = `
|
||||
INSERT INTO process_work_item_detail
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||
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 *
|
||||
`;
|
||||
|
||||
@@ -389,6 +486,15 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
sort_order || 0,
|
||||
remark || null,
|
||||
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 });
|
||||
@@ -410,7 +516,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
}
|
||||
|
||||
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 = `
|
||||
UPDATE process_work_item_detail
|
||||
@@ -419,6 +529,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
is_required = COALESCE($3, is_required),
|
||||
sort_order = COALESCE($4, sort_order),
|
||||
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()
|
||||
WHERE id = $6 AND company_code = $7
|
||||
RETURNING *
|
||||
@@ -432,6 +551,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||
remark,
|
||||
id,
|
||||
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) {
|
||||
@@ -544,8 +672,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||
for (const detail of item.details) {
|
||||
await client.query(
|
||||
`INSERT INTO process_work_item_detail
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||
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,
|
||||
workItemId,
|
||||
@@ -555,6 +685,15 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||
detail.sort_order || 0,
|
||||
detail.remark || null,
|
||||
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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ router.use(authenticateToken);
|
||||
router.get("/items", ctrl.getItemsWithRouting);
|
||||
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
||||
|
||||
// 기본 버전 설정/해제
|
||||
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
|
||||
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
|
||||
|
||||
// 작업 항목 CRUD
|
||||
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
|
||||
router.post("/work-items", ctrl.createWorkItem);
|
||||
|
||||
@@ -2534,14 +2534,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{group.items.map((item, idx) => {
|
||||
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 =
|
||||
selectedLeftItem &&
|
||||
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={itemId}
|
||||
key={itemId != null ? `${itemId}-${idx}` : idx}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
@@ -2596,14 +2596,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{filteredData.map((item, idx) => {
|
||||
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 =
|
||||
selectedLeftItem &&
|
||||
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={itemId}
|
||||
key={itemId != null ? `${itemId}-${idx}` : idx}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
@@ -2698,7 +2698,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
// 재귀 렌더링 함수
|
||||
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
||||
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 =
|
||||
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
@@ -2749,7 +2750,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const displaySubtitle = displayFields[1]?.value || null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={itemId}>
|
||||
<React.Fragment key={`${itemId}-${index}`}>
|
||||
{/* 현재 항목 */}
|
||||
<div
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
{currentTabData.map((item: any, idx: number) => {
|
||||
const itemId = item.id || idx;
|
||||
const itemId = item.id ?? idx;
|
||||
const isExpanded = expandedRightItems.has(itemId);
|
||||
|
||||
// 표시할 컬럼 결정
|
||||
@@ -3097,7 +3098,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const detailColumns = columnsToShow.slice(summaryCount);
|
||||
|
||||
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
|
||||
className="flex cursor-pointer items-start justify-between"
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
@@ -3287,10 +3288,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{filteredData.map((item, idx) => {
|
||||
const itemId = item.id || item.ID || idx;
|
||||
const itemId = item.id || item.ID;
|
||||
|
||||
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) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
@@ -3404,7 +3405,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
key={`${itemId}-${index}`}
|
||||
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
{/* 요약 정보 */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -51,6 +51,8 @@ export function ItemRoutingComponent({
|
||||
refreshDetails,
|
||||
deleteDetail,
|
||||
deleteVersion,
|
||||
setDefaultVersion,
|
||||
unsetDefaultVersion,
|
||||
} = useItemRouting(configProp || {});
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
@@ -70,16 +72,21 @@ export function ItemRoutingComponent({
|
||||
}, [fetchItems]);
|
||||
|
||||
// 모달 저장 성공 감지 -> 데이터 새로고침
|
||||
const refreshVersionsRef = React.useRef(refreshVersions);
|
||||
const refreshDetailsRef = React.useRef(refreshDetails);
|
||||
refreshVersionsRef.current = refreshVersions;
|
||||
refreshDetailsRef.current = refreshDetails;
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaveSuccess = () => {
|
||||
refreshVersions();
|
||||
refreshDetails();
|
||||
refreshVersionsRef.current();
|
||||
refreshDetailsRef.current();
|
||||
};
|
||||
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
return () => {
|
||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
};
|
||||
}, [refreshVersions, refreshDetails]);
|
||||
}, []);
|
||||
|
||||
// 품목 검색
|
||||
const handleSearch = useCallback(() => {
|
||||
@@ -156,6 +163,24 @@ export function ItemRoutingComponent({
|
||||
[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 () => {
|
||||
if (!deleteTarget) return;
|
||||
@@ -175,12 +200,6 @@ export function ItemRoutingComponent({
|
||||
setDeleteTarget(null);
|
||||
}, [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;
|
||||
|
||||
if (isPreview) {
|
||||
@@ -295,34 +314,56 @@ export function ItemRoutingComponent({
|
||||
<span className="mr-1 text-xs text-muted-foreground">버전:</span>
|
||||
{versions.map((ver) => {
|
||||
const isActive = selectedVersionId === ver.id;
|
||||
const isDefault = ver.is_default === true;
|
||||
return (
|
||||
<div key={ver.id} className="flex items-center gap-0.5">
|
||||
<Badge
|
||||
variant={isActive ? "default" : "outline"}
|
||||
className={cn(
|
||||
"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)}
|
||||
>
|
||||
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
|
||||
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
||||
</Badge>
|
||||
{!config.readonly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({
|
||||
type: "version",
|
||||
id: ver.id,
|
||||
name: ver.version_name || ver.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({
|
||||
type: "version",
|
||||
id: ver.id,
|
||||
name: ver.version_name || ver.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -394,11 +435,11 @@ export function ItemRoutingComponent({
|
||||
<TableRow key={detail.id}>
|
||||
{config.processColumns.map((col) => {
|
||||
let cellValue = detail[col.name];
|
||||
if (
|
||||
col.name === "process_code" &&
|
||||
detail[processNameKey]
|
||||
) {
|
||||
cellValue = `${detail[col.name]} (${detail[processNameKey]})`;
|
||||
if (cellValue == null) {
|
||||
const aliasKey = Object.keys(detail).find(
|
||||
(k) => k.endsWith(`_${col.name}`)
|
||||
);
|
||||
if (aliasKey) cellValue = detail[aliasKey];
|
||||
}
|
||||
return (
|
||||
<TableCell
|
||||
|
||||
@@ -94,27 +94,29 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||
[configKey]
|
||||
);
|
||||
|
||||
// 공정 상세 목록 조회 (특정 버전의 공정들)
|
||||
// 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
|
||||
const fetchDetails = useCallback(
|
||||
async (versionId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const ds = configRef.current.dataSource;
|
||||
const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", {
|
||||
params: {
|
||||
tableName: ds.routingDetailTable,
|
||||
searchConditions: JSON.stringify({
|
||||
[ds.routingDetailFkColumn]: {
|
||||
value: versionId,
|
||||
operator: "equals",
|
||||
},
|
||||
}),
|
||||
sortColumn: "seq_no",
|
||||
sortDirection: "ASC",
|
||||
},
|
||||
const searchConditions = {
|
||||
[ds.routingDetailFkColumn]: { value: versionId, operator: "equals" },
|
||||
};
|
||||
const params = new URLSearchParams({
|
||||
page: "1",
|
||||
size: "1000",
|
||||
search: JSON.stringify(searchConditions),
|
||||
sortBy: "seq_no",
|
||||
sortOrder: "ASC",
|
||||
enableEntityJoin: "true",
|
||||
});
|
||||
const res = await apiClient.get(
|
||||
`/table-management/tables/${ds.routingDetailTable}/data-with-joins?${params}`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
setDetails(res.data.data || []);
|
||||
const result = res.data.data;
|
||||
setDetails(Array.isArray(result) ? result : result?.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("공정 상세 조회 실패", err);
|
||||
@@ -136,14 +138,17 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||
|
||||
const versionList = await fetchVersions(itemCode);
|
||||
|
||||
// 첫번째 버전 자동 선택
|
||||
if (config.autoSelectFirstVersion && versionList.length > 0) {
|
||||
const firstVersion = versionList[0];
|
||||
setSelectedVersionId(firstVersion.id);
|
||||
await fetchDetails(firstVersion.id);
|
||||
if (versionList.length > 0) {
|
||||
// 기본 버전 우선, 없으면 첫번째 버전 선택
|
||||
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
|
||||
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
|
||||
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 {
|
||||
const ds = configRef.current.dataSource;
|
||||
const res = await apiClient.delete(
|
||||
`/table-data/${ds.routingDetailTable}/${detailId}`
|
||||
`/table-management/tables/${ds.routingDetailTable}/delete`,
|
||||
{ data: [{ id: detailId }] }
|
||||
);
|
||||
if (res.data?.success) {
|
||||
await refreshDetails();
|
||||
@@ -201,7 +207,8 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||
try {
|
||||
const ds = configRef.current.dataSource;
|
||||
const res = await apiClient.delete(
|
||||
`/table-data/${ds.routingVersionTable}/${versionId}`
|
||||
`/table-management/tables/${ds.routingVersionTable}/delete`,
|
||||
{ data: [{ id: versionId }] }
|
||||
);
|
||||
if (res.data?.success) {
|
||||
if (selectedVersionId === versionId) {
|
||||
@@ -219,6 +226,51 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||
[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 {
|
||||
config,
|
||||
items,
|
||||
@@ -235,5 +287,7 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||
refreshDetails,
|
||||
deleteDetail,
|
||||
deleteVersion,
|
||||
setDefaultVersion,
|
||||
unsetDefaultVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface ItemData {
|
||||
export interface RoutingVersionData {
|
||||
id: string;
|
||||
version_name: string;
|
||||
is_default?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function ItemProcessSelector({
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.item_code} className="mb-1">
|
||||
<div key={item.id} className="mb-1">
|
||||
{/* 품목 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleItem(item.item_code, item.item_name)}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
|
||||
import { DetailFormModal } from "./DetailFormModal";
|
||||
|
||||
interface WorkItemDetailListProps {
|
||||
workItem: WorkItem | null;
|
||||
@@ -34,20 +27,13 @@ export function WorkItemDetailList({
|
||||
onUpdateDetail,
|
||||
onDeleteDetail,
|
||||
}: WorkItemDetailListProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<WorkItemDetail>>({});
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newData, setNewData] = useState<Partial<WorkItemDetail>>({
|
||||
detail_type: detailTypes[0]?.value || "",
|
||||
content: "",
|
||||
is_required: "N",
|
||||
sort_order: 0,
|
||||
});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
||||
const [editTarget, setEditTarget] = useState<WorkItemDetail | null>(null);
|
||||
|
||||
if (!workItem) {
|
||||
return (
|
||||
<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>
|
||||
@@ -58,25 +44,60 @@ export function WorkItemDetailList({
|
||||
const getTypeLabel = (value?: string) =>
|
||||
detailTypes.find((t) => t.value === value)?.label || value || "-";
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newData.content?.trim()) return;
|
||||
onCreateDetail({
|
||||
...newData,
|
||||
sort_order: details.length + 1,
|
||||
});
|
||||
setNewData({
|
||||
detail_type: detailTypes[0]?.value || "",
|
||||
content: "",
|
||||
is_required: "N",
|
||||
sort_order: 0,
|
||||
});
|
||||
setIsAdding(false);
|
||||
const handleOpenAdd = () => {
|
||||
setModalMode("add");
|
||||
setEditTarget(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (id: string) => {
|
||||
onUpdateDetail(id, editData);
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
const handleOpenEdit = (detail: WorkItemDetail) => {
|
||||
setModalMode("edit");
|
||||
setEditTarget(detail);
|
||||
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 (
|
||||
@@ -94,7 +115,7 @@ export function WorkItemDetailList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => setIsAdding(true)}
|
||||
onClick={handleOpenAdd}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
상세 추가
|
||||
@@ -132,242 +153,51 @@ export function WorkItemDetailList({
|
||||
key={detail.id}
|
||||
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,
|
||||
}))
|
||||
}
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{getTypeLabel(detail.detail_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{getContentSummary(detail)}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge
|
||||
variant={detail.is_required === "Y" ? "default" : "secondary"}
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{detail.is_required === "Y" ? "필수" : "선택"}
|
||||
</Badge>
|
||||
</td>
|
||||
{!readonly && (
|
||||
<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"
|
||||
onClick={() => handleOpenEdit(detail)}
|
||||
>
|
||||
<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,
|
||||
}))
|
||||
}
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => onDeleteDetail(detail.id)}
|
||||
>
|
||||
<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">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{getTypeLabel(detail.detail_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{detail.content}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
detail.is_required === "Y" ? "default" : "secondary"
|
||||
}
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{detail.is_required === "Y" ? "필수" : "선택"}
|
||||
</Badge>
|
||||
</td>
|
||||
{!readonly && (
|
||||
<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"
|
||||
onClick={() => {
|
||||
setEditingId(detail.id);
|
||||
setEditData({
|
||||
detail_type: detail.detail_type,
|
||||
content: detail.content,
|
||||
is_required: detail.is_required,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => onDeleteDetail(detail.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</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>
|
||||
</table>
|
||||
|
||||
{details.length === 0 && !isAdding && (
|
||||
{details.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요.
|
||||
@@ -375,6 +205,16 @@ export function WorkItemDetailList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<DetailFormModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSubmit={handleSubmit}
|
||||
detailTypes={detailTypes}
|
||||
editData={editTarget}
|
||||
mode={modalMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ export const defaultConfig: ProcessWorkStandardConfig = {
|
||||
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||
],
|
||||
detailTypes: [
|
||||
{ value: "CHECK", label: "체크" },
|
||||
{ value: "INSPECTION", label: "검사" },
|
||||
{ value: "MEASUREMENT", label: "측정" },
|
||||
{ value: "check", label: "체크리스트" },
|
||||
{ value: "inspect", label: "검사항목" },
|
||||
{ value: "procedure", label: "작업절차" },
|
||||
{ value: "input", label: "직접입력" },
|
||||
{ value: "info", label: "정보조회" },
|
||||
],
|
||||
splitRatio: 30,
|
||||
leftPanelTitle: "품목 및 공정 선택",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SelectionState,
|
||||
} from "../types";
|
||||
|
||||
const API_BASE = "/api/process-work-standard";
|
||||
const API_BASE = "/process-work-standard";
|
||||
|
||||
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||
const [items, setItems] = useState<ItemData[]>([]);
|
||||
|
||||
@@ -87,6 +87,29 @@ export interface WorkItemDetail {
|
||||
sort_order: number;
|
||||
remark?: 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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user