feat: enhance shipping plan editor and batch save functionality
- Added planDate support in batch save operations for shipping plans, allowing for more accurate scheduling. - Updated the ShippingPlanEditorComponent to handle planDate and splitKey for better management of shipping plans. - Enhanced validation logic to ensure that the total planned quantity does not exceed available stock during the editing process. - Introduced functionality to add and remove split rows dynamically, improving user experience in managing shipping plans. These changes aim to provide a more robust and flexible shipping plan management experience, facilitating better tracking and scheduling of shipping operations.
This commit is contained in:
parent
9decf13068
commit
359bf0e614
|
|
@ -333,8 +333,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||||
const savedPlans = [];
|
const savedPlans = [];
|
||||||
|
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
const { sourceId, planQty } = plan;
|
const { sourceId, planQty, planDate } = plan;
|
||||||
if (!sourceId || !planQty || planQty <= 0) continue;
|
if (!sourceId || !planQty || planQty <= 0) continue;
|
||||||
|
const planDateValue = planDate || null;
|
||||||
|
|
||||||
if (detectedSource === "detail") {
|
if (detectedSource === "detail") {
|
||||||
// 디테일 소스: detail_id로 저장
|
// 디테일 소스: detail_id로 저장
|
||||||
|
|
@ -368,9 +369,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||||
const insertRes = await client.query(
|
const insertRes = await client.query(
|
||||||
`INSERT INTO shipment_plan
|
`INSERT INTO shipment_plan
|
||||||
(company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by)
|
(company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by)
|
||||||
VALUES ($1, $2, $3, $4, CURRENT_DATE, 'READY', $5)
|
VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[companyCode, sourceId, detail.master_id, planQty, userId]
|
[companyCode, sourceId, detail.master_id, planQty, planDateValue, userId]
|
||||||
);
|
);
|
||||||
savedPlans.push(insertRes.rows[0]);
|
savedPlans.push(insertRes.rows[0]);
|
||||||
|
|
||||||
|
|
@ -410,9 +411,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||||
const insertRes = await client.query(
|
const insertRes = await client.query(
|
||||||
`INSERT INTO shipment_plan
|
`INSERT INTO shipment_plan
|
||||||
(company_code, sales_order_id, plan_qty, plan_date, status, created_by)
|
(company_code, sales_order_id, plan_qty, plan_date, status, created_by)
|
||||||
VALUES ($1, $2, $3, CURRENT_DATE, 'READY', $4)
|
VALUES ($1, $2, $3, COALESCE($4::date, CURRENT_DATE), 'READY', $5)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[companyCode, masterId, planQty, userId]
|
[companyCode, masterId, planQty, planDateValue, userId]
|
||||||
);
|
);
|
||||||
savedPlans.push(insertRes.rows[0]);
|
savedPlans.push(insertRes.rows[0]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -433,9 +433,9 @@ export const V2ButtonConfigPanel: React.FC<V2ButtonConfigPanelProps> = ({
|
||||||
loadAll();
|
loadAll();
|
||||||
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
|
}, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]);
|
||||||
|
|
||||||
// 화면 목록 로드 (모달 액션용)
|
// 화면 목록 로드 (모달/편집/네비게이트 액션용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionType !== "modal" && actionType !== "navigate") return;
|
if (actionType !== "modal" && actionType !== "navigate" && actionType !== "edit") return;
|
||||||
if (screens.length > 0) return;
|
if (screens.length > 0) return;
|
||||||
|
|
||||||
const loadScreens = async () => {
|
const loadScreens = async () => {
|
||||||
|
|
@ -870,7 +870,6 @@ const ActionDetailSection: React.FC<{
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
case "save":
|
case "save":
|
||||||
case "delete":
|
case "delete":
|
||||||
case "edit":
|
|
||||||
case "quickInsert":
|
case "quickInsert":
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||||
|
|
@ -879,7 +878,6 @@ const ActionDetailSection: React.FC<{
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{actionType === "save" && "저장 설정"}
|
{actionType === "save" && "저장 설정"}
|
||||||
{actionType === "delete" && "삭제 설정"}
|
{actionType === "delete" && "삭제 설정"}
|
||||||
{actionType === "edit" && "편집 설정"}
|
|
||||||
{actionType === "quickInsert" && "즉시 저장 설정"}
|
{actionType === "quickInsert" && "즉시 저장 설정"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -900,6 +898,147 @@ const ActionDetailSection: React.FC<{
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "edit":
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Pencil className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium">편집 설정</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 화면 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">수정 폼 화면</Label>
|
||||||
|
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={modalScreenOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
{screensLoading
|
||||||
|
? "로딩 중..."
|
||||||
|
: action.targetScreenId
|
||||||
|
? screens.find((s) => s.id === action.targetScreenId)?.name || `화면 #${action.targetScreenId}`
|
||||||
|
: "화면 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={modalSearchTerm}
|
||||||
|
onValueChange={setModalSearchTerm}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<CommandList className="max-h-48">
|
||||||
|
<CommandEmpty className="py-3 text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{screens
|
||||||
|
.filter((s) =>
|
||||||
|
!modalSearchTerm ||
|
||||||
|
s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
||||||
|
s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) ||
|
||||||
|
String(s.id).includes(modalSearchTerm)
|
||||||
|
)
|
||||||
|
.map((screen) => (
|
||||||
|
<CommandItem
|
||||||
|
key={screen.id}
|
||||||
|
value={String(screen.id)}
|
||||||
|
onSelect={() => {
|
||||||
|
updateActionConfig("targetScreenId", screen.id);
|
||||||
|
setModalScreenOpen(false);
|
||||||
|
setModalSearchTerm("");
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
action.targetScreenId === screen.id ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.name}</span>
|
||||||
|
{screen.description && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{screen.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 편집 모드 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">편집 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={action.editMode || "modal"}
|
||||||
|
onValueChange={(value) => updateActionConfig("editMode", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="modal" className="text-xs">모달로 열기</SelectItem>
|
||||||
|
<SelectItem value="navigate" className="text-xs">페이지 이동</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 모드일 때 추가 설정 */}
|
||||||
|
{(action.editMode || "modal") === "modal" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">모달 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={action.editModalTitle || ""}
|
||||||
|
onChange={(e) => updateActionConfig("editModalTitle", e.target.value)}
|
||||||
|
placeholder="데이터 수정"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">모달 설명</Label>
|
||||||
|
<Input
|
||||||
|
value={action.editModalDescription || ""}
|
||||||
|
onChange={(e) => updateActionConfig("editModalDescription", e.target.value)}
|
||||||
|
placeholder="모달 설명"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">모달 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={action.modalSize || "lg"}
|
||||||
|
onValueChange={(value) => updateActionConfig("modalSize", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sm" className="text-xs">작게 (sm)</SelectItem>
|
||||||
|
<SelectItem value="md" className="text-xs">보통 (md)</SelectItem>
|
||||||
|
<SelectItem value="lg" className="text-xs">크게 (lg)</SelectItem>
|
||||||
|
<SelectItem value="xl" className="text-xs">아주 크게 (xl)</SelectItem>
|
||||||
|
<SelectItem value="full" className="text-xs">전체 (full)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{commonMessageSection}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case "modal":
|
case "modal":
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export interface AggregateResponse {
|
||||||
export interface BatchSavePlan {
|
export interface BatchSavePlan {
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
planQty: number;
|
planQty: number;
|
||||||
|
planDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ID만 전달 → 백엔드에서 소스 테이블 자동 감지 + JOIN
|
// ID만 전달 → 백엔드에서 소스 테이블 자동 감지 + JOIN
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Factory,
|
Factory,
|
||||||
Truck,
|
Truck,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -92,6 +94,8 @@ export const ShippingPlanEditorComponent: React.FC<
|
||||||
dueDate: order.dueDate,
|
dueDate: order.dueDate,
|
||||||
balanceQty: remainingBalance,
|
balanceQty: remainingBalance,
|
||||||
planQty: 0,
|
planQty: 0,
|
||||||
|
planDate: new Date().toISOString().split("T")[0],
|
||||||
|
splitKey: `${order.sourceId}_0`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,7 +175,12 @@ export const ShippingPlanEditorComponent: React.FC<
|
||||||
const plans = currentGroups.flatMap((g) =>
|
const plans = currentGroups.flatMap((g) =>
|
||||||
g.details
|
g.details
|
||||||
.filter((d) => d.type === "new" && d.planQty > 0)
|
.filter((d) => d.type === "new" && d.planQty > 0)
|
||||||
.map((d) => ({ sourceId: d.sourceId, planQty: d.planQty, balanceQty: d.balanceQty }))
|
.map((d) => ({
|
||||||
|
sourceId: d.sourceId,
|
||||||
|
planQty: d.planQty,
|
||||||
|
planDate: d.planDate || new Date().toISOString().split("T")[0],
|
||||||
|
balanceQty: d.balanceQty,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (plans.length === 0) {
|
if (plans.length === 0) {
|
||||||
|
|
@ -179,12 +188,21 @@ export const ShippingPlanEditorComponent: React.FC<
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 잔량 초과 검증 (allowOverPlan = false일 때)
|
// 같은 sourceId별 합산 → 미출하량 초과 검증
|
||||||
if (!currentConfig.allowOverPlan) {
|
if (!currentConfig.allowOverPlan) {
|
||||||
const overPlan = plans.find((p) => p.balanceQty > 0 && p.planQty > p.balanceQty);
|
const sumBySource = new Map<string, { total: number; balance: number }>();
|
||||||
if (overPlan) {
|
for (const p of plans) {
|
||||||
toast.error("출하계획량이 미출하량을 초과합니다.");
|
const prev = sumBySource.get(p.sourceId) || { total: 0, balance: p.balanceQty };
|
||||||
return;
|
sumBySource.set(p.sourceId, {
|
||||||
|
total: prev.total + p.planQty,
|
||||||
|
balance: prev.balance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [, val] of sumBySource) {
|
||||||
|
if (val.balance > 0 && val.total > val.balance) {
|
||||||
|
toast.error("분할 출하계획의 합산이 미출하량을 초과합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,7 +215,7 @@ export const ShippingPlanEditorComponent: React.FC<
|
||||||
savingRef.current = true;
|
savingRef.current = true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty }));
|
const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty, planDate: p.planDate }));
|
||||||
const res = await batchSaveShippingPlans(savePlans, currentSource);
|
const res = await batchSaveShippingPlans(savePlans, currentSource);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(`${plans.length}건의 출하계획이 저장되었습니다.`);
|
toast.success(`${plans.length}건의 출하계획이 저장되었습니다.`);
|
||||||
|
|
@ -238,33 +256,106 @@ export const ShippingPlanEditorComponent: React.FC<
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 집계 재계산 헬퍼
|
||||||
|
const recalcAggregation = (group: ItemGroup): ItemGroup => {
|
||||||
|
const newPlanTotal = group.details
|
||||||
|
.filter((d) => d.type === "new")
|
||||||
|
.reduce((sum, d) => sum + d.planQty, 0);
|
||||||
|
const existingPlanTotal = group.details
|
||||||
|
.filter((d) => d.type === "existing")
|
||||||
|
.reduce((sum, d) => sum + d.planQty, 0);
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
aggregation: {
|
||||||
|
...group.aggregation,
|
||||||
|
totalPlanQty: existingPlanTotal + newPlanTotal,
|
||||||
|
availableStock:
|
||||||
|
group.aggregation.currentStock - (existingPlanTotal + newPlanTotal),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const handlePlanQtyChange = useCallback(
|
const handlePlanQtyChange = useCallback(
|
||||||
(groupIdx: number, detailIdx: number, value: string) => {
|
(groupIdx: number, detailIdx: number, value: string) => {
|
||||||
setItemGroups((prev) => {
|
setItemGroups((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const group = { ...next[groupIdx] };
|
const group = { ...next[groupIdx] };
|
||||||
const details = [...group.details];
|
const details = [...group.details];
|
||||||
const detail = { ...details[detailIdx] };
|
const target = details[detailIdx];
|
||||||
|
let qty = Number(value) || 0;
|
||||||
|
|
||||||
detail.planQty = Number(value) || 0;
|
// 같은 sourceId의 다른 신규 행 합산
|
||||||
details[detailIdx] = detail;
|
const otherSum = details
|
||||||
|
.filter((d, i) => d.type === "new" && d.sourceId === target.sourceId && i !== detailIdx)
|
||||||
|
.reduce((sum, d) => sum + d.planQty, 0);
|
||||||
|
|
||||||
|
// 잔여 가능량 = 미출하량 - 다른 분할 행 합산
|
||||||
|
const maxAllowed = Math.max(0, target.balanceQty - otherSum);
|
||||||
|
qty = Math.min(qty, maxAllowed);
|
||||||
|
qty = Math.max(0, qty);
|
||||||
|
|
||||||
|
details[detailIdx] = { ...details[detailIdx], planQty: qty };
|
||||||
group.details = details;
|
group.details = details;
|
||||||
|
next[groupIdx] = recalcAggregation(group);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const newPlanTotal = details
|
// 출하계획일 변경
|
||||||
.filter((d) => d.type === "new")
|
const handlePlanDateChange = useCallback(
|
||||||
.reduce((sum, d) => sum + d.planQty, 0);
|
(groupIdx: number, detailIdx: number, value: string) => {
|
||||||
const existingPlanTotal = details
|
setItemGroups((prev) => {
|
||||||
.filter((d) => d.type === "existing")
|
const next = [...prev];
|
||||||
.reduce((sum, d) => sum + d.planQty, 0);
|
const group = { ...next[groupIdx] };
|
||||||
|
const details = [...group.details];
|
||||||
|
details[detailIdx] = { ...details[detailIdx], planDate: value };
|
||||||
|
group.details = details;
|
||||||
|
next[groupIdx] = group;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
group.aggregation = {
|
// 분할 행 추가 (같은 수주에 새 행)
|
||||||
...group.aggregation,
|
const handleAddSplitRow = useCallback(
|
||||||
totalPlanQty: existingPlanTotal + newPlanTotal,
|
(groupIdx: number, sourceId: string) => {
|
||||||
availableStock:
|
setItemGroups((prev) => {
|
||||||
group.aggregation.currentStock -
|
const next = [...prev];
|
||||||
(existingPlanTotal + newPlanTotal),
|
const group = { ...next[groupIdx] };
|
||||||
|
const details = [...group.details];
|
||||||
|
|
||||||
|
// 같은 sourceId의 신규 행 중 마지막 찾기
|
||||||
|
const existingNewRows = details.filter(
|
||||||
|
(d) => d.type === "new" && d.sourceId === sourceId
|
||||||
|
);
|
||||||
|
const baseRow = existingNewRows[0];
|
||||||
|
if (!baseRow) return prev;
|
||||||
|
|
||||||
|
const splitCount = existingNewRows.length;
|
||||||
|
const newRow: PlanDetailRow = {
|
||||||
|
type: "new",
|
||||||
|
sourceId,
|
||||||
|
orderNo: baseRow.orderNo,
|
||||||
|
partnerName: baseRow.partnerName,
|
||||||
|
dueDate: baseRow.dueDate,
|
||||||
|
balanceQty: baseRow.balanceQty,
|
||||||
|
planQty: 0,
|
||||||
|
planDate: new Date().toISOString().split("T")[0],
|
||||||
|
splitKey: `${sourceId}_${splitCount}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 같은 sourceId 신규 행 바로 뒤에 삽입
|
||||||
|
const lastNewIdx = details.reduce(
|
||||||
|
(last, d, i) =>
|
||||||
|
d.type === "new" && d.sourceId === sourceId ? i : last,
|
||||||
|
-1
|
||||||
|
);
|
||||||
|
details.splice(lastNewIdx + 1, 0, newRow);
|
||||||
|
|
||||||
|
group.details = details;
|
||||||
next[groupIdx] = group;
|
next[groupIdx] = group;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
@ -272,6 +363,33 @@ export const ShippingPlanEditorComponent: React.FC<
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 분할 행 삭제
|
||||||
|
const handleRemoveSplitRow = useCallback(
|
||||||
|
(groupIdx: number, detailIdx: number) => {
|
||||||
|
setItemGroups((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const group = { ...next[groupIdx] };
|
||||||
|
const details = [...group.details];
|
||||||
|
const target = details[detailIdx];
|
||||||
|
|
||||||
|
// 같은 sourceId의 신규 행이 1개뿐이면 삭제 안 함 (최소 1행 유지)
|
||||||
|
const sameSourceNewCount = details.filter(
|
||||||
|
(d) => d.type === "new" && d.sourceId === target.sourceId
|
||||||
|
).length;
|
||||||
|
if (sameSourceNewCount <= 1) {
|
||||||
|
toast.warning("최소 1개의 출하계획 행이 필요합니다.");
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.splice(detailIdx, 1);
|
||||||
|
group.details = details;
|
||||||
|
next[groupIdx] = recalcAggregation(group);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col gap-3 rounded-lg border border-dashed border-gray-300 p-4">
|
<div className="flex h-full w-full flex-col gap-3 rounded-lg border border-dashed border-gray-300 p-4">
|
||||||
|
|
@ -353,6 +471,9 @@ export const ShippingPlanEditorComponent: React.FC<
|
||||||
details={group.details}
|
details={group.details}
|
||||||
groupIdx={groupIdx}
|
groupIdx={groupIdx}
|
||||||
onPlanQtyChange={handlePlanQtyChange}
|
onPlanQtyChange={handlePlanQtyChange}
|
||||||
|
onPlanDateChange={handlePlanDateChange}
|
||||||
|
onAddSplit={handleAddSplitRow}
|
||||||
|
onRemoveSplit={handleRemoveSplitRow}
|
||||||
showExisting={showExisting}
|
showExisting={showExisting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -467,25 +588,66 @@ const SummaryCards: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// sourceId별 그룹 (셀병합용)
|
||||||
|
interface SourceGroup {
|
||||||
|
sourceId: string;
|
||||||
|
orderNo: string;
|
||||||
|
partnerName: string;
|
||||||
|
dueDate: string;
|
||||||
|
balanceQty: number;
|
||||||
|
rows: (PlanDetailRow & { _origIdx: number })[];
|
||||||
|
}
|
||||||
|
|
||||||
const DetailTable: React.FC<{
|
const DetailTable: React.FC<{
|
||||||
details: PlanDetailRow[];
|
details: PlanDetailRow[];
|
||||||
groupIdx: number;
|
groupIdx: number;
|
||||||
onPlanQtyChange: (
|
onPlanQtyChange: (groupIdx: number, detailIdx: number, value: string) => void;
|
||||||
groupIdx: number,
|
onPlanDateChange: (groupIdx: number, detailIdx: number, value: string) => void;
|
||||||
detailIdx: number,
|
onAddSplit: (groupIdx: number, sourceId: string) => void;
|
||||||
value: string
|
onRemoveSplit: (groupIdx: number, detailIdx: number) => void;
|
||||||
) => void;
|
|
||||||
showExisting?: boolean;
|
showExisting?: boolean;
|
||||||
}> = ({ details, groupIdx, onPlanQtyChange, showExisting = true }) => {
|
}> = ({ details, groupIdx, onPlanQtyChange, onPlanDateChange, onAddSplit, onRemoveSplit, showExisting = true }) => {
|
||||||
const visibleDetails = details
|
const visibleDetails = details
|
||||||
.map((d, idx) => ({ ...d, _origIdx: idx }))
|
.map((d, idx) => ({ ...d, _origIdx: idx }))
|
||||||
.filter((d) => showExisting || d.type === "new");
|
.filter((d) => showExisting || d.type === "new");
|
||||||
|
|
||||||
|
// sourceId별 그룹핑 (순서 유지)
|
||||||
|
const sourceGroups = useMemo(() => {
|
||||||
|
const map = new Map<string, SourceGroup>();
|
||||||
|
const order: string[] = [];
|
||||||
|
for (const d of visibleDetails) {
|
||||||
|
if (!map.has(d.sourceId)) {
|
||||||
|
map.set(d.sourceId, {
|
||||||
|
sourceId: d.sourceId,
|
||||||
|
orderNo: d.orderNo,
|
||||||
|
partnerName: d.partnerName,
|
||||||
|
dueDate: d.dueDate,
|
||||||
|
balanceQty: d.balanceQty,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
order.push(d.sourceId);
|
||||||
|
}
|
||||||
|
map.get(d.sourceId)!.rows.push(d);
|
||||||
|
}
|
||||||
|
return order.map((id) => map.get(id)!);
|
||||||
|
}, [visibleDetails]);
|
||||||
|
|
||||||
|
// 같은 sourceId 신규 행들의 planQty 합산 (잔여량 계산용)
|
||||||
|
const getRemaining = (sourceId: string, excludeOrigIdx: number) => {
|
||||||
|
const group = sourceGroups.find((g) => g.sourceId === sourceId);
|
||||||
|
if (!group) return 0;
|
||||||
|
const otherSum = group.rows
|
||||||
|
.filter((r) => r.type === "new" && r._origIdx !== excludeOrigIdx)
|
||||||
|
.reduce((sum, r) => sum + r.planQty, 0);
|
||||||
|
return Math.max(0, group.balanceQty - otherSum);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
<th className="w-14 px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||||
구분
|
구분
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
|
@ -500,65 +662,124 @@ const DetailTable: React.FC<{
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
|
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
|
||||||
미출하
|
미출하
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
|
<th className="px-3 py-2 text-center text-xs font-medium text-muted-foreground">
|
||||||
출하계획량
|
출하계획량
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center text-xs font-medium text-muted-foreground">
|
||||||
|
출하계획일
|
||||||
|
</th>
|
||||||
|
<th className="w-16 px-2 py-2 text-center text-xs font-medium text-muted-foreground">
|
||||||
|
분할
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleDetails.map((detail, detailIdx) => (
|
{sourceGroups.map((sg) => {
|
||||||
<tr
|
const totalRows = sg.rows.length;
|
||||||
key={`${detail.type}-${detail.sourceId}-${detail.existingPlanId || detailIdx}`}
|
const newCount = sg.rows.filter((r) => r.type === "new").length;
|
||||||
className="border-b last:border-b-0 hover:bg-muted/30"
|
return sg.rows.map((row, rowIdx) => {
|
||||||
>
|
const isFirst = rowIdx === 0;
|
||||||
<td className="px-3 py-2">
|
const remaining = row.type === "new" ? getRemaining(row.sourceId, row._origIdx) : 0;
|
||||||
{detail.type === "existing" ? (
|
const isDisabled = row.type === "new" && sg.balanceQty <= 0;
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
|
||||||
기존
|
return (
|
||||||
</Badge>
|
<tr
|
||||||
) : (
|
key={row.splitKey || `${row.type}-${row.sourceId}-${row.existingPlanId || row._origIdx}`}
|
||||||
<Badge className="bg-primary text-[10px] text-primary-foreground">
|
className={`hover:bg-muted/30 ${rowIdx < totalRows - 1 ? "" : "border-b"}`}
|
||||||
신규
|
>
|
||||||
</Badge>
|
{/* 구분 - 매 행마다 표시 */}
|
||||||
)}
|
<td className={`px-3 py-2 ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
|
||||||
</td>
|
{row.type === "existing" ? (
|
||||||
<td className="px-3 py-2 text-xs">{detail.orderNo}</td>
|
<Badge variant="secondary" className="text-[10px]">기존</Badge>
|
||||||
<td className="px-3 py-2 text-xs">{detail.partnerName}</td>
|
) : (
|
||||||
<td className="px-3 py-2 text-xs">
|
<Badge className="bg-primary text-[10px] text-primary-foreground">신규</Badge>
|
||||||
{detail.dueDate || "-"}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-xs font-medium">
|
|
||||||
{detail.balanceQty.toLocaleString()}
|
{/* 수주번호, 거래처, 납기일, 미출하 - 첫 행만 rowSpan */}
|
||||||
</td>
|
{isFirst && (
|
||||||
<td className="px-3 py-2 text-right">
|
<>
|
||||||
{detail.type === "existing" ? (
|
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
|
||||||
<span className="text-xs text-muted-foreground">
|
{sg.orderNo}
|
||||||
{detail.planQty.toLocaleString()}
|
</td>
|
||||||
</span>
|
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
|
||||||
) : (
|
{sg.partnerName}
|
||||||
<Input
|
</td>
|
||||||
type="number"
|
<td className="border-t px-3 py-2 text-xs align-middle" rowSpan={totalRows}>
|
||||||
min={0}
|
{sg.dueDate || "-"}
|
||||||
max={
|
</td>
|
||||||
detail.balanceQty > 0 ? detail.balanceQty : undefined
|
<td className="border-t px-3 py-2 text-right text-xs font-bold align-middle" rowSpan={totalRows}>
|
||||||
}
|
{sg.balanceQty.toLocaleString()}
|
||||||
value={detail.planQty || ""}
|
</td>
|
||||||
onChange={(e) =>
|
</>
|
||||||
onPlanQtyChange(groupIdx, detail._origIdx, e.target.value)
|
)}
|
||||||
}
|
|
||||||
className="ml-auto h-7 w-24 text-right text-xs"
|
{/* 출하계획량 */}
|
||||||
placeholder="0"
|
<td className={`px-3 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
|
||||||
/>
|
{row.type === "existing" ? (
|
||||||
)}
|
<span className="text-xs text-muted-foreground">
|
||||||
</td>
|
{row.planQty.toLocaleString()}
|
||||||
</tr>
|
</span>
|
||||||
))}
|
) : (
|
||||||
{visibleDetails.length === 0 && (
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={remaining}
|
||||||
|
value={row.planQty || ""}
|
||||||
|
onChange={(e) => onPlanQtyChange(groupIdx, row._origIdx, e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="mx-auto h-7 w-24 text-center text-xs disabled:opacity-40"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 출하계획일 */}
|
||||||
|
<td className={`px-3 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
|
||||||
|
{row.type === "existing" ? (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={row.planDate || ""}
|
||||||
|
onChange={(e) => onPlanDateChange(groupIdx, row._origIdx, e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="mx-auto h-7 w-32 text-xs disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* 분할 버튼 */}
|
||||||
|
<td className={`px-2 py-2 text-center ${!isFirst ? "border-t border-dashed border-muted" : "border-t"}`}>
|
||||||
|
{row.type === "new" && isFirst && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddSplit(groupIdx, row.sourceId)}
|
||||||
|
disabled={sg.balanceQty <= 0}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-primary hover:bg-primary/10 disabled:opacity-30 disabled:cursor-not-allowed mx-auto"
|
||||||
|
title="분할 행 추가"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{row.type === "new" && !isFirst && newCount > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemoveSplit(groupIdx, row._origIdx)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-destructive hover:bg-destructive/10 mx-auto"
|
||||||
|
title="분할 행 삭제"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
{sourceGroups.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colSpan={8} className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
colSpan={6}
|
|
||||||
className="px-3 py-6 text-center text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
데이터가 없습니다
|
데이터가 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,9 @@ export interface PlanDetailRow {
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
balanceQty: number;
|
balanceQty: number;
|
||||||
planQty: number;
|
planQty: number;
|
||||||
|
planDate?: string;
|
||||||
existingPlanId?: number;
|
existingPlanId?: number;
|
||||||
|
splitKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemGroup {
|
export interface ItemGroup {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue