576 lines
27 KiB
TypeScript
576 lines
27 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ShippingPlanModal — 출하계획 모달 컴포넌트
|
||
|
|
*
|
||
|
|
* 기존 shipping-plan 페이지의 기능을 Dialog 안에 재구현.
|
||
|
|
* orderNo prop이 있으면 해당 수주의 출하계획만 필터링하여 표시.
|
||
|
|
*/
|
||
|
|
|
||
|
|
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 { Badge } from "@/components/ui/badge";
|
||
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Textarea } from "@/components/ui/textarea";
|
||
|
|
import {
|
||
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||
|
|
import { Search, X, Save, RotateCcw, Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import {
|
||
|
|
getShipmentPlanList,
|
||
|
|
updateShipmentPlan,
|
||
|
|
type ShipmentPlanListItem,
|
||
|
|
} from "@/lib/api/shipping";
|
||
|
|
|
||
|
|
interface ShippingPlanModalProps {
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
orderNo?: string;
|
||
|
|
onUpdated?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
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-blue-100 text-blue-800 border-blue-200";
|
||
|
|
case "CONFIRMED": return "bg-indigo-100 text-indigo-800 border-indigo-200";
|
||
|
|
case "SHIPPING": return "bg-amber-100 text-amber-800 border-amber-200";
|
||
|
|
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
||
|
|
case "CANCEL_REQUEST": return "bg-rose-100 text-rose-800 border-rose-200";
|
||
|
|
case "CANCELLED": return "bg-slate-100 text-slate-800 border-slate-200";
|
||
|
|
default: return "bg-gray-100 text-gray-800 border-gray-200";
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
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();
|
||
|
|
};
|
||
|
|
|
||
|
|
export function ShippingPlanModal({ open, onOpenChange, orderNo, onUpdated }: ShippingPlanModalProps) {
|
||
|
|
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||
|
|
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
|
|
|
||
|
|
// 검색
|
||
|
|
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(() => {
|
||
|
|
if (!open) return;
|
||
|
|
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]);
|
||
|
|
setSearchStatus("all");
|
||
|
|
setSearchCustomer("");
|
||
|
|
setSearchKeyword(orderNo || "");
|
||
|
|
setSelectedId(null);
|
||
|
|
setCheckedIds([]);
|
||
|
|
setIsDetailChanged(false);
|
||
|
|
setIsFullscreen(false);
|
||
|
|
}, [open, orderNo]);
|
||
|
|
|
||
|
|
// 데이터 조회
|
||
|
|
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 (open && searchDateFrom && searchDateTo) {
|
||
|
|
fetchData();
|
||
|
|
}
|
||
|
|
}, [open, searchDateFrom, searchDateTo]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
|
|
||
|
|
const handleSearch = () => fetchData();
|
||
|
|
|
||
|
|
const handleResetSearch = () => {
|
||
|
|
setSearchStatus("all");
|
||
|
|
setSearchCustomer("");
|
||
|
|
setSearchKeyword(orderNo || "");
|
||
|
|
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 handleCheck = (id: number, checked: boolean) => {
|
||
|
|
if (checked) {
|
||
|
|
setCheckedIds((prev) => [...prev, id]);
|
||
|
|
} else {
|
||
|
|
setCheckedIds((prev) => prev.filter((i) => i !== id));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
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();
|
||
|
|
onUpdated?.();
|
||
|
|
} else {
|
||
|
|
alert(result.message || "저장 실패");
|
||
|
|
}
|
||
|
|
} catch (err: any) {
|
||
|
|
alert(err.message || "저장 중 오류 발생");
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogContent
|
||
|
|
className={cn(
|
||
|
|
"overflow-hidden flex flex-col transition-all duration-200 p-0",
|
||
|
|
isFullscreen
|
||
|
|
? "max-w-[100vw] max-h-[100vh] w-[100vw] h-[100vh] rounded-none"
|
||
|
|
: "max-w-6xl h-[85vh]"
|
||
|
|
)}
|
||
|
|
onInteractOutside={(e) => e.preventDefault()}
|
||
|
|
>
|
||
|
|
<DialogHeader className="px-4 pt-4 pb-2 shrink-0">
|
||
|
|
<div className="flex items-center justify-between pr-8">
|
||
|
|
<div>
|
||
|
|
<DialogTitle>출하계획 관리</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
{orderNo ? `수주번호 ${orderNo}의 출하계획` : "전체 출하계획을 관리합니다."}
|
||
|
|
</DialogDescription>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||
|
|
onClick={() => setIsFullscreen((prev) => !prev)}
|
||
|
|
title={isFullscreen ? "기본 크기" : "전체 화면"}
|
||
|
|
>
|
||
|
|
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{/* 검색 영역 */}
|
||
|
|
<div className="px-4 pb-2 shrink-0 flex flex-wrap items-end gap-3 border-b">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Label className="text-xs text-muted-foreground">출하계획일</Label>
|
||
|
|
<div className="flex items-center gap-1.5">
|
||
|
|
<Input type="date" className="w-[130px] h-8 text-xs" value={searchDateFrom}
|
||
|
|
onChange={(e) => setSearchDateFrom(e.target.value)} />
|
||
|
|
<span className="text-muted-foreground text-xs">~</span>
|
||
|
|
<Input type="date" className="w-[130px] h-8 text-xs" value={searchDateTo}
|
||
|
|
onChange={(e) => setSearchDateTo(e.target.value)} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Label className="text-xs text-muted-foreground">상태</Label>
|
||
|
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||
|
|
<SelectTrigger className="w-[100px] h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{STATUS_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Label className="text-xs text-muted-foreground">거래처</Label>
|
||
|
|
<Input placeholder="거래처" className="w-[120px] h-8 text-xs" value={searchCustomer}
|
||
|
|
onChange={(e) => setSearchCustomer(e.target.value)}
|
||
|
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Label className="text-xs text-muted-foreground">수주번호/품목</Label>
|
||
|
|
<Input placeholder="수주번호/품목" className="w-[160px] h-8 text-xs" value={searchKeyword}
|
||
|
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||
|
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-1.5 pb-1">
|
||
|
|
<Button size="sm" className="h-8 text-xs" onClick={handleSearch} disabled={loading}>
|
||
|
|
{loading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Search className="w-3.5 h-3.5 mr-1" />}
|
||
|
|
조회
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={handleResetSearch}>
|
||
|
|
<RotateCcw className="w-3.5 h-3.5 mr-1" /> 초기화
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 목록 + 상세 패널 */}
|
||
|
|
<div className="flex-1 overflow-hidden">
|
||
|
|
<ResizablePanelGroup direction="horizontal">
|
||
|
|
<ResizablePanel defaultSize={selectedId ? 60 : 100} minSize={30}>
|
||
|
|
<div className="flex flex-col h-full">
|
||
|
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/10 shrink-0">
|
||
|
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||
|
|
출하계획 목록
|
||
|
|
<Badge variant="secondary" className="font-normal">{data.length}건</Badge>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex-1 overflow-auto">
|
||
|
|
{loading ? (
|
||
|
|
<div className="flex items-center justify-center h-32">
|
||
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<Table>
|
||
|
|
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="w-[36px] text-center">
|
||
|
|
<Checkbox
|
||
|
|
checked={data.length > 0 && checkedIds.length === data.filter((p) => p.status !== "CANCELLED").length}
|
||
|
|
onCheckedChange={handleCheckAll}
|
||
|
|
/>
|
||
|
|
</TableHead>
|
||
|
|
<TableHead className="w-[130px]">수주번호</TableHead>
|
||
|
|
<TableHead className="w-[90px] text-center">납기일</TableHead>
|
||
|
|
<TableHead className="w-[100px]">거래처</TableHead>
|
||
|
|
<TableHead className="w-[90px]">품목코드</TableHead>
|
||
|
|
<TableHead>품목명</TableHead>
|
||
|
|
<TableHead className="w-[70px] text-right">수주수량</TableHead>
|
||
|
|
<TableHead className="w-[70px] text-right">계획수량</TableHead>
|
||
|
|
<TableHead className="w-[90px] text-center">출하계획일</TableHead>
|
||
|
|
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{groupedData.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
|
||
|
|
출하계획이 없습니다.
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
groupedData.map((group) =>
|
||
|
|
group.plans.map((plan, planIdx) => (
|
||
|
|
<TableRow
|
||
|
|
key={plan.id}
|
||
|
|
className={cn(
|
||
|
|
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||
|
|
selectedId === plan.id && "bg-primary/5",
|
||
|
|
plan.status === "CANCELLED" && "opacity-60 bg-slate-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>
|
||
|
|
<TableCell className="font-medium text-xs">
|
||
|
|
{planIdx === 0 ? (plan.order_no || "-") : ""}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-center text-xs">
|
||
|
|
{planIdx === 0 ? formatDate(plan.due_date) : ""}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs">
|
||
|
|
{planIdx === 0 ? (plan.customer_name || "-") : ""}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell>
|
||
|
|
<TableCell className="text-xs font-medium">{plan.part_name || "-"}</TableCell>
|
||
|
|
<TableCell className="text-right text-xs">{formatNumber(plan.order_qty)}</TableCell>
|
||
|
|
<TableCell className="text-right text-xs font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell>
|
||
|
|
<TableCell className="text-center text-xs">{formatDate(plan.plan_date)}</TableCell>
|
||
|
|
<TableCell className="text-center">
|
||
|
|
<span className={cn("px-1.5 py-0.5 rounded-full text-[10px] font-medium border", getStatusColor(plan.status))}>
|
||
|
|
{getStatusLabel(plan.status)}
|
||
|
|
</span>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</ResizablePanel>
|
||
|
|
|
||
|
|
{/* 상세 패널 */}
|
||
|
|
{selectedId && selectedPlan && (
|
||
|
|
<>
|
||
|
|
<ResizableHandle withHandle />
|
||
|
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||
|
|
<div className="flex flex-col h-full bg-card">
|
||
|
|
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
|
||
|
|
<span className="font-semibold text-sm">
|
||
|
|
{selectedPlan.shipment_plan_no || `#${selectedPlan.id}`}
|
||
|
|
</span>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
onClick={handleSaveDetail}
|
||
|
|
disabled={!isDetailChanged || saving}
|
||
|
|
className={cn("h-8", isDetailChanged ? "bg-primary" : "bg-muted text-muted-foreground")}
|
||
|
|
>
|
||
|
|
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
|
||
|
|
저장
|
||
|
|
</Button>
|
||
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setSelectedId(null)}>
|
||
|
|
<X className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex-1 overflow-auto p-4 space-y-5">
|
||
|
|
{/* 기본 정보 */}
|
||
|
|
<section>
|
||
|
|
<h3 className="text-sm font-semibold mb-2">기본 정보</h3>
|
||
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm">
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">상태</span>
|
||
|
|
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getStatusColor(selectedPlan.status))}>
|
||
|
|
{getStatusLabel(selectedPlan.status)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">수주번호</span>
|
||
|
|
<span>{selectedPlan.order_no || "-"}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">거래처</span>
|
||
|
|
<span>{selectedPlan.customer_name || "-"}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">납기일</span>
|
||
|
|
<span>{formatDate(selectedPlan.due_date)}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* 품목 정보 */}
|
||
|
|
<section>
|
||
|
|
<h3 className="text-sm font-semibold mb-2">품목 정보</h3>
|
||
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm bg-muted/30 p-3 rounded-md border border-border/50">
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">품목코드</span>
|
||
|
|
<span>{selectedPlan.part_code || "-"}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">품목명</span>
|
||
|
|
<span className="font-medium">{selectedPlan.part_name || "-"}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">규격</span>
|
||
|
|
<span>{selectedPlan.spec || "-"}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">재질</span>
|
||
|
|
<span>{selectedPlan.material || "-"}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* 수량 정보 */}
|
||
|
|
<section>
|
||
|
|
<h3 className="text-sm font-semibold mb-2">수량 정보</h3>
|
||
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">수주수량</span>
|
||
|
|
<span>{formatNumber(selectedPlan.order_qty)}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-muted-foreground text-xs block mb-0.5">계획수량</Label>
|
||
|
|
<Input
|
||
|
|
type="number" className="h-8 text-sm"
|
||
|
|
value={editPlanQty}
|
||
|
|
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
|
||
|
|
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">출하수량</span>
|
||
|
|
<span>{formatNumber(selectedPlan.shipped_qty)}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-muted-foreground text-xs block mb-0.5">잔여수량</span>
|
||
|
|
<span className={cn("font-semibold",
|
||
|
|
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
|
||
|
|
? "text-destructive"
|
||
|
|
: "text-emerald-600"
|
||
|
|
)}>
|
||
|
|
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* 출하 정보 */}
|
||
|
|
<section>
|
||
|
|
<h3 className="text-sm font-semibold mb-2">출하 정보</h3>
|
||
|
|
<div className="grid grid-cols-1 gap-y-3 text-sm">
|
||
|
|
<div>
|
||
|
|
<Label className="text-muted-foreground text-xs block mb-0.5">출하계획일</Label>
|
||
|
|
<Input
|
||
|
|
type="date" className="h-8 text-sm"
|
||
|
|
value={editPlanDate}
|
||
|
|
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
|
||
|
|
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-muted-foreground text-xs block mb-0.5">비고</Label>
|
||
|
|
<Textarea
|
||
|
|
className="min-h-[70px] resize-y text-sm"
|
||
|
|
value={editMemo}
|
||
|
|
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
|
||
|
|
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||
|
|
placeholder="비고 입력"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* 등록 정보 */}
|
||
|
|
<section>
|
||
|
|
<h3 className="text-sm font-semibold mb-2">등록 정보</h3>
|
||
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm text-muted-foreground">
|
||
|
|
<div>
|
||
|
|
<span className="text-xs block mb-0.5">등록자</span>
|
||
|
|
<span className="text-foreground">{selectedPlan.created_by || "-"}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-xs block mb-0.5">등록일시</span>
|
||
|
|
<span className="text-foreground">{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</ResizablePanel>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</ResizablePanelGroup>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|