src/features/artifact/review/components/ApprovalClient.tsx
line 1
"use client";
// access-gate-exempt: bulk approve acts only on "Awaiting me", which the server fills with
// the requests whose decision is the viewer's (the same right ReviewPanel reads as
// `canDecide`, not a brand role); the server re-checks every decision and a refusal is
// reported, never counted.
import { useNow } from "@/shared/hooks/useNow";
import { relativeLong } from "@/utils/timeAgo";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useSearchParams } from "next/navigation";
import { useState } from "react";
line 10
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { toast } from "@/utils/toast";
import { LazyVideoStill } from "@/components/common/lazy-video-still";
import { SegmentedControl } from "@/components/ui/segmented-control";
line 24
} from "@/utils/objectProxy";
import { listApprovals } from "../services/approval.service";
import { decideApproval, listApprovals } from "../services/approval.service";
import { ApprovalStatusBadge } from "./ApprovalStatusBadge";
import { ApproverTicks } from "./ApproverTicks";
line 67
const approvals = data?.approvals ?? [];
// Bulk approve (RISE G33): "Awaiting me" only, and each request is still decided on its
// own, so one the server refuses is reported and the rest go through.
const queryClient = useQueryClient();
const bulk = tab === "awaiting_me" && approvals.length > 0;
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [approving, setApproving] = useState(false);
const chosen = approvals.filter((approval) => selected.has(approval.id));
const allChosen = bulk && chosen.length === approvals.length;
const toggle = (id: string, on: boolean) =>
setSelected((current) => {
const next = new Set(current);
if (on) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
const changeTab = (next: ApprovalTab) => {
setSelected(new Set());
setTab(next);
};
const approveChosen = async () => {
setApproving(true);
let failed = 0;
for (const approval of chosen) {
try {
await decideApproval(brandId, approval.id, { status: "approved" });
} catch {
failed += 1;
}
}
const approved = chosen.length - failed;
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["submissions", brandId] }),
queryClient.invalidateQueries({ queryKey: ["artifacts", brandId] }),
]);
setApproving(false);
setConfirmOpen(false);
setSelected(new Set());
if (failed === 0) {
toast.success(t("bulk.success", { count: approved }));
} else {
toast.error(t("bulk.partial", { approved, failed }));
}
};
return (
<div className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-5 px-8 py-10">
line 84
}))}
value={tab}
onChange={setTab}
onChange={changeTab}
/>
</header>
{bulk && !isPending && (
<div className="flex min-h-9 items-center gap-3 px-1">
<Label
size="sm"
tone="muted"
weight="normal"
className="flex items-center gap-2.5"
>
<Checkbox
checked={allChosen}
indeterminate={chosen.length > 0 && !allChosen}
onCheckedChange={(checked) =>
setSelected(
checked
? new Set(approvals.map((approval) => approval.id))
: new Set()
)
}
/>
{chosen.length > 0
? t("bulk.selected", { count: chosen.length })
: t("bulk.selectAll")}
</Label>
{chosen.length > 0 && (
<Button
type="button"
size="sm"
shape="pill"
className="ml-auto"
onClick={() => setConfirmOpen(true)}
>
{t("bulk.approve", { count: chosen.length })}
</Button>
)}
</div>
)}
{isPending ? (
<div className="flex flex-col gap-2">
line 101
<ul className="flex flex-col gap-2">
{approvals.map((approval) => (
<li key={approval.id}>
<li key={approval.id} className="flex items-center gap-3">
{bulk && (
<Checkbox
aria-label={t("bulk.selectOne", {
name: approval.artifact.name ?? t("untitled"),
})}
checked={selected.has(approval.id)}
onCheckedChange={(checked) => toggle(approval.id, checked)}
className="ml-1 shrink-0"
/>
)}
<InboxRow
approval={approval}
line 113
</ul>
)}
<ConfirmDialog
open={confirmOpen}
title={t("bulk.confirmTitle", { count: chosen.length })}
message={t("bulk.confirmMessage")}
confirmLabel={t("bulk.confirm")}
confirmLoading={approving}
onConfirm={() => void approveChosen()}
onCancel={() => setConfirmOpen(false)}
/>
{openArtifactId && (
<InboxLightbox
line 156
wrap="normal"
onClick={onOpen}
className="flex w-full"
className="flex w-full min-w-0 flex-1"
>
{isAudioMime(approval.artifact.mimeType) ? (
src/i18n/locales/vi.json
line 86
"rowMeta": "{who} · đã yêu cầu {when} · cần {count} lượt duyệt",
"rowMetaStage": "{who} · đã yêu cầu {when} · cần {count} lượt duyệt · Vòng {index}/{total}",
"withdrawn": "Đã hủy yêu cầu"
"withdrawn": "Đã hủy yêu cầu",
"bulk": {
"selectAll": "Chọn tất cả",
"selected": "Đã chọn {count}",
"selectOne": "Chọn {name}",
"approve": "Duyệt {count} mục",
"confirmTitle": "Duyệt {count} ấn phẩm?",
"confirmMessage": "Mỗi yêu cầu sẽ được ghi là bạn đã duyệt. Hãy chắc chắn bạn đã xem từng ấn phẩm.",
"confirm": "Duyệt",
"success": "Đã duyệt {count} yêu cầu.",
"partial": "Đã duyệt {approved}. {failed} yêu cầu không duyệt được; mở từng mục để xem lý do."
}
},
"thread": {