ERP-node/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx

298 lines
10 KiB
TypeScript

"use client";
import React, { useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
import { Flame } from "lucide-react";
import { ScheduleItem, TimelineSchedulerConfig, ZoomLevel } from "../types";
import { statusOptions, dayLabels } from "../config";
interface ItemScheduleGroup {
itemCode: string;
itemName: string;
hourlyCapacity: number;
dailyCapacity: number;
schedules: ScheduleItem[];
totalPlanQty: number;
totalCompletedQty: number;
remainingQty: number;
dueDates: { date: string; isUrgent: boolean }[];
}
interface ItemTimelineCardProps {
group: ItemScheduleGroup;
viewStartDate: Date;
viewEndDate: Date;
zoomLevel: ZoomLevel;
cellWidth: number;
config: TimelineSchedulerConfig;
onScheduleClick?: (schedule: ScheduleItem) => void;
}
const toDateString = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const addDays = (d: Date, n: number) => {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
};
const diffDays = (a: Date, b: Date) =>
Math.round((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24));
function generateDateCells(start: Date, end: Date) {
const cells: { date: Date; label: string; dayLabel: string; isWeekend: boolean; isToday: boolean; dateStr: string }[] = [];
const today = toDateString(new Date());
let cur = new Date(start);
while (cur <= end) {
const d = new Date(cur);
const dow = d.getDay();
cells.push({
date: d,
label: String(d.getDate()),
dayLabel: dayLabels[dow],
isWeekend: dow === 0 || dow === 6,
isToday: toDateString(d) === today,
dateStr: toDateString(d),
});
cur = addDays(cur, 1);
}
return cells;
}
export function ItemTimelineCard({
group,
viewStartDate,
viewEndDate,
zoomLevel,
cellWidth,
config,
onScheduleClick,
}: ItemTimelineCardProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const dateCells = useMemo(
() => generateDateCells(viewStartDate, viewEndDate),
[viewStartDate, viewEndDate]
);
const totalWidth = dateCells.length * cellWidth;
const dueDateSet = useMemo(() => {
const set = new Set<string>();
group.dueDates.forEach((d) => set.add(d.date));
return set;
}, [group.dueDates]);
const urgentDateSet = useMemo(() => {
const set = new Set<string>();
group.dueDates.filter((d) => d.isUrgent).forEach((d) => set.add(d.date));
return set;
}, [group.dueDates]);
const statusColor = (status: string) =>
config.statusColors?.[status as keyof typeof config.statusColors] ||
statusOptions.find((s) => s.value === status)?.color ||
"#3b82f6";
const isUrgentItem = group.dueDates.some((d) => d.isUrgent);
const hasRemaining = group.remainingQty > 0;
return (
<div className="rounded-lg border bg-background">
{/* 품목 헤더 */}
<div className="flex items-start justify-between border-b px-3 py-2 sm:px-4 sm:py-3">
<div className="flex items-start gap-2">
<input type="checkbox" className="mt-1 h-3.5 w-3.5 rounded border-border sm:h-4 sm:w-4" />
<div>
<p className="text-[10px] text-muted-foreground sm:text-xs">{group.itemCode}</p>
<p className="text-xs font-semibold sm:text-sm">{group.itemName}</p>
</div>
</div>
<div className="text-right text-[10px] text-muted-foreground sm:text-xs">
<p>
: <span className="font-semibold text-foreground">{group.hourlyCapacity.toLocaleString()}</span> EA
</p>
<p>
: <span className="font-semibold text-foreground">{group.dailyCapacity.toLocaleString()}</span> EA
</p>
</div>
</div>
{/* 타임라인 영역 */}
<div ref={scrollRef} className="overflow-x-auto">
<div style={{ width: totalWidth, minWidth: "100%" }}>
{/* 날짜 헤더 */}
<div className="flex border-b">
{dateCells.map((cell) => {
const isDueDate = dueDateSet.has(cell.dateStr);
const isUrgentDate = urgentDateSet.has(cell.dateStr);
return (
<div
key={cell.dateStr}
className={cn(
"flex shrink-0 flex-col items-center justify-center border-r py-1",
cell.isWeekend && "bg-muted/30",
cell.isToday && "bg-primary/5",
isDueDate && "ring-2 ring-inset ring-destructive",
isUrgentDate && "bg-destructive/5"
)}
style={{ width: cellWidth }}
>
<span className={cn(
"text-[10px] font-medium sm:text-xs",
cell.isToday && "text-primary",
cell.isWeekend && "text-destructive/70"
)}>
{cell.label}
</span>
<span className={cn(
"text-[8px] sm:text-[10px]",
cell.isToday && "text-primary",
cell.isWeekend && "text-destructive/50",
!cell.isToday && !cell.isWeekend && "text-muted-foreground"
)}>
{cell.dayLabel}
</span>
</div>
);
})}
</div>
{/* 스케줄 바 영역 */}
<div className="relative" style={{ height: 48 }}>
{group.schedules.map((schedule) => {
const schedStart = new Date(schedule.startDate);
const schedEnd = new Date(schedule.endDate);
const startOffset = diffDays(schedStart, viewStartDate);
const endOffset = diffDays(schedEnd, viewStartDate);
const left = Math.max(0, startOffset * cellWidth);
const right = Math.min(totalWidth, (endOffset + 1) * cellWidth);
const width = Math.max(cellWidth * 0.5, right - left);
if (right < 0 || left > totalWidth) return null;
const qty = Number(schedule.data?.plan_qty) || 0;
const color = statusColor(schedule.status);
return (
<div
key={schedule.id}
className="absolute cursor-pointer rounded-md shadow-sm transition-shadow hover:shadow-md"
style={{
left,
top: 8,
width,
height: 32,
backgroundColor: color,
}}
onClick={() => onScheduleClick?.(schedule)}
title={`${schedule.title} (${schedule.startDate} ~ ${schedule.endDate})`}
>
<div className="flex h-full items-center justify-center truncate px-1 text-[10px] font-medium text-white sm:text-xs">
{qty > 0 ? `${qty.toLocaleString()} EA` : schedule.title}
</div>
</div>
);
})}
{/* 납기일 마커 */}
{group.dueDates.map((dueDate, idx) => {
const d = new Date(dueDate.date);
const offset = diffDays(d, viewStartDate);
if (offset < 0 || offset > dateCells.length) return null;
const left = offset * cellWidth + cellWidth / 2;
return (
<div
key={`due-${idx}`}
className="absolute top-0 bottom-0"
style={{ left, width: 0 }}
>
<div className={cn(
"absolute top-0 h-full w-px",
dueDate.isUrgent ? "bg-destructive" : "bg-destructive/40"
)} />
</div>
);
})}
</div>
</div>
</div>
{/* 하단 잔량 영역 */}
<div className="flex items-center gap-2 border-t px-3 py-1.5 sm:px-4 sm:py-2">
<input type="checkbox" className="h-3.5 w-3.5 rounded border-border sm:h-4 sm:w-4" />
{hasRemaining && (
<div className={cn(
"flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-semibold sm:text-xs",
isUrgentItem
? "bg-destructive/10 text-destructive"
: "bg-warning/10 text-warning"
)}>
{isUrgentItem && <Flame className="h-3 w-3 sm:h-3.5 sm:w-3.5" />}
{group.remainingQty.toLocaleString()} EA
</div>
)}
{/* 스크롤 인디케이터 */}
<div className="ml-auto flex-1">
<div className="h-1 w-16 rounded-full bg-muted" />
</div>
</div>
</div>
);
}
/**
* 스케줄 데이터를 품목별로 그룹화
*/
export function groupSchedulesByItem(schedules: ScheduleItem[]): ItemScheduleGroup[] {
const grouped = new Map<string, ScheduleItem[]>();
schedules.forEach((s) => {
const key = s.data?.item_code || "unknown";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(s);
});
const result: ItemScheduleGroup[] = [];
grouped.forEach((items, itemCode) => {
const first = items[0];
const hourlyCapacity = Number(first.data?.hourly_capacity) || 0;
const dailyCapacity = Number(first.data?.daily_capacity) || 0;
const totalPlanQty = items.reduce((sum, s) => sum + (Number(s.data?.plan_qty) || 0), 0);
const totalCompletedQty = items.reduce((sum, s) => sum + (Number(s.data?.completed_qty) || 0), 0);
const dueDates: { date: string; isUrgent: boolean }[] = [];
const seenDueDates = new Set<string>();
items.forEach((s) => {
const dd = s.data?.due_date;
if (dd) {
const dateStr = typeof dd === "string" ? dd.split("T")[0] : "";
if (dateStr && !seenDueDates.has(dateStr)) {
seenDueDates.add(dateStr);
const isUrgent = s.data?.priority === "urgent" || s.data?.priority === "high";
dueDates.push({ date: dateStr, isUrgent });
}
}
});
result.push({
itemCode,
itemName: first.data?.item_name || first.title || itemCode,
hourlyCapacity,
dailyCapacity,
schedules: items.sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
),
totalPlanQty,
totalCompletedQty,
remainingQty: totalPlanQty - totalCompletedQty,
dueDates: dueDates.sort((a, b) => a.date.localeCompare(b.date)),
});
});
return result.sort((a, b) => a.itemCode.localeCompare(b.itemCode));
}