jskim-node #418
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (마법봉)" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 줌 레벨별 표시 일수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue