Added status tracking & episode due dates
Deploy / deploy (push) Failing after 2m30s

This commit is contained in:
twotalesanimation
2026-06-03 14:49:12 +02:00
parent 15046892d1
commit e75a15132e
12 changed files with 915 additions and 25 deletions
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ShotCard } from "@/components/shots/ShotCard";
import { NewShotDialog } from "@/components/shots/NewShotDialog";
import { ImportShotsDialog } from "@/components/shots/ImportShotsDialog";
@@ -11,9 +12,12 @@ import { TaskCard } from "@/components/tasks/TaskCard";
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
import { cn } from "@/lib/utils";
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight } from "lucide-react";
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight, Calendar, Pencil, Check, X } from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
import { setEpisodeDueDate } from "@/actions/episode-due-dates";
import { useToast } from "@/components/ui/use-toast";
import { format } from "date-fns";
type Tab = "shots" | "assets" | "tasks" | "kanban" | "settings";
@@ -61,6 +65,7 @@ interface ProjectTabsClientProps {
tasks: any[];
artists: Artist[];
shotGroups: { id: string; name: string }[];
episodeDueDates: { episode: string; dueDate: Date | string }[];
canManage: boolean;
}
@@ -75,14 +80,42 @@ export function ProjectTabsClient({
tasks,
artists,
shotGroups,
episodeDueDates,
canManage,
}: ProjectTabsClientProps) {
const { toast } = useToast();
const [activeTab, setActiveTab] = useState<Tab>("shots");
const [showNewShot, setShowNewShot] = useState(false);
const [showImportShots, setShowImportShots] = useState(false);
const [showNewAsset, setShowNewAsset] = useState(false);
const [showNewTask, setShowNewTask] = useState(false);
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const [editingEpisode, setEditingEpisode] = useState<string | null>(null);
const [episodeDateInput, setEpisodeDateInput] = useState("");
const [isPendingDate, startDateTransition] = useTransition();
// Build a lookup map for episode due dates
const episodeDueDateMap = new Map(
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
);
const handleSaveEpisodeDate = (episode: string) => {
startDateTransition(async () => {
try {
await setEpisodeDueDate({
projectId,
episode,
dueDate: episodeDateInput || null,
});
toast({ title: `Due date ${episodeDateInput ? "set" : "cleared"} for Episode ${episode}` });
} catch {
toast({ title: "Failed to save due date", variant: "destructive" });
} finally {
setEditingEpisode(null);
setEpisodeDateInput("");
}
});
};
const toggleEpisode = (ep: string) =>
setCollapsedEpisodes((prev) => {
@@ -211,23 +244,98 @@ export function ProjectTabsClient({
<div className="space-y-4">
{episodeGroups.map(([episode, episodeShots]) => {
const collapsed = collapsedEpisodes.has(episode);
const dueDate = episodeDueDateMap.get(episode);
const isOverdue = dueDate && dueDate < new Date();
const isEditing = editingEpisode === episode;
return (
<div key={episode}>
<button
onClick={() => toggleEpisode(episode)}
className="flex items-center gap-2 w-full mb-3 group text-left"
>
{collapsed
? <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
: <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />}
<span className="font-semibold text-sm">
Episode {episode}
</span>
<span className="text-xs text-muted-foreground font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
<div className="flex items-center gap-2 w-full mb-3">
<button
onClick={() => toggleEpisode(episode)}
className="flex items-center gap-2 shrink-0"
>
{collapsed
? <ChevronRight className="h-4 w-4 text-muted-foreground" />
: <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
<button
onClick={() => toggleEpisode(episode)}
className="flex items-center gap-2 text-left"
>
<span className="font-semibold text-sm">
Episode {episode}
</span>
<span className="text-xs text-muted-foreground font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
</button>
{/* Due date display / edit */}
{isEditing ? (
<div className="flex items-center gap-1 ml-2">
<Input
type="date"
value={episodeDateInput}
onChange={(e) => setEpisodeDateInput(e.target.value)}
className="h-6 w-36 text-xs px-2 py-0"
/>
<button
onClick={() => handleSaveEpisodeDate(episode)}
disabled={isPendingDate}
className="text-emerald-400 hover:text-emerald-300 p-0.5"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { setEditingEpisode(null); setEpisodeDateInput(""); }}
className="text-zinc-500 hover:text-zinc-300 p-0.5"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 ml-2">
{dueDate && (
<span className={cn(
"flex items-center gap-1 text-xs",
isOverdue ? "text-red-400" : "text-zinc-400"
)}>
<Calendar className="h-3 w-3" />
{format(dueDate, "d MMM yyyy")}
</span>
)}
{canManage && (
<button
onClick={() => {
setEditingEpisode(episode);
setEpisodeDateInput(
dueDate ? format(dueDate, "yyyy-MM-dd") : ""
);
}}
className="text-zinc-600 hover:text-zinc-300 p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
title="Set due date"
>
<Pencil className="h-3 w-3" />
</button>
)}
</div>
)}
<div className="flex-1 h-px bg-border ml-1" />
</button>
{/* Always-visible edit button for managers */}
{canManage && !isEditing && (
<button
onClick={() => {
setEditingEpisode(episode);
setEpisodeDateInput(
dueDate ? format(dueDate, "yyyy-MM-dd") : ""
);
}}
className="text-zinc-600 hover:text-zinc-400 transition-colors shrink-0"
title="Set episode due date"
>
<Pencil className="h-3 w-3" />
</button>
)}
</div>
{!collapsed && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{episodeShots.map((shot) => (
+3 -1
View File
@@ -106,11 +106,12 @@ async function getTeamMembers() {
export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
const [project, artists, clients, teamMembers] = await Promise.all([
const [project, artists, clients, teamMembers, episodeDueDates] = await Promise.all([
getProject(id),
getProjectArtists(),
getClients(),
getTeamMembers(),
db.episodeDueDate.findMany({ where: { projectId: id }, orderBy: { episode: "asc" } }),
]);
if (!project) notFound();
@@ -208,6 +209,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
tasks={project.tasks as any}
artists={artists}
shotGroups={project.shotGroups}
episodeDueDates={episodeDueDates}
canManage={!!canManage}
/>
</div>
@@ -0,0 +1,348 @@
"use client";
import { useState, useTransition, useCallback } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { updateShotNotes } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import {
Film,
Clock,
CheckCircle2,
AlertCircle,
Calendar,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import { format } from "date-fns";
type ShotRow = {
id: string;
shotCode: string;
scene: string;
episode: string | null;
shotNumber: number;
status: string;
priority: string;
dueDate: Date | string | null;
thumbnailUrl: string | null;
notes: string | null;
description: string | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
};
interface Project {
id: string;
name: string;
code: string;
projectType: string;
}
interface ShotStatusClientProps {
projects: Project[];
selectedProjectId: string | null;
selectedProject: Project | null;
shots: ShotRow[];
canManage: boolean;
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film },
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle },
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
};
function NotesCell({
shot,
canManage,
}: {
shot: ShotRow;
canManage: boolean;
}) {
const [value, setValue] = useState(shot.notes ?? "");
const [saved, setSaved] = useState(true);
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
const handleBlur = useCallback(() => {
if (value === (shot.notes ?? "")) return;
startTransition(async () => {
try {
await updateShotNotes(shot.id, value);
setSaved(true);
toast({ title: "Notes saved" });
} catch {
toast({ title: "Failed to save notes", variant: "destructive" });
}
});
}, [value, shot.id, shot.notes, toast]);
if (!canManage) {
return (
<span className="text-sm text-zinc-400 whitespace-pre-wrap">
{shot.notes ?? <span className="italic text-zinc-600"></span>}
</span>
);
}
return (
<div className="relative">
<Textarea
value={value}
onChange={(e) => { setValue(e.target.value); setSaved(false); }}
onBlur={handleBlur}
placeholder="Add notes..."
className="min-h-[60px] text-sm resize-none bg-zinc-900 border-zinc-700 focus:border-zinc-500 text-zinc-200 placeholder:text-zinc-600"
rows={2}
/>
{isPending && (
<Loader2 className="absolute bottom-2 right-2 h-3 w-3 animate-spin text-zinc-500" />
)}
{!isPending && !saved && (
<span className="absolute bottom-1.5 right-2 text-[10px] text-zinc-500">unsaved</span>
)}
</div>
);
}
function ShotTable({ shots, canManage }: { shots: ShotRow[]; canManage: boolean }) {
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-14">Thumb</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Shot</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Status</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Due Date</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{shots.map((shot) => {
const cfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const StatusIcon = cfg.icon;
const dueDate = shot.dueDate ? new Date(shot.dueDate) : null;
const isOverdue = dueDate && dueDate < new Date() && shot.status !== "COMPLETE";
return (
<tr key={shot.id} className="hover:bg-zinc-800/30 transition-colors">
{/* Thumbnail */}
<td className="py-2 px-3">
<div className="w-10 h-10 rounded overflow-hidden bg-zinc-800 flex items-center justify-center shrink-0">
{shot.thumbnailUrl ? (
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
width={40}
height={40}
className="object-cover w-full h-full"
/>
) : (
<Film className="h-4 w-4 text-zinc-600" />
)}
</div>
</td>
{/* Shot name */}
<td className="py-2 px-3">
<span className="font-mono text-sm text-white">{shot.shotCode}</span>
{shot.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
{shot.description}
</p>
)}
</td>
{/* Status */}
<td className="py-2 px-3">
<Badge className={cn("gap-1 text-xs", cfg.color)}>
<StatusIcon className="h-3 w-3" />
{cfg.label}
</Badge>
</td>
{/* Due date */}
<td className="py-2 px-3">
{dueDate ? (
<span
className={cn(
"flex items-center gap-1 text-xs",
isOverdue ? "text-red-400" : "text-zinc-400"
)}
>
<Calendar className="h-3 w-3" />
{format(dueDate, "d MMM yyyy")}
</span>
) : (
<span className="text-xs text-zinc-600 italic"></span>
)}
</td>
{/* Notes */}
<td className="py-2 px-3 min-w-[220px]">
<NotesCell shot={shot} canManage={canManage} />
</td>
</tr>
);
})}
</tbody>
</table>
);
}
export function ShotStatusClient({
projects,
selectedProjectId,
selectedProject,
shots,
canManage,
}: ShotStatusClientProps) {
const router = useRouter();
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const toggleEpisode = (ep: string) =>
setCollapsedEpisodes((prev) => {
const next = new Set(prev);
next.has(ep) ? next.delete(ep) : next.add(ep);
return next;
});
const handleProjectChange = (id: string) => {
router.push(`/shot-status?projectId=${id}`);
};
// Group by episode for episodic projects
const isEpisodic = selectedProject?.projectType === "EPISODIC";
const episodeGroups: [string, ShotRow[]][] = isEpisodic
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
const key = shot.episode ?? "(No Episode)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
}
return Array.from(map.entries());
})()
: [];
const statusCounts = shots.reduce(
(acc, s) => {
acc[s.status] = (acc[s.status] ?? 0) + 1;
return acc;
},
{} as Record<string, number>
);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-white">Shot Status</h1>
<p className="text-zinc-400 mt-1">Track shot progress and add production notes</p>
</div>
{/* Project selector */}
<div className="flex items-center gap-3">
<label className="text-sm text-zinc-400 shrink-0">Project:</label>
<Select value={selectedProjectId ?? ""} onValueChange={handleProjectChange}>
<SelectTrigger className="w-72">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
<span className="font-mono text-xs text-zinc-400 mr-2">{p.code}</span>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* No project selected */}
{!selectedProject && (
<div className="text-center py-20 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a project to view shot status</p>
</div>
)}
{/* Shots table */}
{selectedProject && shots.length === 0 && (
<div className="text-center py-20 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No shots in this project yet</p>
</div>
)}
{selectedProject && shots.length > 0 && (
<>
{/* Summary pills */}
<div className="flex flex-wrap gap-2">
<span className="text-xs text-zinc-500 self-center">{shots.length} shots total</span>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
const count = statusCounts[key] ?? 0;
if (!count) return null;
return (
<Badge key={key} className={cn("gap-1 text-xs", cfg.color)}>
{count} {cfg.label}
</Badge>
);
})}
</div>
{/* Table */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden">
{isEpisodic ? (
<div className="divide-y divide-zinc-800">
{episodeGroups.map(([episode, episodeShots]) => {
const collapsed = collapsedEpisodes.has(episode);
return (
<div key={episode}>
<button
onClick={() => toggleEpisode(episode)}
className="flex items-center gap-2 w-full px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">Episode {episode}</span>
<span className="text-xs text-zinc-500 font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
</button>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable shots={episodeShots} canManage={canManage} />
</div>
)}
</div>
);
})}
</div>
) : (
<div className="overflow-x-auto">
<ShotTable shots={shots} canManage={canManage} />
</div>
)}
</div>
</>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { ShotStatusClient } from "./ShotStatusClient";
async function getProjects() {
return db.project.findMany({
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
select: { id: true, name: true, code: true, projectType: true },
orderBy: { name: "asc" },
});
}
async function getShotsForProject(projectId: string) {
return db.shot.findMany({
where: { projectId },
orderBy: [
{ episode: { sort: "asc", nulls: "last" } },
{ scene: "asc" },
{ shotNumber: "asc" },
],
select: {
id: true,
shotCode: true,
scene: true,
episode: true,
shotNumber: true,
status: true,
priority: true,
dueDate: true,
thumbnailUrl: true,
notes: true,
description: true,
artist: { select: { id: true, name: true, image: true, email: true } },
},
});
}
export default async function ShotStatusPage({
searchParams,
}: {
searchParams: Promise<{ projectId?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
const { projectId } = await searchParams;
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const [projects, shots] = await Promise.all([
getProjects(),
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
]);
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
return (
<ShotStatusClient
projects={projects}
selectedProjectId={projectId ?? null}
selectedProject={selectedProject}
shots={shots as any}
canManage={canManage}
/>
);
}