ERP-node/frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundConfig.tsx

562 lines
18 KiB
TypeScript

"use client";
/**
* pop-cart-outbound 디자이너 설정 패널
*
* 3탭 구성: [데이터] [카드] [장바구니]
*
* 사용자 설정 가능 영역:
* - 데이터 소스 (테이블, 조인, 필터, 정렬)
* - 카드 헤더 컬럼 매핑 (품목명, 코드, 단위)
* - 스탯 필드 (추가/삭제/정렬, 라벨+컬럼+포맷)
* - 수량 입력 (라벨, 기본값 컬럼, 최대값 컬럼, 단위)
* - 빈 상태 메시지
*
* 고정 영역 (설정 노출 안 함):
* - 장바구니 로직 (useCartSync)
* - 카드 레이아웃 (세로형)
* - 담기/취소 버튼
*/
import React, { useState, useEffect } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
PopCartOutboundConfig,
OutboundStatField,
CardListDataSource,
StatFieldFormat,
} from "../types";
import {
fetchTableList,
fetchTableColumns,
type TableInfo,
type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher";
import { TableCombobox } from "../pop-shared/TableCombobox";
// ===== Props =====
interface ConfigPanelProps {
config: PopCartOutboundConfig | undefined;
onUpdate: (config: PopCartOutboundConfig) => void;
}
// ===== 기본값 =====
const DEFAULT_CONFIG: PopCartOutboundConfig = {
dataSource: { tableName: "" },
keyColumn: "id",
header: { titleField: "", codeField: "" },
statFields: [],
quantityInput: { label: "출고수량", unit: "EA" },
emptyMessage: "거래처를 선택하면 출고 대상 품목이 표시됩니다",
emptyIcon: "📦",
};
// ===== 메인 =====
export function PopCartOutboundConfigPanel({ config, onUpdate }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<"data" | "card" | "cart">("data");
const cfg: PopCartOutboundConfig = config || DEFAULT_CONFIG;
const update = (partial: Partial<PopCartOutboundConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const hasTable = !!cfg.dataSource?.tableName;
const tabs: { key: typeof activeTab; label: string; disabled?: boolean }[] = [
{ key: "data", label: "데이터" },
{ key: "card", label: "카드", disabled: !hasTable },
{ key: "cart", label: "장바구니", disabled: !hasTable },
];
return (
<div className="flex h-full flex-col">
{/* 탭 헤더 */}
<div className="flex border-b">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
activeTab === tab.key
? "border-b-2 border-primary text-primary"
: tab.disabled
? "cursor-not-allowed text-muted-foreground/50"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
disabled={tab.disabled}
>
{tab.label}
</button>
))}
</div>
{/* 탭 내용 */}
<div className="flex-1 overflow-y-auto p-3">
{activeTab === "data" && <DataTab config={cfg} onUpdate={update} />}
{activeTab === "card" && <CardTab config={cfg} onUpdate={update} />}
{activeTab === "cart" && <CartTab config={cfg} onUpdate={update} />}
</div>
</div>
);
}
// ===== 데이터 탭 =====
function DataTab({
config,
onUpdate,
}: {
config: PopCartOutboundConfig;
onUpdate: (p: Partial<PopCartOutboundConfig>) => void;
}) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const tableName = config.dataSource?.tableName || "";
useEffect(() => {
fetchTableList().then(setTables);
}, []);
useEffect(() => {
if (!tableName) {
setColumns([]);
return;
}
fetchTableColumns(tableName).then(setColumns).catch(() => setColumns([]));
}, [tableName]);
return (
<div className="space-y-4">
{/* 테이블 선택 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1">
<TableCombobox
tables={tables}
value={tableName}
onSelect={(val: string) =>
onUpdate({
dataSource: { ...config.dataSource, tableName: val },
header: { titleField: "", codeField: "" },
statFields: [],
})
}
/>
</div>
</div>
{/* PK 컬럼 */}
{tableName && (
<div>
<Label className="text-[10px] text-muted-foreground">PK </Label>
<Select
value={config.keyColumn || "id"}
onValueChange={(val) => onUpdate({ keyColumn: val })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.name}
{col.comment ? ` (${col.comment})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 고정 안내 */}
<div className="rounded-md bg-muted/50 p-2">
<p className="text-[10px] text-muted-foreground">
(//DB ) .
(+) .
</p>
</div>
</div>
);
}
// ===== 카드 탭 =====
function CardTab({
config,
onUpdate,
}: {
config: PopCartOutboundConfig;
onUpdate: (p: Partial<PopCartOutboundConfig>) => void;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const tableName = config.dataSource?.tableName || "";
useEffect(() => {
if (!tableName) return;
fetchTableColumns(tableName).then(setColumns).catch(() => {});
}, [tableName]);
const colOptions = columns.map((c) => ({
value: c.name,
label: c.comment ? `${c.name} (${c.comment})` : c.name,
}));
return (
<div className="space-y-4">
{/* 헤더 매핑 */}
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.header?.titleField || ""}
onValueChange={(val) =>
onUpdate({ header: { ...config.header, titleField: val } })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.header?.codeField || ""}
onValueChange={(val) =>
onUpdate({ header: { ...config.header, codeField: val } })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> ()</Label>
<Select
value={config.header?.unitField || "__none__"}
onValueChange={(val) =>
onUpdate({
header: {
...config.header,
unitField: val === "__none__" ? undefined : val,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 스탯 필드 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<button
type="button"
onClick={() => {
const newField: OutboundStatField = {
id: `sf_${Date.now()}`,
label: "",
column: "",
format: "number",
};
onUpdate({
statFields: [...(config.statFields || []), newField],
});
}}
className="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] text-primary hover:bg-primary/10"
>
<Plus size={12} />
</button>
</div>
{(config.statFields || []).map((sf, idx) => (
<div key={sf.id || `sf-${idx}`} className="flex items-end gap-1.5 rounded border border-border/50 p-2">
{/* 라벨 */}
<div className="flex-1">
<Label className="text-[9px] text-muted-foreground"></Label>
<Input
className="mt-0.5 h-6 text-xs"
placeholder="주문수량"
value={sf.label}
onChange={(e) => {
const updated = [...config.statFields];
updated[idx] = { ...sf, label: e.target.value };
onUpdate({ statFields: updated });
}}
/>
</div>
{/* 컬럼 */}
<div className="flex-1">
<Label className="text-[9px] text-muted-foreground"></Label>
<Select
value={sf.column || ""}
onValueChange={(val) => {
const updated = [...config.statFields];
updated[idx] = { ...sf, column: val };
onUpdate({ statFields: updated });
}}
>
<SelectTrigger className="mt-0.5 h-6 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 포맷 */}
<div className="w-20">
<Label className="text-[9px] text-muted-foreground"></Label>
<Select
value={sf.format || "number"}
onValueChange={(val) => {
const updated = [...config.statFields];
updated[idx] = { ...sf, format: val as StatFieldFormat };
onUpdate({ statFields: updated });
}}
>
<SelectTrigger className="mt-0.5 h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number" className="text-xs"></SelectItem>
<SelectItem value="currency" className="text-xs"></SelectItem>
<SelectItem value="text" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 삭제 */}
<button
type="button"
onClick={() => {
onUpdate({
statFields: config.statFields.filter((_, i) => i !== idx),
});
}}
className="mb-0.5 rounded p-1 text-destructive hover:bg-destructive/10"
>
<Trash2 size={12} />
</button>
</div>
))}
{(config.statFields || []).length === 0 && (
<p className="text-center text-[10px] text-muted-foreground">
,
</p>
)}
</div>
</div>
);
}
// ===== 장바구니 탭 =====
function CartTab({
config,
onUpdate,
}: {
config: PopCartOutboundConfig;
onUpdate: (p: Partial<PopCartOutboundConfig>) => void;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const tableName = config.dataSource?.tableName || "";
useEffect(() => {
if (!tableName) return;
fetchTableColumns(tableName).then(setColumns).catch(() => {});
}, [tableName]);
const colOptions = columns.map((c) => ({
value: c.name,
label: c.comment ? `${c.name} (${c.comment})` : c.name,
}));
const qi = config.quantityInput || { label: "출고수량", unit: "EA" };
return (
<div className="space-y-4">
{/* 수량 입력 설정 */}
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
className="mt-1 h-7 text-xs"
value={qi.label}
onChange={(e) =>
onUpdate({
quantityInput: { ...qi, label: e.target.value },
})
}
/>
</div>
<div className="w-20">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
className="mt-1 h-7 text-xs"
value={qi.unit || ""}
onChange={(e) =>
onUpdate({
quantityInput: { ...qi, unit: e.target.value },
})
}
/>
</div>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> ()</Label>
<Select
value={qi.defaultColumn || "__none__"}
onValueChange={(val) =>
onUpdate({
quantityInput: {
...qi,
defaultColumn: val === "__none__" ? undefined : val,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="없음 (기본 1)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
( 1)
</SelectItem>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> ()</Label>
<Select
value={qi.maxColumn || "__none__"}
onValueChange={(val) =>
onUpdate({
quantityInput: {
...qi,
maxColumn: val === "__none__" ? undefined : val,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="없음 (무제한)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
()
</SelectItem>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 빈 상태 메시지 */}
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
className="mt-1 h-7 text-xs"
value={config.emptyMessage || ""}
onChange={(e) => onUpdate({ emptyMessage: e.target.value })}
placeholder="거래처를 선택하면 출고 대상 품목이 표시됩니다"
/>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> ()</Label>
<Input
className="mt-1 h-7 text-xs"
value={config.emptyIcon || ""}
onChange={(e) => onUpdate({ emptyIcon: e.target.value })}
placeholder="📦"
/>
</div>
</div>
{/* 고정 안내 */}
<div className="rounded-md bg-blue-50 p-2 dark:bg-blue-950/30">
<p className="text-[10px] text-blue-600 dark:text-blue-400">
/ DB (cart_items) .
.
</p>
</div>
</div>
);
}