diff --git a/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/archive/route.ts b/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/archive/route.ts
new file mode 100644
index 0000000..26391d7
--- /dev/null
+++ b/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/archive/route.ts
@@ -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 });
+}
diff --git a/apps/web/src/app/dashboard/[guildId]/submissions/archived/page.tsx b/apps/web/src/app/dashboard/[guildId]/submissions/archived/page.tsx
new file mode 100644
index 0000000..e44f8f8
--- /dev/null
+++ b/apps/web/src/app/dashboard/[guildId]/submissions/archived/page.tsx
@@ -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 (
+
+ {t.board.noPerm}
+
+ );
+ }
+
+ 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 = (
+
+
+ {t.backToSubmissions}
+
+ );
+
+ const header = (
+
+ {backLink}
+
{t.archivedTitle}
+
+ );
+
+ if (submissions.length === 0) {
+ return (
+
+ {header}
+
+ {t.noArchived}
+
+
+ );
+ }
+
+ 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 (
+
+ {header}
+
+
+ );
+}
diff --git a/apps/web/src/app/dashboard/[guildId]/submissions/page.tsx b/apps/web/src/app/dashboard/[guildId]/submissions/page.tsx
index 51da8d2..260bffb 100644
--- a/apps/web/src/app/dashboard/[guildId]/submissions/page.tsx
+++ b/apps/web/src/app/dashboard/[guildId]/submissions/page.tsx
@@ -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";
@@ -36,19 +38,33 @@ export default async function GuildSubmissionsPage({
const liveToken = signGuildRealtimeToken(guildId);
const live = 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 ? (
+
+
+ {t.viewArchived} ({archivedCount})
+
+
+ ) : null;
+
if (submissions.length === 0) {
return (
<>
{live}
+ {archivedLink}
{t.noSubmissions}
@@ -77,6 +93,7 @@ export default async function GuildSubmissionsPage({
return (
<>
{live}
+ {archivedLink}
(null);
const [formFilter, setFormFilter] = useState("all");
+ const [busyId, setBusyId] = useState(null);
// Distinct forms present in the current submissions, for the filter dropdown.
const forms = [...new Map(rows.map((r) => [r.formId, r.formTitle])).entries()]
@@ -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);
@@ -216,13 +245,25 @@ export function SubmissionsTable({
|
-
-
- {labels.open}
-
+ |
+
+
+ {labels.open}
+
+ {canReview && (
+
+ )}
+
|
);
diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts
index 4e69d05..755694e 100644
--- a/apps/web/src/i18n/dictionaries.ts
+++ b/apps/web/src/i18n/dictionaries.ts
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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.",
diff --git a/apps/web/src/lib/guild.ts b/apps/web/src/lib/guild.ts
index 011e236..2c210cc 100644
--- a/apps/web/src/lib/guild.ts
+++ b/apps/web/src/lib/guild.ts
@@ -155,10 +155,18 @@ export async function getGuildForms(guildId: string) {
* Pass a {@link ReviewScope}: `all` returns everything; otherwise only the
* granted forms' submissions (empty grants → no rows).
*/
-export async function getGuildSubmissions(guildId: string, scope: ReviewScope) {
+export async function getGuildSubmissions(
+ guildId: string,
+ scope: ReviewScope,
+ opts: { archived?: boolean } = {},
+) {
if (!scope.all && scope.formIds.length === 0) return [];
return prisma.submission.findMany({
- where: { guildId, ...(scope.all ? {} : { formId: { in: scope.formIds } }) },
+ where: {
+ guildId,
+ ...(scope.all ? {} : { formId: { in: scope.formIds } }),
+ archivedAt: opts.archived ? { not: null } : null,
+ },
orderBy: { submittedAt: "desc" },
take: 100,
select: {
@@ -173,6 +181,21 @@ export async function getGuildSubmissions(guildId: string, scope: ReviewScope) {
});
}
+/** Count a guild's archived submissions within the reviewer's scope. */
+export async function countArchivedSubmissions(
+ guildId: string,
+ scope: ReviewScope,
+): Promise {
+ if (!scope.all && scope.formIds.length === 0) return 0;
+ return prisma.submission.count({
+ where: {
+ guildId,
+ ...(scope.all ? {} : { formId: { in: scope.formIds } }),
+ archivedAt: { not: null },
+ },
+ });
+}
+
/** Submissions the user themselves made, across all guilds, for "My Submissions". */
export async function getUserSubmissions(userId: string) {
return prisma.submission.findMany({
diff --git a/packages/db/prisma/migrations/20260703120000_submission_archive/migration.sql b/packages/db/prisma/migrations/20260703120000_submission_archive/migration.sql
new file mode 100644
index 0000000..3791738
--- /dev/null
+++ b/packages/db/prisma/migrations/20260703120000_submission_archive/migration.sql
@@ -0,0 +1,4 @@
+-- Archive a submission: soft-hide it from the active list without deleting it.
+ALTER TABLE "submissions" ADD COLUMN "archived_at" TIMESTAMP(3);
+
+CREATE INDEX "submissions_guild_id_archived_at_idx" ON "submissions" ("guild_id", "archived_at");
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 6c7487d..3871f92 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -263,8 +263,9 @@ model Submission {
status String @default("submitted")
score Int?
meta Json @default("{}")
- submittedAt DateTime @default(now()) @map("submitted_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ submittedAt DateTime @default(now()) @map("submitted_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ archivedAt DateTime? @map("archived_at")
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@ -275,6 +276,7 @@ model Submission {
@@index([formId, status])
@@index([userId])
@@index([guildId, submittedAt])
+ @@index([guildId, archivedAt])
@@map("submissions")
}