Files
pipeline/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx
T
DDD1542 f179a575ab feat: add shipping plan page with search and detail editing functionality
- Implemented the ShippingPlanPage component for managing shipment plans.
- Added search filters for date range, status, customer, and keywords.
- Integrated table for displaying shipment plans with grouping and selection features.
- Included detail panel for editing plan quantity, date, and memo with validation.
- Enhanced table readability with CSS adjustments for cell padding and hover effects.

style: improve global styles for table readability

- Adjusted padding and font sizes for table cells and headers.
- Added striped background for even rows and hover effects for better visibility.

fix: update TableSettingsModal for better overflow handling

- Modified modal layout to ensure proper scrolling for content overflow.
- Ensured drag-and-drop functionality for column settings remains intact.

chore: register new routes for COMPANY_7 and COMPANY_16 features

- Added dynamic imports for new pages related to purchase, logistics, quality, and design for COMPANY_7 and COMPANY_16.
2026-04-03 09:28:43 +09:00

590 lines
27 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 { Search, X, Save, RotateCcw, 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";
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[]>([]);
// 검색
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchCustomer, setSearchCustomer] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
// 상세 패널 편집
const [editPlanQty, setEditPlanQty] = useState("");
const [editPlanDate, setEditPlanDate] = useState("");
const [editMemo, setEditMemo] = useState("");
const [isDetailChanged, setIsDetailChanged] = useState(false);
const [saving, setSaving] = useState(false);
// 날짜 초기화
useEffect(() => {
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const oneMonthLater = new Date(today);
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
}, []);
// 데이터 조회
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
if (searchDateFrom) params.dateFrom = searchDateFrom;
if (searchDateTo) params.dateTo = searchDateTo;
if (searchStatus !== "all") params.status = searchStatus;
if (searchCustomer.trim()) params.customer = searchCustomer.trim();
if (searchKeyword.trim()) params.keyword = searchKeyword.trim();
const result = await getShipmentPlanList(params);
if (result.success) {
setData(result.data || []);
}
} catch (err) {
console.error("출하계획 조회 실패:", err);
} finally {
setLoading(false);
}
}, [searchDateFrom, searchDateTo, searchStatus, searchCustomer, searchKeyword]);
// 초기 로드 + 검색 시 자동 조회
useEffect(() => {
if (searchDateFrom && searchDateTo) {
fetchData();
}
}, [searchDateFrom, searchDateTo]);
const handleSearch = () => fetchData();
const handleResetSearch = () => {
setSearchStatus("all");
setSearchCustomer("");
setSearchKeyword("");
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const oneMonthLater = new Date(today);
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
};
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
const groupedData = useMemo(() => {
const orderMap = new Map<string, ShipmentPlanListItem[]>();
const orderKeys: string[] = [];
data.forEach(plan => {
const key = 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]);
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>
{/* 검색 필터 */}
<div className="bg-card border rounded-lg p-3 shrink-0">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Label className="text-[11px] font-semibold text-muted-foreground whitespace-nowrap"></Label>
<Input
type="date"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
className="w-[130px] h-9"
/>
<span className="text-muted-foreground/40 text-xs">~</span>
<Input
type="date"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
className="w-[130px] h-9"
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-[11px] font-semibold text-muted-foreground whitespace-nowrap"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[100px] h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map(o => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-[11px] font-semibold text-muted-foreground whitespace-nowrap"></Label>
<Input
placeholder="거래처 검색"
className="w-[130px] h-9"
value={searchCustomer}
onChange={(e) => setSearchCustomer(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-[11px] font-semibold text-muted-foreground whitespace-nowrap">/</Label>
<Input
placeholder="수주번호 / 품목 검색"
className="w-[180px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button variant="ghost" size="sm" onClick={handleResetSearch}>
<RotateCcw className="w-3.5 h-3.5" />
</Button>
<Button size="sm" onClick={handleSearch} disabled={loading}>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
</div>
{/* 마스터-디테일 */}
<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">
<Table>
<TableHeader className="sticky top-0 bg-muted/30 z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<Checkbox
checked={data.length > 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length}
onCheckedChange={handleCheckAll}
/>
</TableHead>
{ts.isVisible("order_no") && <TableHead className="w-[10%]"></TableHead>}
{ts.isVisible("due_date") && <TableHead className="w-[8%] text-center"></TableHead>}
{ts.isVisible("customer_name") && <TableHead className="w-[12%]"></TableHead>}
{ts.isVisible("part_code") && <TableHead className="w-[18%]"></TableHead>}
{ts.isVisible("part_name") && <TableHead className="w-[18%]"></TableHead>}
{ts.isVisible("order_qty") && <TableHead className="w-[7%] text-right"></TableHead>}
{ts.isVisible("plan_qty") && <TableHead className="w-[7%] text-right"></TableHead>}
{ts.isVisible("plan_date") && <TableHead className="w-[8%] text-center"></TableHead>}
{ts.isVisible("status") && <TableHead className="w-[6%] text-center"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-40 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
<Inbox className="w-5 h-5 text-muted-foreground/30" />
</div>
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground/60"> </p>
</div>
</TableCell>
</TableRow>
) : (
groupedData.map(group =>
group.plans.map((plan, planIdx) => (
<TableRow
key={plan.id}
className={cn(
"cursor-pointer transition-colors",
selectedId === plan.id && "bg-primary/5",
plan.status === "CANCELLED" && "opacity-50",
planIdx === 0 && "border-t-2 border-t-border"
)}
onClick={() => handleRowClick(plan)}
>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
{planIdx === 0 && (
<Checkbox
checked={group.plans.every(p => checkedIds.includes(p.id))}
onCheckedChange={(c) => {
if (c) {
setCheckedIds(prev => [...new Set([...prev, ...group.plans.filter(p => p.status !== "CANCELLED").map(p => p.id)])]);
} else {
setCheckedIds(prev => prev.filter(id => !group.plans.some(p => p.id === id)));
}
}}
/>
)}
</TableCell>
{ts.isVisible("order_no") && <TableCell className="font-medium text-sm">
{planIdx === 0 ? (plan.order_no || "-") : ""}
</TableCell>}
{ts.isVisible("due_date") && <TableCell className="text-center text-sm">
{planIdx === 0 ? formatDate(plan.due_date) : ""}
</TableCell>}
{ts.isVisible("customer_name") && <TableCell className="text-sm">
{planIdx === 0 ? (plan.customer_name || "-") : ""}
</TableCell>}
{ts.isVisible("part_code") && <TableCell className="text-muted-foreground text-[13px]">{plan.part_code || "-"}</TableCell>}
{ts.isVisible("part_name") && <TableCell className="font-medium text-sm">{plan.part_name || "-"}</TableCell>}
{ts.isVisible("order_qty") && <TableCell className="text-right text-sm">{formatNumber(plan.order_qty)}</TableCell>}
{ts.isVisible("plan_qty") && <TableCell className="text-right font-semibold text-primary text-sm">{formatNumber(plan.plan_qty)}</TableCell>}
{ts.isVisible("plan_date") && <TableCell className="text-center text-sm">{formatDate(plan.plan_date)}</TableCell>}
{ts.isVisible("status") && <TableCell className="text-center">
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(plan.status))}>
{getStatusLabel(plan.status)}
</span>
</TableCell>}
</TableRow>
))
)
)}
</TableBody>
</Table>
</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}
onSave={ts.applySettings}
/>
</div>
);
}