Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { prisma } from "@msk-forms/db";
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";

import { getCurrentUser } from "@/lib/auth";
import { canReviewForm } from "@/lib/guild";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const bodySchema = z.object({ archived: z.boolean() });

/**
* Archive (soft-hide) or restore a submission. Archived submissions drop out of
* the active Submissions list and Board but are never deleted — they live on the
* dedicated "Archived" page. Reviewer-gated to the submission's form.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ guildId: string; id: string }> },
) {
const { guildId, id } = await params;

const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });

const submission = await prisma.submission.findUnique({
where: { id },
select: { guildId: true, formId: true },
});
if (!submission || submission.guildId !== guildId) {
return NextResponse.json({ error: "Submission not found." }, { status: 404 });
}
if (!(await canReviewForm(guildId, user.id, submission.formId))) {
return NextResponse.json({ error: "Forbidden." }, { status: 403 });
}

const parsed = bodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid request." }, { status: 422 });
}

await prisma.submission.update({
where: { id },
data: { archivedAt: parsed.data.archived ? new Date() : null },
});
return NextResponse.json({ ok: true });
}
118 changes: 118 additions & 0 deletions apps/web/src/app/dashboard/[guildId]/submissions/archived/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { prisma } from "@msk-forms/db";
import { Card } from "@msk-forms/ui";
import { IconArrowLeft } from "@tabler/icons-react";
import type { Route } from "next";
import Link from "next/link";

import { SubmissionsTable } from "@/components/dashboard/submissions-table";
import { requireUser } from "@/lib/auth";
import { resolveStatus, statusOptions } from "@/lib/forms";
import { getGuildSubmissions, getReviewScope } from "@/lib/guild";
import { getDict } from "@/i18n";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export default async function ArchivedSubmissionsPage({
params,
}: {
params: Promise<{ guildId: string }>;
}) {
const { guildId } = await params;
const user = await requireUser(`/dashboard/${guildId}/submissions/archived`);
const dict = await getDict();
const t = dict.dashboard;

const scope = await getReviewScope(guildId, user.id);
const canReview = scope.all || scope.formIds.length > 0;
if (!canReview) {
return (
<Card className="p-8">
<p className="text-muted-foreground">{t.board.noPerm}</p>
</Card>
);
}

const [submissions, defs] = await Promise.all([
getGuildSubmissions(guildId, scope, { archived: true }),
prisma.formStatusDef.findMany({
where: { guildId },
orderBy: { order: "asc" },
select: { key: true, label: true, color: true, order: true },
}),
]);

const backLink = (
<Link
href={`/dashboard/${guildId}/submissions` as Route}
className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<IconArrowLeft size={16} stroke={1.75} />
{t.backToSubmissions}
</Link>
);

const header = (
<div className="flex flex-col gap-1">
{backLink}
<h2 className="font-heading text-xl font-semibold text-foreground">{t.archivedTitle}</h2>
</div>
);

if (submissions.length === 0) {
return (
<div className="flex flex-col gap-4">
{header}
<Card className="p-8">
<p className="text-muted-foreground">{t.noArchived}</p>
</Card>
</div>
);
}

const options = statusOptions(defs, dict.statusLabels);
const present = new Set(submissions.map((s) => s.status));
const extra = [...present]
.filter((k) => !options.some((o) => o.key === k))
.map((k) => resolveStatus(k, defs, dict.statusLabels));

const rows = submissions.map((s) => ({
id: s.id,
applicant: s.user?.username ?? t.anonymous,
avatar: s.user?.avatar ?? null,
formId: s.formId,
formTitle: s.form.title,
date: s.submittedAt.toISOString().slice(0, 10),
status: s.status,
score: s.score,
}));

return (
<div className="flex flex-col gap-4">
{header}
<SubmissionsTable
guildId={guildId}
rows={rows}
options={[...options, ...extra]}
canReview={canReview}
archived
labels={{
colApplicant: t.colApplicant,
colForm: t.colForm,
colDate: t.colDate,
colStatus: t.colStatus,
colScore: t.colScore,
open: t.open,
archive: t.archive,
restore: t.restore,
selected: t.bulk.selected,
apply: t.bulk.apply,
applying: t.bulk.applying,
moveTo: t.board.moveTo,
bulkFailed: t.bulk.failed,
allForms: t.allForms,
}}
/>
</div>
);
}
23 changes: 21 additions & 2 deletions apps/web/src/app/dashboard/[guildId]/submissions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { prisma } from "@msk-forms/db";
import { Card } from "@msk-forms/ui";
import type { Route } from "next";
import Link from "next/link";

import { DashboardLive } from "@/components/dashboard/dashboard-live";
import { SubmissionsTable } from "@/components/dashboard/submissions-table";
import { requireUser } from "@/lib/auth";
import { resolveStatus, statusOptions } from "@/lib/forms";
import { getGuildSubmissions, getReviewScope } from "@/lib/guild";
import { countArchivedSubmissions, getGuildSubmissions, getReviewScope } from "@/lib/guild";
import { signGuildRealtimeToken } from "@/lib/realtime-token";
import { getDict } from "@/i18n";

Expand Down Expand Up @@ -36,19 +38,33 @@ export default async function GuildSubmissionsPage({
const liveToken = signGuildRealtimeToken(guildId);
const live = liveToken ? <DashboardLive guildId={guildId} token={liveToken} /> : null;

const [submissions, defs] = await Promise.all([
const [submissions, defs, archivedCount] = await Promise.all([
getGuildSubmissions(guildId, scope),
prisma.formStatusDef.findMany({
where: { guildId },
orderBy: { order: "asc" },
select: { key: true, label: true, color: true, order: true },
}),
countArchivedSubmissions(guildId, scope),
]);

const archivedLink =
archivedCount > 0 ? (
<div className="flex justify-end">
<Link
href={`/dashboard/${guildId}/submissions/archived` as Route}
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{t.viewArchived} ({archivedCount})
</Link>
</div>
) : null;

if (submissions.length === 0) {
return (
<>
{live}
{archivedLink}
<Card className="p-8">
<p className="text-muted-foreground">{t.noSubmissions}</p>
</Card>
Expand Down Expand Up @@ -77,6 +93,7 @@ export default async function GuildSubmissionsPage({
return (
<>
{live}
{archivedLink}
<SubmissionsTable
guildId={guildId}
rows={rows}
Expand All @@ -89,6 +106,8 @@ export default async function GuildSubmissionsPage({
colStatus: t.colStatus,
colScore: t.colScore,
open: t.open,
archive: t.archive,
restore: t.restore,
selected: t.bulk.selected,
apply: t.bulk.apply,
applying: t.bulk.applying,
Expand Down
55 changes: 48 additions & 7 deletions apps/web/src/components/dashboard/submissions-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface SubmissionsTableLabels {
colStatus: string;
colScore: string;
open: string;
archive: string;
restore: string;
selected: string;
apply: string;
applying: string;
Expand All @@ -43,12 +45,15 @@ export function SubmissionsTable({
rows,
options,
canReview,
archived = false,
labels,
}: {
guildId: string;
rows: SubmissionRow[];
options: StatusOption[];
canReview: boolean;
/** When true this is the archived view: the row action restores instead of archives. */
archived?: boolean;
labels: SubmissionsTableLabels;
}) {
const router = useRouter();
Expand All @@ -57,6 +62,7 @@ export function SubmissionsTable({
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formFilter, setFormFilter] = useState("all");
const [busyId, setBusyId] = useState<string | null>(null);

// Distinct forms present in the current submissions, for the filter dropdown.
const forms = [...new Map(rows.map((r) => [r.formId, r.formTitle])).entries()]
Expand Down Expand Up @@ -87,6 +93,29 @@ export function SubmissionsTable({
setSelected(allSelected ? new Set() : new Set(visibleRows.map((r) => r.id)));
}

async function setArchived(id: string, value: boolean) {
setError(null);
setBusyId(id);
try {
const res = await fetch(`/api/guilds/${guildId}/submissions/${id}/archive`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archived: value }),
});
if (!res.ok) throw new Error(labels.bulkFailed);
setSelected((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : labels.bulkFailed);
} finally {
setBusyId(null);
}
}

async function apply() {
setError(null);
setApplying(true);
Expand Down Expand Up @@ -216,13 +245,25 @@ export function SubmissionsTable({
<td className="px-4 py-3">
<StatusBadge label={status.label} color={status.color} />
</td>
<td className="px-4 py-3 text-end">
<Link
href={`/dashboard/${guildId}/submissions/${s.id}` as Route}
className="text-sm font-medium text-primary transition-colors hover:underline"
>
{labels.open}
</Link>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-4">
<Link
href={`/dashboard/${guildId}/submissions/${s.id}` as Route}
className="text-sm font-medium text-primary transition-colors hover:underline"
>
{labels.open}
</Link>
{canReview && (
<button
type="button"
onClick={() => setArchived(s.id, !archived)}
disabled={busyId === s.id}
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
{archived ? labels.restore : labels.archive}
</button>
)}
</div>
</td>
</tr>
);
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ const en = {
colApplicant: "Applicant", colForm: "Form", colDate: "Date", colStatus: "Status", colScore: "Score",
allForms: "All forms",
anonymous: "Anonymous", noSubmissions: "No submissions yet.", open: "Open",
archive: "Archive", restore: "Restore", viewArchived: "Archived",
archivedTitle: "Archived submissions", noArchived: "No archived submissions.", backToSubmissions: "Back to submissions",
mySubmissionsTitle: "My submissions", noMySubmissions: "You haven’t submitted any forms yet.",
newFormTitle: "New form", editFormTitle: "Edit form",
noPermCreate: "You don’t have permission to create forms.",
Expand Down Expand Up @@ -829,6 +831,8 @@ const de: Dictionary = {
colApplicant: "Bewerber", colForm: "Formular", colDate: "Datum", colStatus: "Status", colScore: "Punkte",
allForms: "Alle Formulare",
anonymous: "Anonym", noSubmissions: "Noch keine Einreichungen.", open: "Öffnen",
archive: "Archivieren", restore: "Wiederherstellen", viewArchived: "Archiviert",
archivedTitle: "Archivierte Einreichungen", noArchived: "Keine archivierten Einreichungen.", backToSubmissions: "Zurück zu den Einreichungen",
mySubmissionsTitle: "Meine Einreichungen", noMySubmissions: "Du hast noch keine Formulare abgeschickt.",
newFormTitle: "Neues Formular", editFormTitle: "Formular bearbeiten",
noPermCreate: "Du hast keine Berechtigung, Formulare zu erstellen.",
Expand Down Expand Up @@ -1373,6 +1377,8 @@ const hu: Dictionary = {
colApplicant: "Jelentkező", colForm: "Űrlap", colDate: "Dátum", colStatus: "Állapot", colScore: "Pont",
allForms: "Minden űrlap",
anonymous: "Névtelen", noSubmissions: "Még nincsenek beküldések.", open: "Megnyitás",
archive: "Archiválás", restore: "Visszaállítás", viewArchived: "Archivált",
archivedTitle: "Archivált beküldések", noArchived: "Nincsenek archivált beküldések.", backToSubmissions: "Vissza a beküldésekhez",
mySubmissionsTitle: "Beküldéseim", noMySubmissions: "Még nem küldtél be egyetlen űrlapot sem.",
newFormTitle: "Új űrlap", editFormTitle: "Űrlap szerkesztése",
noPermCreate: "Nincs jogosultságod űrlapok létrehozásához.",
Expand Down Expand Up @@ -1917,6 +1923,8 @@ const fr: Dictionary = {
colApplicant: "Candidat", colForm: "Formulaire", colDate: "Date", colStatus: "Statut", colScore: "Score",
allForms: "Tous les formulaires",
anonymous: "Anonyme", noSubmissions: "Aucune soumission pour l'instant.", open: "Ouvrir",
archive: "Archiver", restore: "Restaurer", viewArchived: "Archivées",
archivedTitle: "Candidatures archivées", noArchived: "Aucune candidature archivée.", backToSubmissions: "Retour aux candidatures",
mySubmissionsTitle: "Mes soumissions", noMySubmissions: "Vous n'avez encore soumis aucun formulaire.",
newFormTitle: "Nouveau formulaire", editFormTitle: "Modifier le formulaire",
noPermCreate: "Vous n'avez pas la permission de créer des formulaires.",
Expand Down Expand Up @@ -2462,6 +2470,8 @@ const es: Dictionary = {
colApplicant: "Solicitante", colForm: "Formulario", colDate: "Fecha", colStatus: "Estado", colScore: "Puntuación",
allForms: "Todos los formularios",
anonymous: "Anónimo", noSubmissions: "Aún no hay envíos.", open: "Abrir",
archive: "Archivar", restore: "Restaurar", viewArchived: "Archivadas",
archivedTitle: "Envíos archivados", noArchived: "No hay envíos archivados.", backToSubmissions: "Volver a los envíos",
mySubmissionsTitle: "Mis envíos", noMySubmissions: "Aún no has enviado ningún formulario.",
newFormTitle: "Nuevo formulario", editFormTitle: "Editar formulario",
noPermCreate: "No tienes permiso para crear formularios.",
Expand Down Expand Up @@ -3007,6 +3017,8 @@ const pt: Dictionary = {
colApplicant: "Candidato", colForm: "Formulário", colDate: "Data", colStatus: "Status", colScore: "Pontuação",
allForms: "Todos os formulários",
anonymous: "Anônimo", noSubmissions: "Nenhuma candidatura ainda.", open: "Abrir",
archive: "Arquivar", restore: "Restaurar", viewArchived: "Arquivadas",
archivedTitle: "Candidaturas arquivadas", noArchived: "Nenhuma candidatura arquivada.", backToSubmissions: "Voltar às candidaturas",
mySubmissionsTitle: "Minhas candidaturas", noMySubmissions: "Você ainda não enviou nenhum formulário.",
newFormTitle: "Novo formulário", editFormTitle: "Editar formulário",
noPermCreate: "Você não tem permissão para criar formulários.",
Expand Down Expand Up @@ -3552,6 +3564,8 @@ const pl: Dictionary = {
colApplicant: "Kandydat", colForm: "Formularz", colDate: "Data", colStatus: "Status", colScore: "Wynik",
allForms: "Wszystkie formularze",
anonymous: "Anonimowy", noSubmissions: "Brak zgłoszeń.", open: "Otwórz",
archive: "Archiwizuj", restore: "Przywróć", viewArchived: "Zarchiwizowane",
archivedTitle: "Zarchiwizowane zgłoszenia", noArchived: "Brak zarchiwizowanych zgłoszeń.", backToSubmissions: "Powrót do zgłoszeń",
mySubmissionsTitle: "Moje zgłoszenia", noMySubmissions: "Nie wysłałeś jeszcze żadnych formularzy.",
newFormTitle: "Nowy formularz", editFormTitle: "Edytuj formularz",
noPermCreate: "Nie masz uprawnień do tworzenia formularzy.",
Expand Down
Loading