feat: enhance V2TimelineSchedulerConfigPanel with filter and view mode options
- Added new filter and linking settings section to the V2TimelineSchedulerConfigPanel, allowing users to manage static filters and linked filters more effectively. - Introduced view mode options to switch between different display modes in the timeline scheduler. - Updated the configuration types and added new toolbar action settings to support custom actions in the timeline toolbar. - Enhanced the overall user experience by providing more flexible filtering and display options. These updates aim to improve the functionality and usability of the timeline scheduler within the ERP system, enabling better data management and visualization. Made-with: Cursor
This commit is contained in:
parent
64c9f25f63
commit
1a319d1785
|
|
@ -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