jskim-node #418

Merged
kjs merged 52 commits from jskim-node into main 2026-03-16 14:53:15 +09:00
4 changed files with 918 additions and 223 deletions
Showing only changes of commit 1a319d1785 - Show all commits

View File

@ -15,11 +15,11 @@ import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers } from "lucide-react";
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel } from "@/lib/registry/components/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
interface V2TimelineSchedulerConfigPanelProps {
config: TimelineSchedulerConfig;
@ -49,10 +49,16 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
const [resourceTableOpen, setResourceTableOpen] = useState(false);
const [customTableOpen, setCustomTableOpen] = useState(false);
const [scheduleDataOpen, setScheduleDataOpen] = useState(true);
const [filterLinkOpen, setFilterLinkOpen] = useState(false);
const [sourceDataOpen, setSourceDataOpen] = useState(true);
const [resourceOpen, setResourceOpen] = useState(true);
const [displayOpen, setDisplayOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const [newFilterKey, setNewFilterKey] = useState("");
const [newFilterValue, setNewFilterValue] = useState("");
const [linkedFilterTableOpen, setLinkedFilterTableOpen] = useState(false);
const [expandedActionId, setExpandedActionId] = useState<string | null>(null);
useEffect(() => {
const loadTables = async () => {
@ -225,6 +231,31 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</Select>
</div>
{/* 뷰 모드 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs text-muted-foreground truncate"> </p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{viewModeOptions.find((o) => o.value === (config.viewMode || "resource"))?.description}
</p>
</div>
<Select
value={config.viewMode || "resource"}
onValueChange={(v) => updateConfig({ viewMode: v as any })}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{viewModeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 커스텀 테이블 사용 여부 */}
<div className="flex items-center justify-between py-1">
<div>
@ -470,6 +501,210 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</CollapsibleContent>
</Collapsible>
{/* ─── 필터 & 연동 설정 ─── */}
<Collapsible open={filterLinkOpen} onOpenChange={setFilterLinkOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> & </span>
<Badge variant="secondary" className="text-[10px] h-5">
{Object.keys(config.staticFilters || {}).length + (config.linkedFilter ? 1 : 0)}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterLinkOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-4">
{/* 정적 필터 */}
<div className="space-y-2">
<p className="text-xs font-medium text-primary"> (staticFilters)</p>
<p className="text-[10px] text-muted-foreground"> </p>
{Object.entries(config.staticFilters || {}).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<Input value={key} disabled className="h-7 flex-1 text-xs bg-muted/30" />
<span className="text-xs text-muted-foreground">=</span>
<Input value={value} disabled className="h-7 flex-1 text-xs bg-muted/30" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const updated = { ...config.staticFilters };
delete updated[key];
updateConfig({ staticFilters: Object.keys(updated).length > 0 ? updated : undefined });
}}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
value={newFilterKey}
onChange={(e) => setNewFilterKey(e.target.value)}
placeholder="필드명 (예: product_type)"
className="h-7 flex-1 text-xs"
/>
<span className="text-xs text-muted-foreground">=</span>
<Input
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
placeholder="값 (예: 완제품)"
className="h-7 flex-1 text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (!newFilterKey.trim()) return;
updateConfig({
staticFilters: {
...(config.staticFilters || {}),
[newFilterKey.trim()]: newFilterValue.trim(),
},
});
setNewFilterKey("");
setNewFilterValue("");
}}
disabled={!newFilterKey.trim()}
className="h-7 w-7 p-0"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 구분선 */}
<div className="border-t" />
{/* 연결 필터 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-primary flex items-center gap-1">
<Link className="h-3 w-3" />
(linkedFilter)
</p>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Switch
checked={!!config.linkedFilter}
onCheckedChange={(v) => {
if (v) {
updateConfig({
linkedFilter: {
sourceField: "",
targetField: "",
showEmptyWhenNoSelection: true,
emptyMessage: "좌측 목록에서 항목을 선택하세요",
},
});
} else {
updateConfig({ linkedFilter: undefined });
}
}}
/>
</div>
{config.linkedFilter && (
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-2 pt-1">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> tableName </p>
</div>
<Popover open={linkedFilterTableOpen} onOpenChange={setLinkedFilterTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-[140px] justify-between text-xs"
disabled={loading}
>
{config.linkedFilter.sourceTableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="end">
<Command filter={(value, search) => value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs p-2"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({
linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName },
});
setLinkedFilterTableOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.linkedFilter?.sourceTableName === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (sourceField) *</span>
<Input
value={config.linkedFilter.sourceField || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })}
placeholder="예: part_code"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (targetField) *</span>
<Input
value={config.linkedFilter.targetField || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })}
placeholder="예: item_code"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.linkedFilter.emptyMessage || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })}
placeholder="선택 안내 문구"
className="h-7 w-[180px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Switch
checked={config.linkedFilter.showEmptyWhenNoSelection ?? true}
onCheckedChange={(v) => updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })}
/>
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 2단계: 소스 데이터 설정 ─── */}
<Collapsible open={sourceDataOpen} onOpenChange={setSourceDataOpen}>
<CollapsibleTrigger asChild>
@ -1038,6 +1273,17 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
onCheckedChange={(v) => updateConfig({ showAddButton: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showLegend ?? true}
onCheckedChange={(v) => updateConfig({ showLegend: v })}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
@ -1114,6 +1360,405 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 6단계: 툴바 액션 설정 ─── */}
<Collapsible open={actionsOpen} onOpenChange={setActionsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{(config.toolbarActions || []).length}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", actionsOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<p className="text-[10px] text-muted-foreground">
API ( )
</p>
{/* 기존 액션 목록 */}
{(config.toolbarActions || []).map((action, index) => (
<Collapsible
key={action.id}
open={expandedActionId === action.id}
onOpenChange={(open) => setExpandedActionId(open ? action.id : null)}
>
<div className="rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-muted/30"
>
<div className="flex items-center gap-2">
<GripVertical className="h-3 w-3 text-muted-foreground/50" />
<div className={cn("h-3 w-3 rounded-sm", action.color?.split(" ")[0] || "bg-primary")} />
<span className="text-xs font-medium">{action.label || "새 액션"}</span>
<Badge variant="outline" className="text-[9px] h-4">
{action.dataSource === "linkedSelection" ? "연결선택" : "스케줄"}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
const updated = (config.toolbarActions || []).filter((_, i) => i !== index);
updateConfig({ toolbarActions: updated.length > 0 ? updated : undefined });
}}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<ChevronDown className={cn("h-3 w-3 text-muted-foreground transition-transform", expandedActionId === action.id && "rotate-180")} />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t px-3 py-3 space-y-2.5">
{/* 기본 설정 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.label}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], label: e.target.value };
updateConfig({ toolbarActions: updated });
}}
className="h-7 text-xs"
/>
</div>
<div className="w-[110px]">
<span className="text-[10px] text-muted-foreground"></span>
<Select
value={action.icon || "Zap"}
onValueChange={(v) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], icon: v as any };
updateConfig({ toolbarActions: updated });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{toolbarIconOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<span className="text-[10px] text-muted-foreground"> (Tailwind )</span>
<Input
value={action.color || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], color: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="예: bg-emerald-600 hover:bg-emerald-700"
className="h-7 text-xs"
/>
</div>
{/* API 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5">API </p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"> API *</span>
<Input
value={action.previewApi}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], previewApi: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="/production/generate-schedule/preview"
className="h-7 text-xs"
/>
</div>
<div>
<span className="text-[10px] text-muted-foreground"> API *</span>
<Input
value={action.applyApi}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], applyApi: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="/production/generate-schedule"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 다이얼로그 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"></p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.dialogTitle || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dialogTitle: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="자동 생성"
className="h-7 text-xs"
/>
</div>
<div>
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.dialogDescription || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dialogDescription: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="미리보기 후 확인하여 적용합니다"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 데이터 소스 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"> </p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"> *</span>
<Select
value={action.dataSource}
onValueChange={(v) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dataSource: v as any };
updateConfig({ toolbarActions: updated });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{dataSourceOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<div>
<span>{opt.label}</span>
<span className="ml-1 text-[10px] text-muted-foreground">({opt.description})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{action.dataSource === "linkedSelection" && (
<div className="ml-2 border-l-2 border-blue-200 pl-2 space-y-1.5">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.groupByField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, groupByField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="linkedFilter.sourceField 사용"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.quantityField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, quantityField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="balance_qty"
className="h-7 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.dueDateField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, dueDateField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="due_date"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.nameField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, nameField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="part_name"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{action.dataSource === "currentSchedules" && (
<div className="ml-2 border-l-2 border-amber-200 pl-2 space-y-1.5">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.scheduleFilterField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="product_type"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.scheduleFilterValue || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterValue: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="완제품"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
{/* 표시 조건 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"> (showWhen)</p>
<p className="text-[9px] text-muted-foreground mb-1">staticFilters </p>
{Object.entries(action.showWhen || {}).map(([key, value]) => (
<div key={key} className="flex items-center gap-1 mb-1">
<Input value={key} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
<span className="text-[10px]">=</span>
<Input value={value} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const updated = [...(config.toolbarActions || [])];
const newShowWhen = { ...updated[index].showWhen };
delete newShowWhen[key];
updated[index] = { ...updated[index], showWhen: Object.keys(newShowWhen).length > 0 ? newShowWhen : undefined };
updateConfig({ toolbarActions: updated });
}}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
</div>
))}
<div className="flex items-center gap-1">
<Input
id={`showWhen-key-${index}`}
placeholder="필드명"
className="h-6 flex-1 text-[10px]"
/>
<span className="text-[10px]">=</span>
<Input
id={`showWhen-val-${index}`}
placeholder="값"
className="h-6 flex-1 text-[10px]"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
const keyEl = document.getElementById(`showWhen-key-${index}`) as HTMLInputElement;
const valEl = document.getElementById(`showWhen-val-${index}`) as HTMLInputElement;
if (!keyEl?.value?.trim()) return;
const updated = [...(config.toolbarActions || [])];
updated[index] = {
...updated[index],
showWhen: { ...(updated[index].showWhen || {}), [keyEl.value.trim()]: valEl?.value?.trim() || "" },
};
updateConfig({ toolbarActions: updated });
keyEl.value = "";
if (valEl) valEl.value = "";
}}
className="h-6 w-6 p-0"
>
<Plus className="h-2.5 w-2.5" />
</Button>
</div>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
{/* 액션 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => {
const newAction: ToolbarAction = {
id: `action_${Date.now()}`,
label: "새 액션",
icon: "Zap",
color: "bg-primary hover:bg-primary/90",
previewApi: "",
applyApi: "",
dataSource: "linkedSelection",
};
updateConfig({
toolbarActions: [...(config.toolbarActions || []), newAction],
});
setExpandedActionId(newAction.id);
}}
className="w-full h-8 text-xs gap-1"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};

View File

@ -12,7 +12,15 @@ import {
Package,
Zap,
RefreshCw,
Download,
Upload,
Play,
FileText,
Send,
Sparkles,
Wand2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useVirtualizer } from "@tanstack/react-virtual";
@ -20,6 +28,7 @@ import {
TimelineSchedulerComponentProps,
ScheduleItem,
ZoomLevel,
ToolbarAction,
} from "./types";
import { useTimelineData } from "./hooks/useTimelineData";
import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components";
@ -53,24 +62,24 @@ export function TimelineSchedulerComponent({
}: TimelineSchedulerComponentProps) {
const containerRef = useRef<HTMLDivElement>(null);
// ────────── 자동 스케줄 생성 상태 ──────────
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewApplying, setPreviewApplying] = useState(false);
const [previewSummary, setPreviewSummary] = useState<any>(null);
const [previewItems, setPreviewItems] = useState<any[]>([]);
const [previewDeleted, setPreviewDeleted] = useState<any[]>([]);
const [previewKept, setPreviewKept] = useState<any[]>([]);
// ────────── 툴바 액션 다이얼로그 상태 (통합) ──────────
const [actionDialog, setActionDialog] = useState<{
actionId: string;
action: ToolbarAction;
isLoading: boolean;
isApplying: boolean;
summary: any;
previews: any[];
deletedSchedules: any[];
keptSchedules: any[];
preparedPayload: any;
} | null>(null);
const linkedFilterValuesRef = useRef<any[]>([]);
// ────────── 반제품 계획 생성 상태 ──────────
const [showSemiPreviewDialog, setShowSemiPreviewDialog] = useState(false);
const [semiPreviewLoading, setSemiPreviewLoading] = useState(false);
const [semiPreviewApplying, setSemiPreviewApplying] = useState(false);
const [semiPreviewSummary, setSemiPreviewSummary] = useState<any>(null);
const [semiPreviewItems, setSemiPreviewItems] = useState<any[]>([]);
const [semiPreviewDeleted, setSemiPreviewDeleted] = useState<any[]>([]);
const [semiPreviewKept, setSemiPreviewKept] = useState<any[]>([]);
// ────────── 아이콘 맵 ──────────
const TOOLBAR_ICONS: Record<string, React.ComponentType<{ className?: string }>> = useMemo(() => ({
Zap, Package, Plus, Download, Upload, RefreshCw, Play, FileText, Send, Sparkles, Wand2,
}), []);
// ────────── linkedFilter 상태 ──────────
const linkedFilter = config.linkedFilter;
@ -339,197 +348,153 @@ export function TimelineSchedulerComponent({
}
}, [onAddSchedule, effectiveResources]);
// ────────── 자동 스케줄 생성: 미리보기 요청 ──────────
const handleAutoSchedulePreview = useCallback(async () => {
const selectedRows = linkedFilterValuesRef.current;
if (!selectedRows || selectedRows.length === 0) {
toast.warning("좌측에서 품목을 선택해주세요");
return;
// ────────── 유효 툴바 액션 (config 기반 또는 하위호환 자동생성) ──────────
const effectiveToolbarActions: ToolbarAction[] = useMemo(() => {
if (config.toolbarActions && config.toolbarActions.length > 0) {
return config.toolbarActions;
}
return [];
}, [config.toolbarActions]);
const sourceField = config.linkedFilter?.sourceField || "part_code";
const grouped = new Map<string, any[]>();
selectedRows.forEach((row: any) => {
const key = row[sourceField] || "";
if (!key) return;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(row);
});
// ────────── 범용 액션: 미리보기 요청 ──────────
const handleActionPreview = useCallback(async (action: ToolbarAction) => {
let payload: any;
const items = Array.from(grouped.entries()).map(([itemCode, rows]) => {
const totalBalanceQty = rows.reduce((sum: number, r: any) => sum + (Number(r.balance_qty) || 0), 0);
const earliestDueDate = rows
.map((r: any) => r.due_date)
.filter(Boolean)
.sort()[0] || new Date().toISOString().split("T")[0];
const first = rows[0];
if (action.dataSource === "linkedSelection") {
const selectedRows = linkedFilterValuesRef.current;
if (!selectedRows || selectedRows.length === 0) {
toast.warning("좌측에서 항목을 선택해주세요");
return;
}
return {
item_code: itemCode,
item_name: first.part_name || first.item_name || itemCode,
required_qty: totalBalanceQty,
earliest_due_date: typeof earliestDueDate === "string" ? earliestDueDate.split("T")[0] : earliestDueDate,
hourly_capacity: Number(first.hourly_capacity) || undefined,
daily_capacity: Number(first.daily_capacity) || undefined,
};
}).filter((item) => item.required_qty > 0);
const groupField = action.payloadConfig?.groupByField || config.linkedFilter?.sourceField || "part_code";
const qtyField = action.payloadConfig?.quantityField || config.sourceConfig?.quantityField || "balance_qty";
const dateField = action.payloadConfig?.dueDateField || config.sourceConfig?.dueDateField || "due_date";
const nameField = action.payloadConfig?.nameField || config.sourceConfig?.groupNameField || "part_name";
if (items.length === 0) {
toast.warning("선택된 품목의 잔량이 없습니다");
return;
}
setShowPreviewDialog(true);
setPreviewLoading(true);
try {
const response = await apiClient.post("/production/generate-schedule/preview", {
items,
options: {
product_type: config.staticFilters?.product_type || "완제품",
safety_lead_time: 1,
recalculate_unstarted: true,
},
const grouped = new Map<string, any[]>();
selectedRows.forEach((row: any) => {
const key = row[groupField] || "";
if (!key) return;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(row);
});
const items = Array.from(grouped.entries()).map(([code, rows]) => {
const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0);
const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort();
const earliestDate = dates[0] || new Date().toISOString().split("T")[0];
const first = rows[0];
return {
item_code: code,
item_name: first[nameField] || first.item_name || code,
required_qty: totalQty,
earliest_due_date: typeof earliestDate === "string" ? earliestDate.split("T")[0] : earliestDate,
hourly_capacity: Number(first.hourly_capacity) || undefined,
daily_capacity: Number(first.daily_capacity) || undefined,
};
}).filter((item) => item.required_qty > 0);
if (items.length === 0) {
toast.warning("선택된 항목의 잔량이 없습니다");
return;
}
payload = {
items,
options: {
...(config.staticFilters || {}),
...(action.payloadConfig?.extraOptions || {}),
},
};
} else if (action.dataSource === "currentSchedules") {
let targetSchedules = schedules;
const filterField = action.payloadConfig?.scheduleFilterField;
const filterValue = action.payloadConfig?.scheduleFilterValue;
if (filterField && filterValue) {
targetSchedules = schedules.filter((s) => {
const val = (s.data as any)?.[filterField] || "";
return val === filterValue;
});
}
if (targetSchedules.length === 0) {
toast.warning("대상 스케줄이 없습니다");
return;
}
const planIds = targetSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) {
toast.warning("유효한 스케줄 ID가 없습니다");
return;
}
payload = {
plan_ids: planIds,
options: action.payloadConfig?.extraOptions || {},
};
}
setActionDialog({
actionId: action.id,
action,
isLoading: true,
isApplying: false,
summary: null,
previews: [],
deletedSchedules: [],
keptSchedules: [],
preparedPayload: payload,
});
try {
const response = await apiClient.post(action.previewApi, payload);
if (response.data?.success) {
setPreviewSummary(response.data.data.summary);
setPreviewItems(response.data.data.previews);
setPreviewDeleted(response.data.data.deletedSchedules || []);
setPreviewKept(response.data.data.keptSchedules || []);
setActionDialog((prev) => prev ? {
...prev,
isLoading: false,
summary: response.data.data.summary,
previews: response.data.data.previews || [],
deletedSchedules: response.data.data.deletedSchedules || [],
keptSchedules: response.data.data.keptSchedules || [],
} : null);
} else {
toast.error("미리보기 생성 실패");
setShowPreviewDialog(false);
toast.error("미리보기 생성 실패", { description: response.data?.message });
setActionDialog(null);
}
} catch (err: any) {
toast.error("미리보기 요청 실패", { description: err.message });
setShowPreviewDialog(false);
} finally {
setPreviewLoading(false);
setActionDialog(null);
}
}, [config.linkedFilter, config.staticFilters]);
}, [config.linkedFilter, config.staticFilters, config.sourceConfig, schedules]);
// ────────── 자동 스케줄 생성: 확인 및 적용 ──────────
const handleAutoScheduleApply = useCallback(async () => {
if (!previewItems || previewItems.length === 0) return;
// ────────── 범용 액션: 확인 및 적용 ──────────
const handleActionApply = useCallback(async () => {
if (!actionDialog) return;
const { action, preparedPayload } = actionDialog;
setPreviewApplying(true);
const items = previewItems.map((p: any) => ({
item_code: p.item_code,
item_name: p.item_name,
required_qty: p.required_qty,
earliest_due_date: p.due_date,
hourly_capacity: p.hourly_capacity,
daily_capacity: p.daily_capacity,
}));
setActionDialog((prev) => prev ? { ...prev, isApplying: true } : null);
try {
const response = await apiClient.post("/production/generate-schedule", {
items,
options: {
product_type: config.staticFilters?.product_type || "완제품",
safety_lead_time: 1,
recalculate_unstarted: true,
},
});
if (response.data?.success) {
const summary = response.data.data.summary;
toast.success("생산계획 업데이트 완료", {
description: `신규: ${summary.new_count}건, 유지: ${summary.kept_count}건, 삭제: ${summary.deleted_count}`,
});
setShowPreviewDialog(false);
refreshTimeline();
} else {
toast.error("생산계획 생성 실패");
}
} catch (err: any) {
toast.error("생산계획 생성 실패", { description: err.message });
} finally {
setPreviewApplying(false);
}
}, [previewItems, config.staticFilters, refreshTimeline]);
// ────────── 반제품 계획 생성: 미리보기 요청 ──────────
const handleSemiSchedulePreview = useCallback(async () => {
// 현재 타임라인에 표시된 완제품 스케줄의 plan ID 수집
const finishedSchedules = schedules.filter((s) => {
const productType = (s.data as any)?.product_type || "";
return productType === "완제품";
});
if (finishedSchedules.length === 0) {
toast.warning("완제품 스케줄이 없습니다. 먼저 완제품 계획을 생성해주세요.");
return;
}
const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) {
toast.warning("유효한 완제품 계획 ID가 없습니다");
return;
}
setShowSemiPreviewDialog(true);
setSemiPreviewLoading(true);
try {
const response = await apiClient.post("/production/generate-semi-schedule/preview", {
plan_ids: planIds,
options: { considerStock: true },
});
if (response.data?.success) {
setSemiPreviewSummary(response.data.data.summary);
setSemiPreviewItems(response.data.data.previews || []);
setSemiPreviewDeleted(response.data.data.deletedSchedules || []);
setSemiPreviewKept(response.data.data.keptSchedules || []);
} else {
toast.error("반제품 미리보기 실패", { description: response.data?.message });
setShowSemiPreviewDialog(false);
}
} catch (err: any) {
toast.error("반제품 미리보기 요청 실패", { description: err.message });
setShowSemiPreviewDialog(false);
} finally {
setSemiPreviewLoading(false);
}
}, [schedules]);
// ────────── 반제품 계획 생성: 확인 및 적용 ──────────
const handleSemiScheduleApply = useCallback(async () => {
const finishedSchedules = schedules.filter((s) => {
const productType = (s.data as any)?.product_type || "";
return productType === "완제품";
});
const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) return;
setSemiPreviewApplying(true);
try {
const response = await apiClient.post("/production/generate-semi-schedule", {
plan_ids: planIds,
options: { considerStock: true },
});
const response = await apiClient.post(action.applyApi, preparedPayload);
if (response.data?.success) {
const data = response.data.data;
toast.success("반제품 계획 생성 완료", {
description: `${data.count}건의 반제품 계획이 생성되었습니다`,
const summary = data.summary || data;
toast.success(action.dialogTitle || "완료", {
description: `신규: ${summary.new_count || summary.count || 0}${summary.kept_count ? `, 유지: ${summary.kept_count}` : ""}${summary.deleted_count ? `, 삭제: ${summary.deleted_count}` : ""}`,
});
setShowSemiPreviewDialog(false);
setActionDialog(null);
refreshTimeline();
} else {
toast.error("반제품 계획 생성 실패");
toast.error("실행 실패", { description: response.data?.message });
}
} catch (err: any) {
toast.error("반제품 계획 생성 실패", { description: err.message });
toast.error("실행 실패", { description: err.message });
} finally {
setSemiPreviewApplying(false);
setActionDialog((prev) => prev ? { ...prev, isApplying: false } : null);
}
}, [schedules, refreshTimeline]);
}, [actionDialog, refreshTimeline]);
// ────────── 하단 영역 높이 계산 (툴바 + 범례) ──────────
const showToolbar = config.showToolbar !== false;
@ -713,18 +678,26 @@ export function TimelineSchedulerComponent({
<RefreshCw className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
{config.staticFilters?.product_type === "완제품" && (
<>
<Button size="sm" onClick={handleAutoSchedulePreview} className="h-6 gap-1 bg-emerald-600 px-2 text-[10px] hover:bg-emerald-700 sm:h-7 sm:px-3 sm:text-xs">
<Zap className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{effectiveToolbarActions.map((action) => {
if (action.showWhen) {
const matches = Object.entries(action.showWhen).every(
([key, value]) => config.staticFilters?.[key] === value
);
if (!matches) return null;
}
const IconComp = TOOLBAR_ICONS[action.icon || "Zap"] || Zap;
return (
<Button
key={action.id}
size="sm"
onClick={() => handleActionPreview(action)}
className={cn("h-6 gap-1 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs", action.color || "bg-primary hover:bg-primary/90")}
>
<IconComp className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{action.label}
</Button>
<Button size="sm" onClick={handleSemiSchedulePreview} className="h-6 gap-1 bg-blue-600 px-2 text-[10px] hover:bg-blue-700 sm:h-7 sm:px-3 sm:text-xs">
<Package className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
</>
)}
);
})}
</div>
</div>
)}
@ -796,33 +769,22 @@ export function TimelineSchedulerComponent({
</div>
)}
{/* 완제품 스케줄 생성 미리보기 다이얼로그 */}
<SchedulePreviewDialog
open={showPreviewDialog}
onOpenChange={setShowPreviewDialog}
isLoading={previewLoading}
summary={previewSummary}
previews={previewItems}
deletedSchedules={previewDeleted}
keptSchedules={previewKept}
onConfirm={handleAutoScheduleApply}
isApplying={previewApplying}
/>
{/* 반제품 계획 생성 미리보기 다이얼로그 */}
<SchedulePreviewDialog
open={showSemiPreviewDialog}
onOpenChange={setShowSemiPreviewDialog}
isLoading={semiPreviewLoading}
summary={semiPreviewSummary}
previews={semiPreviewItems}
deletedSchedules={semiPreviewDeleted}
keptSchedules={semiPreviewKept}
onConfirm={handleSemiScheduleApply}
isApplying={semiPreviewApplying}
title="반제품 계획 자동 생성"
description="BOM 기반으로 완제품 계획에 필요한 반제품 생산계획을 생성합니다"
/>
{/* 범용 액션 미리보기 다이얼로그 */}
{actionDialog && (
<SchedulePreviewDialog
open={true}
onOpenChange={(open) => { if (!open) setActionDialog(null); }}
isLoading={actionDialog.isLoading}
summary={actionDialog.summary}
previews={actionDialog.previews}
deletedSchedules={actionDialog.deletedSchedules}
keptSchedules={actionDialog.keptSchedules}
onConfirm={handleActionApply}
isApplying={actionDialog.isApplying}
title={actionDialog.action.dialogTitle}
description={actionDialog.action.dialogDescription}
/>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types";
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType, ToolbarAction } from "./types";
/**
*
@ -94,6 +94,39 @@ export const scheduleTypeOptions: { value: ScheduleType; label: string }[] = [
{ value: "WORK_ASSIGN", label: "작업배정" },
];
/**
*
*/
export const viewModeOptions: { value: string; label: string; description: string }[] = [
{ value: "resource", label: "리소스 기반", description: "설비/작업자 행 기반 간트차트" },
{ value: "itemGrouped", label: "품목별 그룹", description: "품목별 카드형 타임라인" },
];
/**
*
*/
export const dataSourceOptions: { value: string; label: string; description: string }[] = [
{ value: "linkedSelection", label: "연결 필터 선택값", description: "좌측 테이블에서 선택된 행 데이터 사용" },
{ value: "currentSchedules", label: "현재 스케줄", description: "타임라인에 표시 중인 스케줄 ID 사용" },
];
/**
*
*/
export const toolbarIconOptions: { value: string; label: string }[] = [
{ value: "Zap", label: "Zap (번개)" },
{ value: "Package", label: "Package (박스)" },
{ value: "Plus", label: "Plus (추가)" },
{ value: "Download", label: "Download (다운로드)" },
{ value: "Upload", label: "Upload (업로드)" },
{ value: "RefreshCw", label: "RefreshCw (새로고침)" },
{ value: "Play", label: "Play (재생)" },
{ value: "FileText", label: "FileText (문서)" },
{ value: "Send", label: "Send (전송)" },
{ value: "Sparkles", label: "Sparkles (반짝)" },
{ value: "Wand2", label: "Wand2 (마법봉)" },
];
/**
*
*/

View File

@ -128,6 +128,58 @@ export interface SourceDataConfig {
groupNameField?: string;
}
/**
* ( )
*
* preview -> confirm -> apply
*/
export interface ToolbarAction {
/** 고유 ID */
id: string;
/** 버튼 텍스트 */
label: string;
/** lucide-react 아이콘명 */
icon?: "Zap" | "Package" | "Plus" | "Download" | "Upload" | "RefreshCw" | "Play" | "FileText" | "Send" | "Sparkles" | "Wand2";
/** 버튼 색상 클래스 (예: "bg-emerald-600 hover:bg-emerald-700") */
color?: string;
/** 미리보기 API 엔드포인트 (예: "/production/generate-schedule/preview") */
previewApi: string;
/** 적용 API 엔드포인트 (예: "/production/generate-schedule") */
applyApi: string;
/** 다이얼로그 제목 */
dialogTitle?: string;
/** 다이얼로그 설명 */
dialogDescription?: string;
/**
*
* - linkedSelection: 연결 ( )
* - currentSchedules: 현재 ID
*/
dataSource: "linkedSelection" | "currentSchedules";
/** 페이로드 구성 설정 */
payloadConfig?: {
/** linkedSelection: 선택된 행을 그룹화할 필드 (기본: linkedFilter.sourceField) */
groupByField?: string;
/** linkedSelection: 수량 합계 필드 (예: "balance_qty") */
quantityField?: string;
/** linkedSelection: 기준일 필드 (예: "due_date") */
dueDateField?: string;
/** linkedSelection: 표시명 필드 (예: "part_name") */
nameField?: string;
/** currentSchedules: 스케줄 필터 조건 필드명 (예: "product_type") */
scheduleFilterField?: string;
/** currentSchedules: 스케줄 필터 값 (예: "완제품") */
scheduleFilterValue?: string;
/** API 호출 시 추가 옵션 (예: { "safety_lead_time": 1 }) */
extraOptions?: Record<string, any>;
};
/**
* 조건: staticFilters와
* : { "product_type": "완제품" } staticFilters.product_type === "완제품"
*/
showWhen?: Record<string, string>;
}
/**
*
*/
@ -254,6 +306,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
/** 빈 상태 메시지 */
emptyMessage?: string;
};
/** 툴바 커스텀 액션 버튼 설정 */
toolbarActions?: ToolbarAction[];
}
/**