refactor: Enhance screen layout retrieval logic for multi-tenancy support
- Updated the ScreenManagementService to prioritize fetching layouts based on layer_id, ensuring that only the default layer is retrieved for users. - Implemented logic for administrators to re-query layouts based on the screen definition's company_code when no layout is found. - Adjusted the BomItemEditorComponent to dynamically render table cells based on configuration, improving flexibility and usability in the BOM item editor. - Introduced category options loading for dynamic cell rendering, enhancing the user experience in item editing.
This commit is contained in:
parent
5afa373b1f
commit
72068d003a
|
|
@ -1721,18 +1721,28 @@ export class ScreenManagementService {
|
|||
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 🆕 V2 테이블 우선 조회 (회사별 → 공통(*))
|
||||
// V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴
|
||||
// layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음
|
||||
let v2Layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이아웃 없으면 공통(*) 조회
|
||||
// 최고관리자(*): 화면 정의의 company_code로 재조회
|
||||
if (!v2Layout && companyCode === "*" && existingScreen.company_code && existingScreen.company_code !== "*") {
|
||||
v2Layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 사용자: 회사별 레이아웃 없으면 공통(*) 조회
|
||||
if (!v2Layout && companyCode !== "*") {
|
||||
v2Layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
WHERE screen_id = $1 AND company_code = '*' AND layer_id = 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
|
|
@ -5302,7 +5312,22 @@ export class ScreenManagementService {
|
|||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
// 회사별 레이어가 없으면 공통(*) 조회
|
||||
// 최고관리자(*): 화면 정의의 company_code로 재조회
|
||||
if (!layout && companyCode === "*") {
|
||||
const screenDef = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
if (screenDef && screenDef.company_code && screenDef.company_code !== "*") {
|
||||
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, screenDef.company_code, layerId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 사용자: 회사별 레이어가 없으면 공통(*) 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
|
|
|
|||
|
|
@ -376,12 +376,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 화면 정보와 레이아웃 데이터 로딩
|
||||
const [screenInfo, layoutData] = await Promise.all([
|
||||
// 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선)
|
||||
const [screenInfo, v2LayoutData] = await Promise.all([
|
||||
screenApi.getScreen(screenId),
|
||||
screenApi.getLayout(screenId),
|
||||
screenApi.getLayoutV2(screenId),
|
||||
]);
|
||||
|
||||
// V2 → Legacy 변환 (ScreenModal과 동일한 패턴)
|
||||
let layoutData: any = null;
|
||||
if (v2LayoutData && isValidV2Layout(v2LayoutData)) {
|
||||
layoutData = convertV2ToLegacy(v2LayoutData);
|
||||
if (layoutData) {
|
||||
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
|
||||
}
|
||||
}
|
||||
|
||||
// V2 없으면 기존 API fallback
|
||||
if (!layoutData) {
|
||||
layoutData = await screenApi.getLayout(screenId);
|
||||
}
|
||||
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
||||
|
|
|
|||
|
|
@ -35,21 +35,23 @@ import { apiClient } from "@/lib/api/client";
|
|||
interface BomItemNode {
|
||||
tempId: string;
|
||||
id?: string;
|
||||
bom_id?: string;
|
||||
parent_detail_id: string | null;
|
||||
seq_no: number;
|
||||
level: number;
|
||||
child_item_id: string;
|
||||
child_item_code: string;
|
||||
child_item_name: string;
|
||||
child_item_type: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
loss_rate: string;
|
||||
remark: string;
|
||||
children: BomItemNode[];
|
||||
_isNew?: boolean;
|
||||
_isDeleted?: boolean;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
interface BomColumnConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
width?: string;
|
||||
visible?: boolean;
|
||||
editable?: boolean;
|
||||
isSourceDisplay?: boolean;
|
||||
inputType?: string;
|
||||
}
|
||||
|
||||
interface ItemInfo {
|
||||
|
|
@ -211,13 +213,16 @@ function ItemSearchModal({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── 트리 노드 행 렌더링 ───
|
||||
// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ───
|
||||
|
||||
interface TreeNodeRowProps {
|
||||
node: BomItemNode;
|
||||
depth: number;
|
||||
expanded: boolean;
|
||||
hasChildren: boolean;
|
||||
columns: BomColumnConfig[];
|
||||
categoryOptionsMap: Record<string, { value: string; label: string }[]>;
|
||||
mainTableName?: string;
|
||||
onToggle: () => void;
|
||||
onFieldChange: (tempId: string, field: string, value: string) => void;
|
||||
onDelete: (tempId: string) => void;
|
||||
|
|
@ -229,12 +234,84 @@ function TreeNodeRow({
|
|||
depth,
|
||||
expanded,
|
||||
hasChildren,
|
||||
columns,
|
||||
categoryOptionsMap,
|
||||
mainTableName,
|
||||
onToggle,
|
||||
onFieldChange,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: TreeNodeRowProps) {
|
||||
const indentPx = depth * 32;
|
||||
const visibleColumns = columns.filter((c) => c.visible !== false);
|
||||
|
||||
const renderCell = (col: BomColumnConfig) => {
|
||||
const value = node.data[col.key] ?? "";
|
||||
|
||||
// 소스 표시 컬럼 (읽기 전용)
|
||||
if (col.isSourceDisplay) {
|
||||
return (
|
||||
<span className="truncate text-xs" title={String(value)}>
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링
|
||||
if (col.inputType === "category") {
|
||||
const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : "";
|
||||
const options = categoryOptionsMap[categoryRef] || [];
|
||||
return (
|
||||
<Select
|
||||
value={String(value || "")}
|
||||
onValueChange={(val) => onFieldChange(node.tempId, col.key, val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full min-w-[70px] text-xs">
|
||||
<SelectValue placeholder={col.title} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 편집 불가능 컬럼
|
||||
if (col.editable === false) {
|
||||
return (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 숫자 입력
|
||||
if (col.inputType === "number" || col.inputType === "decimal") {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={String(value)}
|
||||
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
|
||||
className="h-7 w-full min-w-[50px] text-center text-xs"
|
||||
placeholder={col.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 텍스트 입력
|
||||
return (
|
||||
<Input
|
||||
value={String(value)}
|
||||
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
|
||||
className="h-7 w-full min-w-[50px] text-xs"
|
||||
placeholder={col.title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -245,10 +322,8 @@ function TreeNodeRow({
|
|||
)}
|
||||
style={{ marginLeft: `${indentPx}px` }}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
|
||||
|
||||
{/* 펼침/접기 */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
|
|
@ -266,57 +341,30 @@ function TreeNodeRow({
|
|||
))}
|
||||
</button>
|
||||
|
||||
{/* 순번 */}
|
||||
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
|
||||
{node.seq_no}
|
||||
</span>
|
||||
|
||||
{/* 품목코드 */}
|
||||
<span className="w-24 shrink-0 truncate font-mono text-xs font-medium">
|
||||
{node.child_item_code || "-"}
|
||||
</span>
|
||||
|
||||
{/* 품목명 */}
|
||||
<span className="min-w-[80px] flex-1 truncate text-xs">
|
||||
{node.child_item_name || "-"}
|
||||
</span>
|
||||
|
||||
{/* 레벨 뱃지 */}
|
||||
{node.level > 0 && (
|
||||
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
L{node.level}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 수량 */}
|
||||
<Input
|
||||
value={node.quantity}
|
||||
onChange={(e) =>
|
||||
onFieldChange(node.tempId, "quantity", e.target.value)
|
||||
}
|
||||
className="h-7 w-16 shrink-0 text-center text-xs"
|
||||
placeholder="수량"
|
||||
/>
|
||||
{/* config.columns 기반 동적 셀 렌더링 */}
|
||||
{visibleColumns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
col.isSourceDisplay ? "min-w-[60px] flex-1" : "min-w-[50px]",
|
||||
)}
|
||||
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
|
||||
>
|
||||
{renderCell(col)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 품목구분 셀렉트 */}
|
||||
<Select
|
||||
value={node.child_item_type || ""}
|
||||
onValueChange={(val) =>
|
||||
onFieldChange(node.tempId, "child_item_type", val)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-20 shrink-0 text-xs">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="assembly">조립</SelectItem>
|
||||
<SelectItem value="process">공정</SelectItem>
|
||||
<SelectItem value="purchase">구매</SelectItem>
|
||||
<SelectItem value="outsource">외주</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 하위 추가 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -327,7 +375,6 @@ function TreeNodeRow({
|
|||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -356,9 +403,16 @@ export function BomItemEditorComponent({
|
|||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
||||
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(null);
|
||||
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||
|
||||
// 설정값 추출
|
||||
const cfg = useMemo(() => component?.componentConfig || {}, [component]);
|
||||
const mainTableName = cfg.mainTableName || "bom_detail";
|
||||
const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id";
|
||||
const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
|
||||
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
|
||||
const fkColumn = cfg.foreignKeyColumn || "bom_id";
|
||||
|
||||
// BOM ID 결정
|
||||
const bomId = useMemo(() => {
|
||||
|
|
@ -368,6 +422,37 @@ export function BomItemEditorComponent({
|
|||
return null;
|
||||
}, [propBomId, formData, selectedRowsData]);
|
||||
|
||||
// ─── 카테고리 옵션 로드 (리피터 방식) ───
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategoryOptions = async () => {
|
||||
const categoryColumns = visibleColumns.filter((col) => col.inputType === "category");
|
||||
if (categoryColumns.length === 0) return;
|
||||
|
||||
for (const col of categoryColumns) {
|
||||
const categoryRef = `${mainTableName}.${col.key}`;
|
||||
if (categoryOptionsMap[categoryRef]) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
|
||||
if (response.data?.success && response.data.data) {
|
||||
const options = response.data.data.map((item: any) => ({
|
||||
value: item.valueCode || item.value_code,
|
||||
label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label,
|
||||
}));
|
||||
setCategoryOptionsMap((prev) => ({ ...prev, [categoryRef]: options }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isDesignMode) {
|
||||
loadCategoryOptions();
|
||||
}
|
||||
}, [visibleColumns, mainTableName, isDesignMode]);
|
||||
|
||||
// ─── 데이터 로드 ───
|
||||
|
||||
const loadBomDetails = useCallback(
|
||||
|
|
@ -375,10 +460,10 @@ export function BomItemEditorComponent({
|
|||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", {
|
||||
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
search: { bom_id: id },
|
||||
search: { [fkColumn]: id },
|
||||
sortBy: "seq_no",
|
||||
sortOrder: "asc",
|
||||
enableEntityJoin: true,
|
||||
|
|
@ -388,7 +473,6 @@ export function BomItemEditorComponent({
|
|||
const tree = buildTree(rows);
|
||||
setTreeData(tree);
|
||||
|
||||
// 1레벨 기본 펼침
|
||||
const firstLevelIds = new Set<string>(
|
||||
tree.map((n) => n.tempId || n.id || ""),
|
||||
);
|
||||
|
|
@ -399,7 +483,7 @@ export function BomItemEditorComponent({
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[mainTableName, fkColumn],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -408,7 +492,7 @@ export function BomItemEditorComponent({
|
|||
}
|
||||
}, [bomId, isDesignMode, loadBomDetails]);
|
||||
|
||||
// ─── 트리 빌드 ───
|
||||
// ─── 트리 빌드 (동적 데이터) ───
|
||||
|
||||
const buildTree = (flatData: any[]): BomItemNode[] => {
|
||||
const nodeMap = new Map<string, BomItemNode>();
|
||||
|
|
@ -419,19 +503,11 @@ export function BomItemEditorComponent({
|
|||
nodeMap.set(item.id || tempId, {
|
||||
tempId,
|
||||
id: item.id,
|
||||
bom_id: item.bom_id,
|
||||
parent_detail_id: item.parent_detail_id || null,
|
||||
parent_detail_id: item[parentKeyColumn] || null,
|
||||
seq_no: Number(item.seq_no) || 0,
|
||||
level: Number(item.level) || 0,
|
||||
child_item_id: item.child_item_id || "",
|
||||
child_item_code: item.child_item_code || "",
|
||||
child_item_name: item.child_item_name || "",
|
||||
child_item_type: item.child_item_type || "",
|
||||
quantity: item.quantity || "1",
|
||||
unit: item.unit || "EA",
|
||||
loss_rate: item.loss_rate || "0",
|
||||
remark: item.remark || "",
|
||||
children: [],
|
||||
data: { ...item },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -440,14 +516,14 @@ export function BomItemEditorComponent({
|
|||
const node = nodeMap.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) {
|
||||
nodeMap.get(item.parent_detail_id)!.children.push(node);
|
||||
const parentId = item[parentKeyColumn];
|
||||
if (parentId && nodeMap.has(parentId)) {
|
||||
nodeMap.get(parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// 순번 정렬
|
||||
const sortChildren = (nodes: BomItemNode[]) => {
|
||||
nodes.sort((a, b) => a.seq_no - b.seq_no);
|
||||
nodes.forEach((n) => sortChildren(n.children));
|
||||
|
|
@ -468,22 +544,14 @@ export function BomItemEditorComponent({
|
|||
) => {
|
||||
items.forEach((node, idx) => {
|
||||
result.push({
|
||||
...node.data,
|
||||
id: node.id,
|
||||
tempId: node.tempId,
|
||||
bom_id: node.bom_id,
|
||||
parent_detail_id: parentId,
|
||||
[parentKeyColumn]: parentId,
|
||||
seq_no: String(idx + 1),
|
||||
level: String(level),
|
||||
child_item_id: node.child_item_id,
|
||||
child_item_code: node.child_item_code,
|
||||
child_item_name: node.child_item_name,
|
||||
child_item_type: node.child_item_type,
|
||||
quantity: node.quantity,
|
||||
unit: node.unit,
|
||||
loss_rate: node.loss_rate,
|
||||
remark: node.remark,
|
||||
_isNew: node._isNew,
|
||||
_targetTable: "bom_detail",
|
||||
_targetTable: mainTableName,
|
||||
});
|
||||
if (node.children.length > 0) {
|
||||
traverse(node.children, node.id || node.tempId, level + 1);
|
||||
|
|
@ -492,7 +560,7 @@ export function BomItemEditorComponent({
|
|||
};
|
||||
traverse(nodes, null, 0);
|
||||
return result;
|
||||
}, []);
|
||||
}, [parentKeyColumn, mainTableName]);
|
||||
|
||||
// 트리 변경 시 부모에게 알림
|
||||
const notifyChange = useCallback(
|
||||
|
|
@ -526,12 +594,12 @@ export function BomItemEditorComponent({
|
|||
return result;
|
||||
};
|
||||
|
||||
// 필드 변경
|
||||
// 필드 변경 (data Record 내부 업데이트)
|
||||
const handleFieldChange = useCallback(
|
||||
(tempId: string, field: string, value: string) => {
|
||||
const newTree = findAndUpdate(treeData, tempId, (node) => ({
|
||||
...node,
|
||||
[field]: value,
|
||||
data: { ...node.data, [field]: value },
|
||||
}));
|
||||
notifyChange(newTree);
|
||||
},
|
||||
|
|
@ -559,35 +627,44 @@ export function BomItemEditorComponent({
|
|||
setItemSearchOpen(true);
|
||||
}, []);
|
||||
|
||||
// 품목 선택 후 추가
|
||||
// 품목 선택 후 추가 (동적 데이터)
|
||||
const handleItemSelect = useCallback(
|
||||
(item: ItemInfo) => {
|
||||
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식)
|
||||
const sourceData: Record<string, any> = {};
|
||||
const sourceTable = cfg.dataSource?.sourceTable;
|
||||
if (sourceTable) {
|
||||
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
|
||||
sourceData[sourceFk] = item.id;
|
||||
// 소스 표시 컬럼의 데이터 병합
|
||||
Object.keys(item).forEach((key) => {
|
||||
sourceData[`_display_${key}`] = (item as any)[key];
|
||||
sourceData[key] = (item as any)[key];
|
||||
});
|
||||
}
|
||||
|
||||
const newNode: BomItemNode = {
|
||||
tempId: generateTempId(),
|
||||
parent_detail_id: null,
|
||||
seq_no: 0,
|
||||
level: 0,
|
||||
child_item_id: item.id,
|
||||
child_item_code: item.item_number || "",
|
||||
child_item_name: item.item_name || "",
|
||||
child_item_type: item.type || "",
|
||||
quantity: "1",
|
||||
unit: item.unit || "EA",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
children: [],
|
||||
_isNew: true,
|
||||
data: {
|
||||
...sourceData,
|
||||
quantity: "1",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
},
|
||||
};
|
||||
|
||||
let newTree: BomItemNode[];
|
||||
|
||||
if (addTargetParentId === null) {
|
||||
// 루트에 추가
|
||||
newNode.seq_no = treeData.length + 1;
|
||||
newNode.level = 0;
|
||||
newTree = [...treeData, newNode];
|
||||
} else {
|
||||
// 특정 노드 하위에 추가
|
||||
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
|
||||
newNode.parent_detail_id = parent.id || parent.tempId;
|
||||
newNode.seq_no = parent.children.length + 1;
|
||||
|
|
@ -597,13 +674,12 @@ export function BomItemEditorComponent({
|
|||
children: [...parent.children, newNode],
|
||||
};
|
||||
});
|
||||
// 부모 노드 펼침
|
||||
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
|
||||
}
|
||||
|
||||
notifyChange(newTree);
|
||||
},
|
||||
[addTargetParentId, treeData, notifyChange],
|
||||
[addTargetParentId, treeData, notifyChange, cfg],
|
||||
);
|
||||
|
||||
// 펼침/접기 토글
|
||||
|
|
@ -628,6 +704,9 @@ export function BomItemEditorComponent({
|
|||
depth={depth}
|
||||
expanded={isExpanded}
|
||||
hasChildren={node.children.length > 0}
|
||||
columns={visibleColumns}
|
||||
categoryOptionsMap={categoryOptionsMap}
|
||||
mainTableName={mainTableName}
|
||||
onToggle={() => toggleExpand(node.tempId)}
|
||||
onFieldChange={handleFieldChange}
|
||||
onDelete={handleDelete}
|
||||
|
|
@ -648,9 +727,6 @@ export function BomItemEditorComponent({
|
|||
const hasConfig =
|
||||
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
|
||||
|
||||
const sourceColumns = (cfg.columns || []).filter((c: any) => c.isSourceDisplay);
|
||||
const inputColumns = (cfg.columns || []).filter((c: any) => !c.isSourceDisplay);
|
||||
|
||||
if (!hasConfig) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-6 text-center">
|
||||
|
|
@ -659,23 +735,36 @@ export function BomItemEditorComponent({
|
|||
BOM 하위 품목 편집기
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
트리 구조로 하위 품목을 관리합니다
|
||||
설정 패널에서 테이블과 컬럼을 지정하세요
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dummyRows = [
|
||||
{ depth: 0, code: "ASM-001", name: "본체 조립", type: "조립", qty: "1" },
|
||||
{ depth: 1, code: "PRT-010", name: "프레임", type: "구매", qty: "2" },
|
||||
{ depth: 1, code: "PRT-011", name: "커버", type: "구매", qty: "1" },
|
||||
{ depth: 0, code: "ASM-002", name: "전장 조립", type: "조립", qty: "1" },
|
||||
{ depth: 1, code: "PRT-020", name: "PCB 보드", type: "구매", qty: "3" },
|
||||
];
|
||||
const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false);
|
||||
|
||||
const DUMMY_DATA: Record<string, string[]> = {
|
||||
item_name: ["본체 조립", "프레임", "커버", "전장 조립", "PCB 보드"],
|
||||
item_number: ["ASM-001", "PRT-010", "PRT-011", "ASM-002", "PRT-020"],
|
||||
specification: ["100×50", "200mm", "ABS", "50×30", "4-Layer"],
|
||||
material: ["AL6061", "SUS304", "ABS", "FR-4", "구리"],
|
||||
stock_unit: ["EA", "EA", "EA", "EA", "EA"],
|
||||
quantity: ["1", "2", "1", "1", "3"],
|
||||
loss_rate: ["0", "5", "3", "0", "2"],
|
||||
unit: ["EA", "EA", "EA", "EA", "EA"],
|
||||
remark: ["", "외주", "", "", ""],
|
||||
seq_no: ["1", "2", "3", "4", "5"],
|
||||
};
|
||||
const DUMMY_DEPTHS = [0, 1, 1, 0, 1];
|
||||
|
||||
const getDummyValue = (col: any, rowIdx: number): string => {
|
||||
const vals = DUMMY_DATA[col.key];
|
||||
if (vals) return vals[rowIdx % vals.length];
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">하위 품목 구성</h4>
|
||||
<Button size="sm" className="h-7 text-xs" disabled>
|
||||
|
|
@ -701,78 +790,91 @@ export function BomItemEditorComponent({
|
|||
트리: {cfg.parentKeyColumn}
|
||||
</span>
|
||||
)}
|
||||
{inputColumns.length > 0 && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">
|
||||
입력 {inputColumns.length}개
|
||||
</span>
|
||||
)}
|
||||
{sourceColumns.length > 0 && (
|
||||
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-600">
|
||||
표시 {sourceColumns.length}개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 더미 트리 미리보기 */}
|
||||
<div className="space-y-0.5 rounded-md border p-1.5">
|
||||
{dummyRows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded px-1.5 py-1",
|
||||
row.depth > 0 && "border-l-2 border-l-primary/20",
|
||||
i === 0 && "bg-accent/30",
|
||||
)}
|
||||
style={{ marginLeft: `${row.depth * 20}px` }}
|
||||
>
|
||||
<GripVertical className="text-muted-foreground h-3 w-3 shrink-0 opacity-40" />
|
||||
{row.depth === 0 ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<span className="text-muted-foreground w-4 text-center text-[10px]">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="w-16 shrink-0 truncate font-mono text-[10px] font-medium">
|
||||
{row.code}
|
||||
</span>
|
||||
<span className="min-w-[50px] flex-1 truncate text-[10px]">
|
||||
{row.name}
|
||||
</span>
|
||||
|
||||
{/* 소스 표시 컬럼 미리보기 */}
|
||||
{sourceColumns.slice(0, 2).map((col: any) => (
|
||||
<span
|
||||
key={col.key}
|
||||
className="w-12 shrink-0 truncate text-center text-[10px] text-blue-500"
|
||||
>
|
||||
{col.title}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* 입력 컬럼 미리보기 */}
|
||||
{inputColumns.slice(0, 2).map((col: any) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="h-5 w-12 shrink-0 rounded border bg-background text-center text-[10px] leading-5"
|
||||
>
|
||||
{col.key === "quantity" || col.title === "수량"
|
||||
? row.qty
|
||||
: ""}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
||||
<Plus className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{visibleColumns.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<Package className="text-muted-foreground mb-1.5 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
컬럼 탭에서 표시할 컬럼을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<table className="w-full text-[10px]">
|
||||
<thead className="bg-muted/60">
|
||||
<tr>
|
||||
<th className="w-6 px-1 py-1.5 text-center font-medium" />
|
||||
<th className="w-5 px-0.5 py-1.5 text-center font-medium">#</th>
|
||||
{visibleColumns.map((col: any) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-left font-medium",
|
||||
col.isSourceDisplay && "text-blue-600",
|
||||
)}
|
||||
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
|
||||
>
|
||||
{col.title}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-14 px-1 py-1.5 text-center font-medium">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DUMMY_DEPTHS.map((depth, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className={cn(
|
||||
"border-t transition-colors",
|
||||
rowIdx === 0 && "bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<td className="px-1 py-1 text-center">
|
||||
<div className="flex items-center justify-center gap-0.5" style={{ paddingLeft: `${depth * 10}px` }}>
|
||||
{depth === 0 ? (
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
) : (
|
||||
<span className="text-primary/40 text-[10px]">└</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-0.5 py-1 text-center">
|
||||
{rowIdx + 1}
|
||||
</td>
|
||||
{visibleColumns.map((col: any) => (
|
||||
<td key={col.key} className="px-1.5 py-0.5">
|
||||
{col.isSourceDisplay ? (
|
||||
<span className="truncate text-blue-600">
|
||||
{getDummyValue(col, rowIdx) || col.title}
|
||||
</span>
|
||||
) : col.editable !== false ? (
|
||||
<div className="h-5 rounded border bg-background px-1.5 text-[10px] leading-5">
|
||||
{getDummyValue(col, rowIdx)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{getDummyValue(col, rowIdx)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-1 py-1 text-center">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
||||
<Plus className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue