+52
-8
@@ -613,6 +613,8 @@ export async function internallyApproveShot(shotId: string) {
|
|||||||
/**
|
/**
|
||||||
* Producer/Supervisor/Admin: share an internally-approved shot with the client.
|
* Producer/Supervisor/Admin: share an internally-approved shot with the client.
|
||||||
* Sets sharedWithClient = true → status becomes CLIENT_REVIEW.
|
* Sets sharedWithClient = true → status becomes CLIENT_REVIEW.
|
||||||
|
* Also marks the latest version of every task on this shot as isClientVisible = true
|
||||||
|
* so the client portal can display them.
|
||||||
*/
|
*/
|
||||||
export async function shareWithClient(shotId: string) {
|
export async function shareWithClient(shotId: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -623,16 +625,43 @@ export async function shareWithClient(shotId: string) {
|
|||||||
|
|
||||||
const shot = await db.shot.findUnique({
|
const shot = await db.shot.findUnique({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
select: { projectId: true, shotApprovalStatus: true },
|
select: {
|
||||||
|
projectId: true,
|
||||||
|
shotApprovalStatus: true,
|
||||||
|
tasks: { select: { id: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!shot) throw new Error("Shot not found");
|
if (!shot) throw new Error("Shot not found");
|
||||||
if (shot.shotApprovalStatus !== "INTERNALLY_APPROVED") {
|
if (shot.shotApprovalStatus !== "INTERNALLY_APPROVED") {
|
||||||
throw new Error("Shot must be internally approved before sharing with client");
|
throw new Error("Shot must be internally approved before sharing with client");
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.shot.update({
|
const now = new Date();
|
||||||
where: { id: shotId },
|
|
||||||
data: { sharedWithClient: true, status: "CLIENT_REVIEW" },
|
await db.$transaction(async (tx) => {
|
||||||
|
// Mark shot as shared
|
||||||
|
await tx.shot.update({
|
||||||
|
where: { id: shotId },
|
||||||
|
data: { sharedWithClient: true, status: "CLIENT_REVIEW" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// For each task, make the latest version client-visible
|
||||||
|
for (const task of shot.tasks) {
|
||||||
|
const latest = await tx.version.findFirst({
|
||||||
|
where: { taskId: task.id, isLatest: true },
|
||||||
|
select: { id: true, isClientVisible: true },
|
||||||
|
});
|
||||||
|
if (latest && !latest.isClientVisible) {
|
||||||
|
await tx.version.update({
|
||||||
|
where: { id: latest.id },
|
||||||
|
data: {
|
||||||
|
isClientVisible: true,
|
||||||
|
sharedAt: now,
|
||||||
|
sharedById: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/projects/${shot.projectId}`);
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
@@ -644,6 +673,7 @@ export async function shareWithClient(shotId: string) {
|
|||||||
/**
|
/**
|
||||||
* Producer/Supervisor/Admin: remove a shot from client review.
|
* Producer/Supervisor/Admin: remove a shot from client review.
|
||||||
* Sets sharedWithClient = false → status becomes READY_FOR_CLIENT.
|
* Sets sharedWithClient = false → status becomes READY_FOR_CLIENT.
|
||||||
|
* Also hides all task versions that were made visible by shareWithClient.
|
||||||
*/
|
*/
|
||||||
export async function unshareFromClient(shotId: string) {
|
export async function unshareFromClient(shotId: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -654,13 +684,27 @@ export async function unshareFromClient(shotId: string) {
|
|||||||
|
|
||||||
const shot = await db.shot.findUnique({
|
const shot = await db.shot.findUnique({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
select: { projectId: true },
|
select: {
|
||||||
|
projectId: true,
|
||||||
|
tasks: { select: { id: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!shot) throw new Error("Shot not found");
|
if (!shot) throw new Error("Shot not found");
|
||||||
|
|
||||||
await db.shot.update({
|
await db.$transaction(async (tx) => {
|
||||||
where: { id: shotId },
|
await tx.shot.update({
|
||||||
data: { sharedWithClient: false, status: "READY_FOR_CLIENT" },
|
where: { id: shotId },
|
||||||
|
data: { sharedWithClient: false, status: "READY_FOR_CLIENT" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide all client-visible versions on this shot's tasks
|
||||||
|
const taskIds = shot.tasks.map((t) => t.id);
|
||||||
|
if (taskIds.length > 0) {
|
||||||
|
await tx.version.updateMany({
|
||||||
|
where: { taskId: { in: taskIds }, isClientVisible: true },
|
||||||
|
data: { isClientVisible: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/projects/${shot.projectId}`);
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { submitApproval } from "@/actions/approvals";
|
import { submitApproval } from "@/actions/approvals";
|
||||||
|
import { internallyApproveShot } from "@/actions/shots";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
|
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Film,
|
Film,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface ReviewPageClientProps {
|
interface ReviewPageClientProps {
|
||||||
@@ -110,10 +112,11 @@ export function ReviewPageClient({
|
|||||||
const [pendingFrame, setPendingFrame] = useState<number | null>(null);
|
const [pendingFrame, setPendingFrame] = useState<number | null>(null);
|
||||||
const [approvalDialog, setApprovalDialog] = useState<{
|
const [approvalDialog, setApprovalDialog] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
|
status: "REJECTED" | "NEEDS_CHANGES" | null;
|
||||||
}>({ open: false, status: null });
|
}>({ open: false, status: null });
|
||||||
const [approvalNotes, setApprovalNotes] = useState("");
|
const [approvalNotes, setApprovalNotes] = useState("");
|
||||||
const [isSubmittingApproval, setIsSubmittingApproval] = useState(false);
|
const [isSubmittingApproval, setIsSubmittingApproval] = useState(false);
|
||||||
|
const [isInternallyApproving, setIsInternallyApproving] = useState(false);
|
||||||
const [showVersions, setShowVersions] = useState(false);
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
|
|
||||||
const playerRef = useRef<ReviewPlayerRef>(null);
|
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||||
@@ -165,9 +168,7 @@ export function ReviewPageClient({
|
|||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title:
|
title:
|
||||||
approvalDialog.status === "APPROVED"
|
approvalDialog.status === "REJECTED"
|
||||||
? "Version approved!"
|
|
||||||
: approvalDialog.status === "REJECTED"
|
|
||||||
? "Version rejected"
|
? "Version rejected"
|
||||||
: "Changes requested",
|
: "Changes requested",
|
||||||
});
|
});
|
||||||
@@ -176,7 +177,7 @@ export function ReviewPageClient({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: "Failed to submit approval",
|
title: "Failed to submit",
|
||||||
description: (err as Error).message,
|
description: (err as Error).message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
@@ -185,7 +186,26 @@ export function ReviewPageClient({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openApproval = (status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES") => {
|
const handleInternalApprove = async () => {
|
||||||
|
const shotId = version.shot?.id;
|
||||||
|
if (!shotId) return;
|
||||||
|
setIsInternallyApproving(true);
|
||||||
|
try {
|
||||||
|
await internallyApproveShot(shotId);
|
||||||
|
toast({ title: "Shot internally approved", description: "Status set to Ready for Client" });
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to approve",
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsInternallyApproving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openApproval = (status: "REJECTED" | "NEEDS_CHANGES") => {
|
||||||
setApprovalDialog({ open: true, status });
|
setApprovalDialog({ open: true, status });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,14 +308,19 @@ export function ReviewPageClient({
|
|||||||
<XCircle className="h-3 w-3" />
|
<XCircle className="h-3 w-3" />
|
||||||
<span className="hidden sm:inline">Reject</span>
|
<span className="hidden sm:inline">Reject</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{version.shot?.id && (
|
||||||
size="sm"
|
<Button
|
||||||
className="h-7 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
|
size="sm"
|
||||||
onClick={() => openApproval("APPROVED")}
|
className="h-7 text-xs gap-1 bg-sky-600 hover:bg-sky-500 text-white"
|
||||||
>
|
onClick={handleInternalApprove}
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
disabled={isInternallyApproving}
|
||||||
<span className="hidden sm:inline">Approve</span>
|
>
|
||||||
</Button>
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{isInternallyApproving ? "Approving…" : "Approve Internally"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,9 +363,7 @@ export function ReviewPageClient({
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{approvalDialog.status === "APPROVED"
|
{approvalDialog.status === "REJECTED"
|
||||||
? "Approve Version"
|
|
||||||
: approvalDialog.status === "REJECTED"
|
|
||||||
? "Reject Version"
|
? "Reject Version"
|
||||||
: "Request Changes"}
|
: "Request Changes"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@@ -372,9 +395,7 @@ export function ReviewPageClient({
|
|||||||
onClick={handleApprovalSubmit}
|
onClick={handleApprovalSubmit}
|
||||||
disabled={isSubmittingApproval}
|
disabled={isSubmittingApproval}
|
||||||
className={
|
className={
|
||||||
approvalDialog.status === "APPROVED"
|
approvalDialog.status === "REJECTED"
|
||||||
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
|
||||||
: approvalDialog.status === "REJECTED"
|
|
||||||
? "bg-red-600 hover:bg-red-500 text-white"
|
? "bg-red-600 hover:bg-red-500 text-white"
|
||||||
: "bg-orange-600 hover:bg-orange-500 text-white"
|
: "bg-orange-600 hover:bg-orange-500 text-white"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user