- Implemented a new API endpoint for retrieving BOM materials based on item codes, enhancing the ability to manage and view component materials. - Added necessary SQL queries to fetch BOM details, ensuring that the data is filtered by company code for multi-tenancy support. - Updated frontend components to integrate BOM materials fetching, allowing for better visibility and management of materials in the process workflow. These changes aim to streamline the management of BOM materials, facilitating better tracking and organization within the application.
256 lines
8.4 KiB
TypeScript
256 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useMemo, useCallback } from "react";
|
|
import { Save, Loader2, ClipboardCheck } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { ProcessWorkStandardConfig, WorkItem } from "./types";
|
|
import { defaultConfig } from "./config";
|
|
import { useProcessWorkStandard } from "./hooks/useProcessWorkStandard";
|
|
import { ItemProcessSelector } from "./components/ItemProcessSelector";
|
|
import { WorkPhaseSection } from "./components/WorkPhaseSection";
|
|
import { WorkItemAddModal } from "./components/WorkItemAddModal";
|
|
|
|
interface ProcessWorkStandardComponentProps {
|
|
config?: Partial<ProcessWorkStandardConfig>;
|
|
formData?: Record<string, any>;
|
|
isPreview?: boolean;
|
|
tableName?: string;
|
|
screenId?: number | string;
|
|
}
|
|
|
|
export function ProcessWorkStandardComponent({
|
|
config: configProp,
|
|
isPreview,
|
|
screenId,
|
|
}: ProcessWorkStandardComponentProps) {
|
|
const resolvedConfig = useMemo(() => {
|
|
const merged = {
|
|
...configProp,
|
|
};
|
|
if (merged.itemListMode === "registered" && !merged.screenCode && screenId) {
|
|
merged.screenCode = `screen_${screenId}`;
|
|
}
|
|
return merged;
|
|
}, [configProp, screenId]);
|
|
|
|
const config: ProcessWorkStandardConfig = useMemo(
|
|
() => ({
|
|
...defaultConfig,
|
|
...resolvedConfig,
|
|
dataSource: { ...defaultConfig.dataSource, ...resolvedConfig?.dataSource },
|
|
phases: resolvedConfig?.phases?.length
|
|
? resolvedConfig.phases
|
|
: defaultConfig.phases,
|
|
detailTypes: resolvedConfig?.detailTypes?.length
|
|
? resolvedConfig.detailTypes
|
|
: defaultConfig.detailTypes,
|
|
}),
|
|
[resolvedConfig]
|
|
);
|
|
|
|
const {
|
|
items,
|
|
routings,
|
|
workItems,
|
|
selectedWorkItemIdByPhase,
|
|
selectedDetailsByPhase,
|
|
selection,
|
|
loading,
|
|
isRegisteredMode,
|
|
loadItems,
|
|
selectItem,
|
|
selectProcess,
|
|
fetchWorkItemDetails,
|
|
createWorkItem,
|
|
updateWorkItem,
|
|
deleteWorkItem,
|
|
createDetail,
|
|
updateDetail,
|
|
deleteDetail,
|
|
} = useProcessWorkStandard(config);
|
|
|
|
// 모달 상태
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [modalPhaseKey, setModalPhaseKey] = useState("");
|
|
const [editingItem, setEditingItem] = useState<WorkItem | null>(null);
|
|
|
|
// phase별 작업 항목 그룹핑
|
|
const workItemsByPhase = useMemo(() => {
|
|
const map: Record<string, WorkItem[]> = {};
|
|
for (const phase of config.phases) {
|
|
map[phase.key] = workItems.filter((wi) => wi.work_phase === phase.key);
|
|
}
|
|
return map;
|
|
}, [workItems, config.phases]);
|
|
|
|
const sortedPhases = useMemo(
|
|
() => [...config.phases].sort((a, b) => a.sortOrder - b.sortOrder),
|
|
[config.phases]
|
|
);
|
|
|
|
const handleAddWorkItem = useCallback((phaseKey: string) => {
|
|
setModalPhaseKey(phaseKey);
|
|
setEditingItem(null);
|
|
setModalOpen(true);
|
|
}, []);
|
|
|
|
const handleEditWorkItem = useCallback((item: WorkItem) => {
|
|
setModalPhaseKey(item.work_phase);
|
|
setEditingItem(item);
|
|
setModalOpen(true);
|
|
}, []);
|
|
|
|
const handleModalSave = useCallback(
|
|
async (data: Parameters<typeof createWorkItem>[0]) => {
|
|
if (editingItem) {
|
|
await updateWorkItem(editingItem.id, {
|
|
title: data.title,
|
|
is_required: data.is_required,
|
|
description: data.description,
|
|
} as any);
|
|
} else {
|
|
await createWorkItem(data);
|
|
}
|
|
},
|
|
[editingItem, createWorkItem, updateWorkItem]
|
|
);
|
|
|
|
const handleSelectWorkItem = useCallback(
|
|
(workItemId: string, phaseKey: string) => {
|
|
fetchWorkItemDetails(workItemId, phaseKey);
|
|
},
|
|
[fetchWorkItemDetails]
|
|
);
|
|
|
|
const handleInit = useCallback(() => {
|
|
loadItems();
|
|
}, [loadItems]);
|
|
|
|
const splitRatio = config.splitRatio || 30;
|
|
|
|
if (isPreview) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
|
|
<div className="text-center">
|
|
<ClipboardCheck className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
공정 작업기준
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
{sortedPhases.map((p) => p.label).join(" / ")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
|
|
{/* 메인 콘텐츠 */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* 좌측 패널 */}
|
|
<div style={{ width: `${splitRatio}%` }} className="shrink-0 overflow-hidden">
|
|
<ItemProcessSelector
|
|
title={config.leftPanelTitle || "품목 및 공정 선택"}
|
|
items={items}
|
|
routings={routings}
|
|
selection={selection}
|
|
onSearch={(keyword) => loadItems(keyword)}
|
|
onSelectItem={selectItem}
|
|
onSelectProcess={selectProcess}
|
|
onInit={handleInit}
|
|
/>
|
|
</div>
|
|
|
|
{/* 우측 패널 */}
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
{/* 우측 헤더 */}
|
|
{selection.routingDetailId ? (
|
|
<>
|
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
|
<div>
|
|
<h2 className="text-base font-bold">
|
|
{selection.itemName} - {selection.processName}
|
|
</h2>
|
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>품목: {selection.itemCode}</span>
|
|
<span>공정: {selection.processName}</span>
|
|
<span>버전: {selection.routingVersionName}</span>
|
|
</div>
|
|
</div>
|
|
{!config.readonly && (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="gap-1.5"
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Save className="h-3.5 w-3.5" />
|
|
)}
|
|
전체 저장
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 작업 단계별 섹션 */}
|
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
|
{sortedPhases.map((phase) => (
|
|
<WorkPhaseSection
|
|
key={phase.key}
|
|
phase={phase}
|
|
items={workItemsByPhase[phase.key] || []}
|
|
selectedWorkItemId={selectedWorkItemIdByPhase[phase.key] || null}
|
|
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
|
|
detailTypes={config.detailTypes}
|
|
readonly={config.readonly}
|
|
selectedItemCode={selection.itemCode || undefined}
|
|
onSelectWorkItem={handleSelectWorkItem}
|
|
onAddWorkItem={handleAddWorkItem}
|
|
onEditWorkItem={handleEditWorkItem}
|
|
onDeleteWorkItem={deleteWorkItem}
|
|
onCreateDetail={createDetail}
|
|
onUpdateDetail={updateDetail}
|
|
onDeleteDetail={deleteDetail}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
|
<ClipboardCheck className="mb-3 h-12 w-12 text-muted-foreground/30" />
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
좌측에서 품목과 공정을 선택하세요
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
품목을 펼쳐 라우팅별 공정을 선택하면 작업기준을 관리할 수
|
|
있습니다
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 작업 항목 추가/수정 모달 */}
|
|
<WorkItemAddModal
|
|
open={modalOpen}
|
|
onClose={() => {
|
|
setModalOpen(false);
|
|
setEditingItem(null);
|
|
}}
|
|
onSave={handleModalSave}
|
|
phaseKey={modalPhaseKey}
|
|
phaseLabel={
|
|
config.phases.find((p) => p.key === modalPhaseKey)?.label || ""
|
|
}
|
|
detailTypes={config.detailTypes}
|
|
editItem={editingItem}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|