298 lines
10 KiB
TypeScript
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));
|
|
}
|