This commit is contained in:
@@ -14,6 +14,9 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { internallyApproveShot, shareWithClient } from "@/actions/shots";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
Film,
|
Film,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -21,6 +24,8 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
ShieldCheck,
|
||||||
|
Send,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -46,12 +51,15 @@ interface PlaylistShot {
|
|||||||
shotCode: string;
|
shotCode: string;
|
||||||
episode: string | null;
|
episode: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
shotApprovalStatus: string;
|
||||||
|
sharedWithClient: boolean;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
latestVersion: PlaylistVersion;
|
latestVersion: PlaylistVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlaylistClientProps {
|
interface PlaylistClientProps {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
|
userRole: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const APPROVAL_COLORS: Record<string, string> = {
|
const APPROVAL_COLORS: Record<string, string> = {
|
||||||
@@ -68,14 +76,18 @@ const APPROVAL_ICONS: Record<string, React.ElementType> = {
|
|||||||
NEEDS_CHANGES: AlertCircle,
|
NEEDS_CHANGES: AlertCircle,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PlaylistClient({ projects }: PlaylistClientProps) {
|
export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||||
const playerRef = useRef<ReviewPlayerRef>(null);
|
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||||
const reset = useReviewStore((s) => s.reset);
|
const reset = useReviewStore((s) => s.reset);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [projectId, setProjectId] = useState<string | null>(null);
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
const [shots, setShots] = useState<PlaylistShot[]>([]);
|
const [shots, setShots] = useState<PlaylistShot[]>([]);
|
||||||
const [activeShot, setActiveShot] = useState<PlaylistShot | null>(null);
|
const [activeShot, setActiveShot] = useState<PlaylistShot | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isActioning, setIsActioning] = useState(false);
|
||||||
|
|
||||||
|
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(userRole);
|
||||||
|
|
||||||
// Restore last project from localStorage on mount
|
// Restore last project from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,6 +137,46 @@ export function PlaylistClient({ projects }: PlaylistClientProps) {
|
|||||||
setActiveShot(shot);
|
setActiveShot(shot);
|
||||||
}, [reset]);
|
}, [reset]);
|
||||||
|
|
||||||
|
const refreshShots = useCallback(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
fetch(`/api/playlist?projectId=${projectId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
const list: PlaylistShot[] = data.shots ?? [];
|
||||||
|
setShots(list);
|
||||||
|
setActiveShot((prev) => prev ? (list.find((s) => s.id === prev.id) ?? prev) : null);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleInternallyApprove = async () => {
|
||||||
|
if (!activeShot) return;
|
||||||
|
setIsActioning(true);
|
||||||
|
try {
|
||||||
|
await internallyApproveShot(activeShot.id);
|
||||||
|
toast({ title: "Shot internally approved", description: "Status: Ready for Client" });
|
||||||
|
refreshShots();
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Failed", description: (e as Error).message, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsActioning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareWithClient = async () => {
|
||||||
|
if (!activeShot) return;
|
||||||
|
setIsActioning(true);
|
||||||
|
try {
|
||||||
|
await shareWithClient(activeShot.id);
|
||||||
|
toast({ title: "Shared with client", description: "Status: Client Review" });
|
||||||
|
refreshShots();
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Failed", description: (e as Error).message, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsActioning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Group shots by episode for episodic projects
|
// Group shots by episode for episodic projects
|
||||||
const selectedProject = projects.find((p) => p.id === projectId);
|
const selectedProject = projects.find((p) => p.id === projectId);
|
||||||
const isEpisodic = selectedProject?.projectType === "EPISODIC";
|
const isEpisodic = selectedProject?.projectType === "EPISODIC";
|
||||||
@@ -173,6 +225,33 @@ export function PlaylistClient({ projects }: PlaylistClientProps) {
|
|||||||
})()}
|
})()}
|
||||||
{activeShot.latestVersion.approvalStatus.replace(/_/g, " ")}
|
{activeShot.latestVersion.approvalStatus.replace(/_/g, " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
|
{canApprove && activeShot.shotApprovalStatus === "PENDING" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isActioning}
|
||||||
|
onClick={handleInternallyApprove}
|
||||||
|
className="h-7 text-xs gap-1 text-purple-400 border-purple-500/30 hover:bg-purple-500/10"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline">Internally Approve</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canApprove && activeShot.shotApprovalStatus === "INTERNALLY_APPROVED" && !activeShot.sharedWithClient && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isActioning}
|
||||||
|
onClick={handleShareWithClient}
|
||||||
|
className="h-7 text-xs gap-1 text-amber-400 border-amber-500/30 hover:bg-amber-500/10"
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline">Share with Client</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/review/${activeShot.latestVersion.id}`}
|
href={`/review/${activeShot.latestVersion.id}`}
|
||||||
className="text-zinc-500 hover:text-zinc-200 transition-colors"
|
className="text-zinc-500 hover:text-zinc-200 transition-colors"
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ export default async function PlaylistPage() {
|
|||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return <PlaylistClient projects={projects} />;
|
return <PlaylistClient projects={projects} userRole={session.user.role} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export async function GET(req: NextRequest) {
|
|||||||
shotCode: true,
|
shotCode: true,
|
||||||
episode: true,
|
episode: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
shotApprovalStatus: true,
|
||||||
|
sharedWithClient: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
tasks: {
|
tasks: {
|
||||||
select: {
|
select: {
|
||||||
@@ -71,6 +73,8 @@ export async function GET(req: NextRequest) {
|
|||||||
shotCode: shot.shotCode,
|
shotCode: shot.shotCode,
|
||||||
episode: shot.episode,
|
episode: shot.episode,
|
||||||
status: shot.status,
|
status: shot.status,
|
||||||
|
shotApprovalStatus: shot.shotApprovalStatus,
|
||||||
|
sharedWithClient: shot.sharedWithClient,
|
||||||
thumbnailUrl: shot.thumbnailUrl,
|
thumbnailUrl: shot.thumbnailUrl,
|
||||||
latestVersion: {
|
latestVersion: {
|
||||||
id: latest.id,
|
id: latest.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user