Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
@@ -0,0 +1,592 @@
"use client";
import { useState, useRef, useCallback, useMemo } from "react";
import {
DndContext,
DragStartEvent,
DragEndEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
addDays,
startOfWeek,
startOfDay,
format,
differenceInDays,
isBefore,
parseISO,
} from "date-fns";
import { scheduleTask, unscheduleTask } from "@/actions/schedule";
import { ScheduleTimeline } from "@/components/schedule/ScheduleTimeline";
import { BacklogPanel } from "@/components/schedule/BacklogPanel";
import { ScheduleFilters } from "@/components/schedule/ScheduleFilters";
import { CalendarDays, PanelRightOpen, PanelRightClose, Columns2, Rows } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
export const DAY_WIDTH = 216;
export const ROW_HEIGHT = 56;
export const HEADER_HEIGHT = 44;
export const LABEL_WIDTH = 208;
export const NUM_DAYS = 35;
export const SLOT_HEIGHT = 48; // task height = ROW_HEIGHT - 8
export interface ScheduleTask {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: string | null;
estimatedHours: number | null;
scheduledStartDate: string | null;
scheduledEndDate: string | null;
scheduleNotes: string | null;
assignedArtistId: string | null;
assignedArtist: {
id: string;
name: string | null;
email: string;
image: string | null;
} | null;
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
asset: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
}
export interface ScheduleArtist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
export interface ActiveDragData {
type: "scheduled" | "backlog" | "resize";
taskId: string;
duration?: number;
estimatedHours?: number | null;
originalEndDate?: string;
}
interface SchedulePageClientProps {
artists: ScheduleArtist[];
tasks: ScheduleTask[];
backlog: ScheduleTask[];
projects: { id: string; name: string; code: string }[];
canEdit: boolean;
currentUserId: string;
activeProject?: string;
activeArtist?: string;
}
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function calcDuration(task: ScheduleTask): number {
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
}
export function SchedulePageClient({
artists,
tasks,
backlog,
projects,
canEdit,
currentUserId,
activeProject,
activeArtist,
}: SchedulePageClientProps) {
const { toast } = useToast();
const [localTasks, setLocalTasks] = useState<ScheduleTask[]>(tasks);
const [localBacklog, setLocalBacklog] = useState<ScheduleTask[]>(backlog);
const [viewStart, setViewStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
);
const [showBacklog, setShowBacklog] = useState(true);
const [filterProject, setFilterProject] = useState(activeProject ?? "");
const [filterArtist, setFilterArtist] = useState(activeArtist ?? "");
const [filterStatus, setFilterStatus] = useState("");
const [activeDrag, setActiveDrag] = useState<ActiveDragData | null>(null);
const [resizePreview, setResizePreview] = useState<{
taskId: string;
endDate: Date;
estimatedHours: number;
} | null>(null);
const [dayWidth, setDayWidth] = useState(DAY_WIDTH);
const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);
const timelineRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
})
);
const days = useMemo(
() => Array.from({ length: NUM_DAYS }, (_, i) => addDays(viewStart, i)),
[viewStart]
);
const filteredArtists = useMemo(() => {
if (!filterArtist) return artists;
return artists.filter((a) => a.id === filterArtist);
}, [artists, filterArtist]);
const filteredScheduledTasks = useMemo(() => {
return localTasks.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localTasks, filterProject, filterArtist, filterStatus]);
const filteredBacklog = useMemo(() => {
return localBacklog.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localBacklog, filterProject, filterArtist, filterStatus]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const data = event.active.data.current as ActiveDragData;
setActiveDrag(data);
},
[]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveDrag(null);
const { active, delta, over } = event;
if (!active.data.current) return;
const dragData = active.data.current as ActiveDragData;
// Resize handle - only deltaX matters
if (dragData.type === "resize") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task?.scheduledStartDate || !dragData.originalEndDate) return;
const originalEnd = toDate(dragData.originalEndDate)!;
const daysDelta = Math.round(delta.x / dayWidth);
let newEnd = addDays(originalEnd, daysDelta);
const startDate = toDate(task.scheduledStartDate)!;
if (isBefore(newEnd, startDate)) newEnd = startDate;
const prevEnd = task.scheduledEndDate;
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: newEnd.toISOString() }
: t
)
);
scheduleTask({
taskId: dragData.taskId,
scheduledStartDate: task.scheduledStartDate,
scheduledEndDate: newEnd.toISOString(),
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: prevEnd }
: t
)
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
return;
}
// Check if dropped over the backlog panel (unschedule)
if (over?.id === "backlog-drop-zone" && dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== dragData.taskId));
setLocalBacklog((prev) => [
{
...task,
scheduledStartDate: null,
scheduledEndDate: null,
},
...prev,
]);
unscheduleTask(dragData.taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) =>
prev.filter((t) => t.id !== dragData.taskId)
);
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
return;
}
// Drop on timeline - calculate position from translated rect
const translatedRect = active.rect.current.translated;
if (!translatedRect || !timelineRef.current) return;
const timelineBounds = timelineRef.current.getBoundingClientRect();
const scrollLeft = timelineRef.current.scrollLeft;
const centerX = translatedRect.left + translatedRect.width / 2;
const centerY = translatedRect.top + translatedRect.height / 2;
// Check if drop is within timeline bounds
const inTimeline =
centerX >= timelineBounds.left &&
centerX <= timelineBounds.right &&
centerY >= timelineBounds.top + HEADER_HEIGHT &&
centerY <= timelineBounds.bottom;
if (!inTimeline && dragData.type !== "backlog") return;
if (!inTimeline && dragData.type === "backlog") return;
const dayIndex = Math.max(
0,
Math.min(
Math.floor((centerX - timelineBounds.left + scrollLeft) / dayWidth),
NUM_DAYS - 1
)
);
const artistIndex = Math.max(
0,
Math.min(
Math.floor(
(centerY - timelineBounds.top - HEADER_HEIGHT) / rowHeight
),
filteredArtists.length - 1
)
);
const newArtist = filteredArtists[artistIndex];
if (!newArtist) return;
const taskId = dragData.taskId;
// Duration always derived from estimatedHours so width = hours
let duration = 1;
if (dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
} else if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
}
// Pack task within the dropped day using hour offsets.
// Sum hours already scheduled for this artist on that day,
// and start the new task at that hour (stored in scheduledStartDate's time component).
const existingForArtist = localTasks.filter(
(t) =>
t.assignedArtistId === newArtist.id &&
t.id !== taskId &&
t.scheduledStartDate
);
const droppedDay = startOfDay(addDays(viewStart, dayIndex));
const hoursOnDay = existingForArtist
.filter((t) => {
const tStart = toDate(t.scheduledStartDate!);
return tStart && differenceInDays(startOfDay(tStart), droppedDay) === 0;
})
.reduce((sum, t) => sum + (t.estimatedHours ?? 8), 0);
const newStartDate = new Date(droppedDay);
newStartDate.setHours(hoursOnDay);
const newEndDate = addDays(newStartDate, duration - 1);
if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (!task) return;
const scheduledTask: ScheduleTask = {
...task,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
};
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
setLocalTasks((prev) => [...prev, scheduledTask]);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [task, ...prev]);
toast({ title: "Failed to schedule task", variant: "destructive" });
});
} else if (dragData.type === "scheduled") {
const prevTask = localTasks.find((t) => t.id === taskId);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
if (prevTask) {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
}
toast({ title: "Failed to move task", variant: "destructive" });
});
}
},
[localTasks, localBacklog, viewStart, filteredArtists, toast, dayWidth, rowHeight]
);
const handleResizeMouseDown = useCallback(
(taskId: string, _currentEndDate: string) =>
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const prevTask = localTasks.find((t) => t.id === taskId);
if (!prevTask?.scheduledStartDate) return;
const startX = e.clientX;
const startDate = toDate(prevTask.scheduledStartDate)!;
const originalHours = prevTask.estimatedHours ?? 8;
const HOUR_WIDTH = dayWidth / 8; // px per 1 hour
let currentHours = originalHours;
const onMouseMove = (me: MouseEvent) => {
const deltaPx = me.clientX - startX;
const hoursDelta = Math.round(deltaPx / HOUR_WIDTH);
currentHours = Math.max(1, originalHours + hoursDelta);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const newEndDate = addDays(startDate, newDays - 1);
setResizePreview({
taskId,
endDate: newEndDate,
estimatedHours: currentHours,
});
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const finalEnd = addDays(startDate, newDays - 1);
setResizePreview(null);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: prevTask.scheduledStartDate,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
},
[localTasks, toast, dayWidth]
);
const activeDragTask = activeDrag
? localTasks.find((t) => t.id === activeDrag.taskId) ??
localBacklog.find((t) => t.id === activeDrag.taskId)
: null;
const handleUnschedule = useCallback(
(taskId: string) => {
const task = localTasks.find((t) => t.id === taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [
{ ...task, scheduledStartDate: null, scheduledEndDate: null },
...prev,
]);
unscheduleTask(taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
},
[localTasks, toast]
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-full overflow-hidden bg-zinc-950">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-zinc-800 bg-zinc-900 shrink-0">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-amber-400" />
<h1 className="text-lg font-semibold text-white">Schedule</h1>
<span className="text-sm text-zinc-500">
{format(viewStart, "MMM d")} {" "}
{format(addDays(viewStart, NUM_DAYS - 1), "MMM d, yyyy")}
</span>
</div>
<div className="flex items-center gap-2">
{/* Zoom controls */}
<div className="flex items-center gap-3 border border-zinc-700 rounded-md px-2.5 py-1">
<div className="flex items-center gap-1.5">
<Columns2 className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={60}
max={400}
step={8}
value={dayWidth}
onChange={(e) => setDayWidth(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
<div className="flex items-center gap-1.5">
<Rows className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={40}
max={120}
step={4}
value={rowHeight}
onChange={(e) => setRowHeight(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowBacklog((b) => !b)}
className="text-zinc-400 hover:text-white gap-2"
>
{showBacklog ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
{showBacklog ? "Hide" : "Backlog"}
</Button>
</div>
</div>
{/* Filters */}
<ScheduleFilters
projects={projects}
artists={artists}
filterProject={filterProject}
filterArtist={filterArtist}
filterStatus={filterStatus}
viewStart={viewStart}
onProjectChange={setFilterProject}
onArtistChange={setFilterArtist}
onStatusChange={setFilterStatus}
onViewStartChange={setViewStart}
/>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
<ScheduleTimeline
artists={filteredArtists}
tasks={filteredScheduledTasks}
days={days}
viewStart={viewStart}
canEdit={canEdit}
timelineRef={timelineRef}
resizePreview={resizePreview}
onResizeMouseDown={handleResizeMouseDown}
activeDragId={activeDrag?.taskId ?? null}
onUnschedule={handleUnschedule}
dayWidth={dayWidth}
rowHeight={rowHeight}
/>
{showBacklog && (
<BacklogPanel
tasks={filteredBacklog}
canEdit={canEdit}
/>
)}
</div>
{/* Drag overlay */}
<DragOverlay dropAnimation={null}>
{activeDragTask && (
<div className="rounded-md border border-amber-500/60 bg-amber-500/20 px-2 py-1.5 text-xs font-medium text-amber-200 shadow-xl opacity-90 max-w-[200px] truncate pointer-events-none">
{activeDragTask.shot?.shotCode ??
activeDragTask.asset?.assetCode ??
activeDragTask.title}
</div>
)}
</DragOverlay>
</div>
</DndContext>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { SchedulePageClient } from "./SchedulePageClient";
export const metadata = { title: "Schedule" };
export default async function SchedulePage({
searchParams,
}: {
searchParams: Promise<{ project?: string; artist?: string; status?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { project, artist, status } = await searchParams;
const [artists, scheduledTasks, backlogTasks, projects] = await Promise.all([
db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: [{ role: "asc" }, { name: "asc" }],
}),
db.task.findMany({
where: {
scheduledStartDate: { not: null },
status: { not: "DONE" },
...(project ? { projectId: project } : {}),
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: { scheduledStartDate: "asc" },
}),
db.task.findMany({
where: {
scheduledStartDate: null,
status: { not: "DONE" },
...(project ? { projectId: project } : {}),
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
}),
db.project.findMany({
where: { status: "ACTIVE" },
select: { id: true, name: true, code: true },
orderBy: { name: "asc" },
}),
]);
const canEdit = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role
);
const serializeTask = (t: (typeof scheduledTasks)[number]) => ({
...t,
dueDate: t.dueDate ? t.dueDate.toISOString() : null,
scheduledStartDate: t.scheduledStartDate ? t.scheduledStartDate.toISOString() : null,
scheduledEndDate: t.scheduledEndDate ? t.scheduledEndDate.toISOString() : null,
});
return (
<SchedulePageClient
artists={artists}
tasks={scheduledTasks.map(serializeTask)}
backlog={backlogTasks.map(serializeTask)}
projects={projects}
canEdit={canEdit}
currentUserId={session.user.id}
activeProject={project}
activeArtist={artist}
/>
);
}