Enhance production plan service by adding lead time handling. Implemented checks for lead time column in item_info and adjusted scheduling logic accordingly. Updated frontend to reflect lead time in production plan management and shipping order pages, including Excel upload functionality for batch processing.

This commit is contained in:
kjs
2026-03-23 20:39:07 +09:00
parent cab0342081
commit 074626426b
7 changed files with 264 additions and 96 deletions
@@ -35,6 +35,33 @@ export async function getOrderSummary(
const whereClause = conditions.join(" AND ");
// item_info에 lead_time 컬럼이 존재하는지 확인
const leadTimeColCheck = await pool.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true;
const itemLeadTimeCte = hasLeadTime
? `item_lead_time AS (
SELECT
item_number,
id AS item_id,
COALESCE(lead_time, 0) AS lead_time
FROM item_info
WHERE company_code = $1
),`
: `item_lead_time AS (
SELECT
item_number,
id AS item_id,
0 AS lead_time
FROM item_info
WHERE company_code = $1
),`;
const query = `
WITH order_summary AS (
SELECT
@@ -49,6 +76,7 @@ export async function getOrderSummary(
WHERE ${whereClause}
GROUP BY so.part_code, so.part_name
),
${itemLeadTimeCte}
stock_info AS (
SELECT
item_code,
@@ -85,10 +113,12 @@ export async function getOrderSummary(
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
0
) AS required_plan_qty
) AS required_plan_qty,
COALESCE(ilt.lead_time, 0) AS lead_time
FROM order_summary os
LEFT JOIN stock_info si ON os.item_code = si.item_code
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id)
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
ORDER BY os.item_code;
`;
@@ -367,6 +397,7 @@ export async function previewSchedule(
}
const dailyCapacity = item.daily_capacity || 800;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty;
@@ -381,20 +412,32 @@ export async function previewSchedule(
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
endDate.setDate(endDate.getDate() + duration);
}
// 해당 품목의 수주 건수 확인
@@ -411,10 +454,11 @@ export async function previewSchedule(
required_qty: requiredQty,
daily_capacity: dailyCapacity,
hourly_capacity: item.hourly_capacity || 100,
production_days: productionDays,
production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date,
lead_time: itemLeadTime,
order_count: orderCount,
status: "planned",
});
@@ -490,25 +534,37 @@ export async function generateSchedule(
// 필요 수량 계산 (삭제된 planned 수량을 복원)
const dailyCapacity = item.daily_capacity || 800;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty + deletedQtyForItem;
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 시작일 = 납기일 - 생산일수 - 안전리드타임
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
// 시작일이 오늘보다 이전이면 오늘로 조정
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
endDate.setDate(endDate.getDate() + duration);
}
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
@@ -675,13 +731,24 @@ async function getBomChildItems(
companyCode: string,
itemCode: string
) {
// item_info에 lead_time 컬럼 존재 여부 확인
const colCheck = await client.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time, 0)" : "0";
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_number AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
bd.unit,
${leadTimeCol} AS child_lead_time
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
@@ -740,9 +807,12 @@ export async function previewSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = new Date(plan.start_date);
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
previews.push({
parent_plan_id: plan.id,
@@ -752,13 +822,14 @@ export async function previewSemiSchedule(
item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1,
lead_time: childLeadTime,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
: semiEndDate.toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
: semiEndDate.toISOString().split("T")[0],
product_type: "반제품",
status: "planned",
});
@@ -839,10 +910,12 @@ export async function generateSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(
@@ -109,7 +109,7 @@ export default function ProductionPlanManagementPage() {
const [searchEndDate, setSearchEndDate] = useState("");
// 타임라인 옵션
const [safetyLeadTime, setSafetyLeadTime] = useState(1);
// 리드타임은 품목정보(item_info)에서 관리
const [displayWeeks, setDisplayWeeks] = useState(4);
const [recalculateUnstarted, setRecalculateUnstarted] = useState(true);
@@ -333,6 +333,7 @@ export default function ProductionPlanManagementPage() {
item_name: item.item_name,
required_qty: Number(item.required_plan_qty),
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: Number(item.lead_time) || 0,
}));
setGenerating(true);
@@ -340,7 +341,7 @@ export default function ProductionPlanManagementPage() {
const req: GenerateScheduleRequest = {
items,
options: {
safety_lead_time: safetyLeadTime,
safety_lead_time: 0,
recalculate_unstarted: recalculateUnstarted,
product_type: "완제품",
},
@@ -357,7 +358,7 @@ export default function ProductionPlanManagementPage() {
} finally {
setGenerating(false);
}
}, [selectedItemGroups, orderItems, safetyLeadTime, recalculateUnstarted]);
}, [selectedItemGroups, orderItems, recalculateUnstarted]);
const handleApplySchedule = useCallback(async () => {
if (selectedItemGroups.size === 0) return;
@@ -369,6 +370,7 @@ export default function ProductionPlanManagementPage() {
item_name: item.item_name,
required_qty: Number(item.required_plan_qty),
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
lead_time: Number(item.lead_time) || 0,
}));
setGenerating(true);
@@ -376,7 +378,7 @@ export default function ProductionPlanManagementPage() {
const res = await generateSchedule({
items,
options: {
safety_lead_time: safetyLeadTime,
safety_lead_time: 0,
recalculate_unstarted: recalculateUnstarted,
product_type: "완제품",
},
@@ -393,7 +395,7 @@ export default function ProductionPlanManagementPage() {
} finally {
setGenerating(false);
}
}, [selectedItemGroups, orderItems, safetyLeadTime, recalculateUnstarted, fetchPlans, fetchOrderSummary]);
}, [selectedItemGroups, orderItems, recalculateUnstarted, fetchPlans, fetchOrderSummary]);
const handleClearTimeline = useCallback(async () => {
if (finishedPlans.length === 0) {
@@ -519,7 +521,7 @@ export default function ProductionPlanManagementPage() {
const res = await generateSchedule({
items,
options: {
safety_lead_time: safetyLeadTime,
safety_lead_time: 0,
recalculate_unstarted: importMode === "new" ? false : recalculateUnstarted,
product_type: "완제품",
},
@@ -535,7 +537,7 @@ export default function ProductionPlanManagementPage() {
} finally {
setGenerating(false);
}
}, [selectedItemGroups, orderItems, safetyLeadTime, importMode, recalculateUnstarted, fetchPlans, fetchOrderSummary]);
}, [selectedItemGroups, orderItems, importMode, recalculateUnstarted, fetchPlans, fetchOrderSummary]);
const handleImportStockItems = useCallback(async () => {
if (selectedStockItems.size === 0) {
@@ -556,7 +558,7 @@ export default function ProductionPlanManagementPage() {
const res = await generateSchedule({
items,
options: {
safety_lead_time: safetyLeadTime,
safety_lead_time: 0,
recalculate_unstarted: importMode === "new" ? false : true,
product_type: "완제품",
},
@@ -572,7 +574,7 @@ export default function ProductionPlanManagementPage() {
} finally {
setGenerating(false);
}
}, [selectedStockItems, stockItems, safetyLeadTime, importMode, fetchPlans, fetchStockShortage]);
}, [selectedStockItems, stockItems, importMode, fetchPlans, fetchStockShortage]);
// 숫자 포맷
const formatNumber = (num: number | string) => Number(num).toLocaleString();
@@ -822,6 +824,7 @@ export default function ProductionPlanManagementPage() {
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right"></TableHead>
<TableHead className="text-xs font-semibold whitespace-nowrap text-right">()</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -846,6 +849,9 @@ export default function ProductionPlanManagementPage() {
<TableCell className={cn("text-xs text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-emerald-600 dark:text-emerald-400")} onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item.required_plan_qty)}
</TableCell>
<TableCell className="text-xs text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{Number(item.lead_time) > 0 ? `${item.lead_time}` : "-"}
</TableCell>
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail) => (
@@ -866,7 +872,7 @@ export default function ProductionPlanManagementPage() {
<TableCell className="text-xs text-right">{formatNumber(detail.order_qty)}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(detail.ship_qty)}</TableCell>
<TableCell className="text-xs text-right">{formatNumber(detail.balance_qty)}</TableCell>
<TableCell colSpan={5} className="text-xs text-muted-foreground">
<TableCell colSpan={6} className="text-xs text-muted-foreground">
: {detail.due_date || "-"}
</TableCell>
</TableRow>
@@ -999,10 +1005,6 @@ export default function ProductionPlanManagementPage() {
<div className="rounded-lg border bg-muted/30 p-4 mb-4">
<div className="flex items-start justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className="flex flex-col gap-1">
<Label className="text-xs font-medium"> ()</Label>
<Input type="number" value={safetyLeadTime} onChange={(e) => setSafetyLeadTime(Number(e.target.value))} className="h-8 w-[100px] text-xs" min={0} max={10} />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs font-medium"> ()</Label>
<Input type="number" value={displayWeeks} onChange={(e) => setDisplayWeeks(Number(e.target.value))} className="h-8 w-[100px] text-xs" min={1} max={12} />
+24 -54
View File
@@ -62,9 +62,11 @@ import {
Check,
ChevronsUpDown,
Loader2,
FileSpreadsheet,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
// --- Types ---
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
@@ -94,57 +96,7 @@ interface SalesOrderOption {
status: string;
}
// --- Sample Data ---
const initialData: Claim[] = [
{
claimNo: "CLM-2025-004",
claimDate: "2025-11-09",
claimType: "불량",
claimStatus: "접수",
customerCode: "CUST-0001",
customerName: "주식회사 코아스포트",
managerName: "김철수",
orderNo: "SO-2025-0102",
claimContent: "제품 표면에 스크래치가 발견되었습니다.",
processContent: "",
},
{
claimNo: "CLM-2025-001",
claimDate: "2025-01-05",
claimType: "불량",
claimStatus: "접수",
customerCode: "CUST-0002",
customerName: "(주)현상산업",
managerName: "김철수",
orderNo: "SO-2025-0102",
claimContent: "제품 불량",
processContent: "",
},
{
claimNo: "CLM-2025-002",
claimDate: "2025-01-04",
claimType: "교환",
claimStatus: "처리중",
customerCode: "CUST-0003",
customerName: "대한전섬",
managerName: "이영희",
orderNo: "SO-2025-0095",
claimContent: "규격 불일치",
processContent: "교환 진행 중",
},
{
claimNo: "CLM-2025-003",
claimDate: "2025-01-03",
claimType: "반품",
claimStatus: "완료",
customerCode: "CUST-0004",
customerName: "삼성전자",
managerName: "박민수",
orderNo: "SO-2024-1285",
claimContent: "수량 초과 납품",
processContent: "반품 완료",
},
];
const initialData: Claim[] = [];
const getClaimTypeStyle = (type: ClaimType) => {
switch (type) {
@@ -193,6 +145,9 @@ export default function ClaimManagementPage() {
const [searchCustomer, setSearchCustomer] = useState("");
const [searchClaimNo, setSearchClaimNo] = useState("");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
@@ -563,9 +518,14 @@ export default function ClaimManagementPage() {
{filteredData.length}
</Badge>
</div>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
@@ -1122,6 +1082,16 @@ export default function ClaimManagementPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName="claim_mng"
onSuccess={() => {
// TODO: 클레임 테이블 API 연동 후 데이터 새로고침
}}
/>
</div>
);
}
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
@@ -24,6 +24,7 @@ import {
getSalesOrderSource,
getItemSource,
} from "@/lib/api/shipping";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
@@ -84,6 +85,9 @@ export default function ShippingOrderPage() {
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
@@ -467,6 +471,9 @@ export default function ShippingOrderPage() {
<Badge variant="secondary" className="font-normal">{orders.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={() => openModal()}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
@@ -821,6 +828,16 @@ export default function ShippingOrderPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName="shipment_instruction"
onSuccess={() => {
fetchOrders();
}}
/>
</div>
);
}
@@ -26,6 +26,9 @@ import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/servi
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
import { FileSpreadsheet, Loader2 as ExcelLoader } from "lucide-react";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
export interface ScreenViewPageProps {
screenIdProp?: number;
@@ -96,6 +99,11 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
// 엑셀 업로드 모달 상태
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{
@@ -650,8 +658,46 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
<TableOptionsProvider>
<div
ref={containerRef}
className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
className={`bg-background relative h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
>
{/* 엑셀 업로드 버튼 (테이블이 있는 화면에서만 표시) */}
{!isPreviewMode && screen?.tableName && (
<div className="absolute top-2 right-3 z-10">
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
disabled={excelDetecting}
onClick={async () => {
if (!screen?.tableName) return;
setExcelDetecting(true);
try {
const result = await autoDetectMultiTableConfig(screen.tableName, screenId);
if (result.success && result.data) {
setExcelChainConfig(result.data);
setExcelUploadOpen(true);
} else {
const { toast } = await import("sonner");
toast.error(result.message || "테이블 구조를 분석할 수 없습니다.");
}
} catch {
const { toast } = await import("sonner");
toast.error("테이블 구조 분석 중 오류가 발생했습니다.");
} finally {
setExcelDetecting(false);
}
}}
>
{excelDetecting ? (
<ExcelLoader className="h-3.5 w-3.5 animate-spin" />
) : (
<FileSpreadsheet className="h-3.5 w-3.5" />
)}
</Button>
</div>
)}
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="bg-muted/30 flex h-full w-full items-center justify-center">
@@ -801,6 +847,22 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
}}
/>
{/* 엑셀 업로드 모달 (멀티테이블 자동감지) */}
{excelChainConfig && (
<MultiTableExcelUploadModal
open={excelUploadOpen}
onOpenChange={(open) => {
setExcelUploadOpen(open);
if (!open) setExcelChainConfig(null);
}}
config={excelChainConfig}
onSuccess={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
setTableRefreshKey((prev) => prev + 1);
}}
/>
)}
{/* 스케줄 생성 확인 다이얼로그 */}
<ScheduleConfirmDialog
open={showConfirmDialog}
@@ -822,6 +822,37 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return true;
};
// 템플릿 다운로드: 테이블 스키마 기반으로 빈 엑셀 파일 생성
const handleDownloadTemplate = async () => {
try {
const { exportToExcel } = await import("@/lib/utils/excelExport");
const response = await getTableSchema(tableName);
if (!response.success || !response.data) {
toast.error("테이블 정보를 가져올 수 없습니다.");
return;
}
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
const columns = response.data.columns.filter(
(col) => !AUTO_COLS.includes(col.name.toLowerCase())
);
// 필수 컬럼에 * 표시
const headerRow: Record<string, any> = {};
for (const col of columns) {
const label = col.label || col.name;
const isRequired = !col.nullable;
headerRow[isRequired ? `${label} *` : label] = "";
}
await exportToExcel([headerRow], `${tableName}_템플릿.xlsx`, "Sheet1");
toast.success("템플릿 파일이 다운로드되었습니다.");
} catch (error) {
console.error("템플릿 다운로드 실패:", error);
toast.error("템플릿 다운로드에 실패했습니다.");
}
};
// 다음 단계
const handleNext = async () => {
if (currentStep === 1 && !file) {
@@ -1607,11 +1638,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div>
)}
{/* 파일 선택 영역 */}
{/* 템플릿 다운로드 + 파일 선택 영역 */}
<div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<div className="flex items-center justify-between">
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={handleDownloadTemplate}
>
<ArrowRight className="h-3 w-3 rotate-90" />
릿
</Button>
</div>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
+1
View File
@@ -19,6 +19,7 @@ export interface OrderSummaryItem {
existing_plan_qty: number;
in_progress_qty: number;
required_plan_qty: number;
lead_time: number;
orders: OrderDetail[];
}