562 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|