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:
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface OrderSummaryItem {
|
||||
existing_plan_qty: number;
|
||||
in_progress_qty: number;
|
||||
required_plan_qty: number;
|
||||
lead_time: number;
|
||||
orders: OrderDetail[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user