455 lines
20 KiB
TypeScript
455 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
|
import { X, Save, Loader2, Inbox, Settings2 } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
getShipmentPlanList,
|
|
updateShipmentPlan,
|
|
type ShipmentPlanListItem,
|
|
} from "@/lib/api/shipping";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
|
|
const GRID_COLUMNS = [
|
|
{ key: "order_no", label: "수주번호" },
|
|
{ key: "due_date", label: "납기일" },
|
|
{ key: "customer_name", label: "거래처" },
|
|
{ key: "part_code", label: "품목코드" },
|
|
{ key: "part_name", label: "품목명" },
|
|
{ key: "order_qty", label: "수주수량" },
|
|
{ key: "plan_qty", label: "계획수량" },
|
|
{ key: "plan_date", label: "계획일" },
|
|
{ key: "status", label: "상태" },
|
|
];
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: "all", label: "전체" },
|
|
{ value: "READY", label: "준비" },
|
|
{ value: "CONFIRMED", label: "확정" },
|
|
{ value: "SHIPPING", label: "출하중" },
|
|
{ value: "COMPLETED", label: "완료" },
|
|
{ value: "CANCEL_REQUEST", label: "취소요청" },
|
|
{ value: "CANCELLED", label: "취소완료" },
|
|
];
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
const found = STATUS_OPTIONS.find(o => o.value === status);
|
|
return found?.label || status;
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case "READY": return "bg-primary/10 text-primary";
|
|
case "CONFIRMED": return "bg-secondary text-secondary-foreground";
|
|
case "SHIPPING": return "bg-warning/10 text-warning";
|
|
case "COMPLETED": return "bg-success/10 text-success";
|
|
case "CANCEL_REQUEST": return "bg-destructive/10 text-destructive";
|
|
case "CANCELLED": return "bg-muted text-muted-foreground";
|
|
default: return "bg-muted text-muted-foreground";
|
|
}
|
|
};
|
|
|
|
export default function ShippingPlanPage() {
|
|
const ts = useTableSettings("c16-shipping-plan", "shipment_plan", GRID_COLUMNS);
|
|
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
|
|
|
// 검색 필터 (DynamicSearchFilter에서 관리)
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
// 상세 패널 편집
|
|
const [editPlanQty, setEditPlanQty] = useState("");
|
|
const [editPlanDate, setEditPlanDate] = useState("");
|
|
const [editMemo, setEditMemo] = useState("");
|
|
const [isDetailChanged, setIsDetailChanged] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
// 데이터 조회
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: any = {};
|
|
for (const f of searchFilters) {
|
|
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
|
const [from, to] = f.value.split(",");
|
|
if (from) params.dateFrom = from;
|
|
if (to) params.dateTo = to;
|
|
} else if (f.columnName === "status") {
|
|
params.status = f.value;
|
|
} else if (f.columnName === "customer_name") {
|
|
params.customer = f.value;
|
|
} else {
|
|
params.keyword = f.value;
|
|
}
|
|
}
|
|
|
|
const result = await getShipmentPlanList(params);
|
|
if (result.success) {
|
|
setData(result.data || []);
|
|
}
|
|
} catch (err) {
|
|
console.error("출하계획 조회 실패:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchFilters]);
|
|
|
|
// searchFilters 변경 시 자동 조회
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
|
|
|
const groupedData = useMemo(() => {
|
|
const grouped = ts.groupData(data);
|
|
const orderMap = new Map<string, any[]>();
|
|
const orderKeys: string[] = [];
|
|
grouped.forEach(plan => {
|
|
const key = (plan as any)._isGroupSummary ? `_summary_${orderKeys.length}` : (plan.order_no || `_no_order_${plan.id}`);
|
|
if (!orderMap.has(key)) {
|
|
orderMap.set(key, []);
|
|
orderKeys.push(key);
|
|
}
|
|
orderMap.get(key)!.push(plan);
|
|
});
|
|
return orderKeys.map(key => ({
|
|
orderNo: key,
|
|
plans: orderMap.get(key)!,
|
|
}));
|
|
}, [data, ts.groupData]);
|
|
|
|
const handleRowClick = (plan: ShipmentPlanListItem) => {
|
|
if (isDetailChanged && selectedId !== plan.id) {
|
|
if (!confirm("변경사항이 있습니다. 저장하지 않고 이동하시겠습니까?")) return;
|
|
}
|
|
setSelectedId(plan.id);
|
|
setEditPlanQty(String(Number(plan.plan_qty)));
|
|
setEditPlanDate(plan.plan_date ? plan.plan_date.split("T")[0] : "");
|
|
setEditMemo(plan.memo || "");
|
|
setIsDetailChanged(false);
|
|
};
|
|
|
|
const handleCheckAll = (checked: boolean) => {
|
|
if (checked) {
|
|
setCheckedIds(data.filter(p => p.status !== "CANCELLED").map(p => p.id));
|
|
} else {
|
|
setCheckedIds([]);
|
|
}
|
|
};
|
|
|
|
const handleSaveDetail = async () => {
|
|
if (!selectedId || !selectedPlan) return;
|
|
|
|
const qty = Number(editPlanQty);
|
|
if (qty <= 0) {
|
|
alert("계획수량은 0보다 커야 해요.");
|
|
return;
|
|
}
|
|
if (!editPlanDate) {
|
|
alert("출하계획일을 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const result = await updateShipmentPlan(selectedId, {
|
|
planQty: qty,
|
|
planDate: editPlanDate,
|
|
memo: editMemo,
|
|
});
|
|
if (result.success) {
|
|
setIsDetailChanged(false);
|
|
alert("저장되었어요.");
|
|
fetchData();
|
|
} else {
|
|
alert(result.message || "저장 실패");
|
|
}
|
|
} catch (err: any) {
|
|
alert(err.message || "저장 중 오류 발생");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
if (!dateStr) return "-";
|
|
return dateStr.split("T")[0];
|
|
};
|
|
|
|
const formatNumber = (val: string | number) => {
|
|
const num = Number(val);
|
|
return isNaN(num) ? "0" : num.toLocaleString();
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full p-4 gap-3">
|
|
{/* 브레드크럼 */}
|
|
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
|
<span>영업관리</span>
|
|
<span className="text-muted-foreground/40">/</span>
|
|
<span className="font-semibold text-foreground">출하계획</span>
|
|
</nav>
|
|
|
|
{/* 검색 필터 (DynamicSearchFilter) */}
|
|
<DynamicSearchFilter
|
|
tableName="shipment_plan"
|
|
filterId="c16-shipping-plan"
|
|
onFilterChange={setSearchFilters}
|
|
dataCount={data.length}
|
|
externalFilterConfig={ts.filterConfig}
|
|
/>
|
|
|
|
{/* 마스터-디테일 */}
|
|
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
|
|
<ResizablePanelGroup direction="horizontal">
|
|
{/* 좌측: 출하계획 목록 */}
|
|
<ResizablePanel defaultSize={60} minSize={30}>
|
|
<div className="flex flex-col h-full">
|
|
{/* 패널 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
|
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
|
{data.length}건
|
|
</span>
|
|
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="flex-1 overflow-auto">
|
|
<EDataTable
|
|
columns={[
|
|
{ key: "order_no", label: "수주번호", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
|
{ key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
|
{ key: "customer_name", label: "거래처", render: (val: any) => <span className="text-sm">{val || "-"}</span> },
|
|
{ key: "part_code", label: "품목코드", render: (val: any) => <span className="text-muted-foreground text-[13px]">{val || "-"}</span> },
|
|
{ key: "part_name", label: "품목명", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
|
{ key: "order_qty", label: "수주수량", align: "right" as const, formatNumber: true },
|
|
{ key: "plan_qty", label: "계획수량", align: "right" as const, render: (val: any) => <span className="font-semibold text-primary text-sm">{formatNumber(val)}</span> },
|
|
{ key: "plan_date", label: "계획일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
|
{ key: "status", label: "상태", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(val))}>{getStatusLabel(val)}</span> },
|
|
] as EDataTableColumn<ShipmentPlanListItem>[]}
|
|
data={data}
|
|
rowKey={(row) => String(row.id)}
|
|
loading={loading}
|
|
emptyMessage="출하계획이 없어요"
|
|
selectedId={selectedId !== null ? String(selectedId) : null}
|
|
onSelect={(id) => {
|
|
if (id) {
|
|
const plan = data.find(p => String(p.id) === id);
|
|
if (plan) handleRowClick(plan);
|
|
}
|
|
}}
|
|
onRowClick={(row) => handleRowClick(row)}
|
|
showCheckbox
|
|
checkedIds={checkedIds.map(String)}
|
|
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
|
showPagination={false}
|
|
draggableColumns={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 우측: 상세 패널 */}
|
|
<ResizablePanel defaultSize={40} minSize={20}>
|
|
<div className="flex flex-col h-full">
|
|
{/* 패널 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[13px] font-bold text-foreground">출하계획 상세</span>
|
|
{selectedPlan && (
|
|
<span className="text-[11px] font-mono text-muted-foreground">{selectedPlan.shipment_plan_no}</span>
|
|
)}
|
|
</div>
|
|
{selectedPlan && (
|
|
<div className="flex items-center gap-1.5">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveDetail}
|
|
disabled={!isDetailChanged || saving}
|
|
variant={isDetailChanged ? "default" : "secondary"}
|
|
>
|
|
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
|
저장
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setSelectedId(null)}>
|
|
<X className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 상세 컨텐츠 */}
|
|
{selectedPlan ? (
|
|
<div className="flex-1 overflow-auto p-4 space-y-4">
|
|
{/* 기본 정보 */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">상태</p>
|
|
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium inline-block", getStatusColor(selectedPlan.status))}>
|
|
{getStatusLabel(selectedPlan.status)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">수주번호</p>
|
|
<p className="text-sm font-mono text-muted-foreground">{selectedPlan.order_no || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">거래처</p>
|
|
<p className="text-sm font-medium text-foreground">{selectedPlan.customer_name || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">납기일</p>
|
|
<p className="text-sm text-foreground">{formatDate(selectedPlan.due_date)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t" />
|
|
|
|
{/* 품목 정보 */}
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-2">품목 정보</p>
|
|
<div className="grid grid-cols-2 gap-3 bg-muted/30 border rounded-md p-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">품목코드</p>
|
|
<p className="text-sm font-mono text-muted-foreground">{selectedPlan.part_code || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">품목명</p>
|
|
<p className="text-sm font-medium text-foreground">{selectedPlan.part_name || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">규격</p>
|
|
<p className="text-sm text-foreground">{selectedPlan.spec || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">재질</p>
|
|
<p className="text-sm text-foreground">{selectedPlan.material || "-"}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t" />
|
|
|
|
{/* 수량 정보 */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">수주수량</p>
|
|
<p className="text-sm text-foreground">{formatNumber(selectedPlan.order_qty)}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">계획수량</Label>
|
|
<Input
|
|
type="number"
|
|
className="h-9"
|
|
value={editPlanQty}
|
|
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
|
|
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">출하수량</p>
|
|
<p className="text-sm text-foreground">{formatNumber(selectedPlan.shipped_qty)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">잔여수량</p>
|
|
<p className={cn(
|
|
"font-semibold text-sm",
|
|
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
|
|
? "text-destructive"
|
|
: "text-success"
|
|
)}>
|
|
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t" />
|
|
|
|
{/* 출하 정보 */}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">출하계획일</Label>
|
|
<Input
|
|
type="date"
|
|
className="h-9"
|
|
value={editPlanDate}
|
|
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
|
|
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">비고</Label>
|
|
<Textarea
|
|
className="min-h-[80px] resize-y text-sm"
|
|
value={editMemo}
|
|
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
|
|
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
|
placeholder="비고를 입력해주세요"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t" />
|
|
|
|
{/* 등록 정보 */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">등록자</p>
|
|
<p className="text-sm text-foreground">{selectedPlan.created_by || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">등록일시</p>
|
|
<p className="text-sm text-foreground">
|
|
{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full text-center p-8 text-muted-foreground">
|
|
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center mb-4">
|
|
<Inbox className="w-6 h-6 text-muted-foreground/30" />
|
|
</div>
|
|
<p className="text-sm font-medium">좌측에서 출하계획을 선택해주세요</p>
|
|
<p className="text-xs text-muted-foreground/60 mt-1">선택하면 상세 정보가 표시돼요</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|