diff --git a/app/admin/ScreenPreview.tsx b/app/admin/ScreenPreview.tsx
new file mode 100644
index 0000000..e281a87
--- /dev/null
+++ b/app/admin/ScreenPreview.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { useState } from 'react';
+
+/**
+ * The panel, at a readable size.
+ *
+ * Rendered by the same code path that serves the device, so this is not an
+ * impression of the screen: it is the screen. `key` is bumped to defeat the
+ * browser cache when the data behind it has just changed.
+ */
+export default function ScreenPreview({ className = '' }: { className?: string }) {
+ const [nonce, setNonce] = useState(() => Date.now());
+
+ return (
+
+
+ );
+}
diff --git a/app/admin/messages/actions.ts b/app/admin/messages/actions.ts
new file mode 100644
index 0000000..c637f7e
--- /dev/null
+++ b/app/admin/messages/actions.ts
@@ -0,0 +1,193 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+
+import { diffOf, recordAudit } from '@/lib/audit';
+import { requireAdmin } from '@/lib/auth/actions';
+import { prisma } from '@/lib/db';
+import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract';
+import { translateToEnglish } from '@/lib/translation/service';
+
+export type ActionResult =
+ | { ok: true; value: T }
+ | { ok: false; error: string };
+
+export type MessageInput = {
+ id?: string;
+ textFr: string;
+ startsAt: string | null;
+ endsAt: string | null;
+ priority: number;
+ isActive: boolean;
+};
+
+export async function saveMessage(input: MessageInput): Promise> {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const textFr = input.textFr.trim();
+ if (textFr.length === 0) {
+ return { ok: false, error: 'Le message ne peut pas être vide.' };
+ }
+ if (textFr.length > MESSAGE_MAX_CHARS) {
+ return {
+ ok: false,
+ error: `Le message dépasse ${MESSAGE_MAX_CHARS} caractères et ne tiendrait pas sur l’écran.`,
+ };
+ }
+ if (input.startsAt && input.endsAt && input.startsAt > input.endsAt) {
+ return { ok: false, error: 'La fin de validité précède le début.' };
+ }
+
+ const existing = input.id
+ ? await prisma.message.findUnique({ where: { id: input.id } })
+ : null;
+
+ // The French text changed, so whatever English sat next to it is stale —
+ // including a translation someone had corrected by hand.
+ const textChanged = existing ? existing.textFr !== textFr : true;
+
+ const data = {
+ textFr,
+ startsAt: input.startsAt ? new Date(input.startsAt) : null,
+ endsAt: input.endsAt ? new Date(input.endsAt) : null,
+ priority: input.priority,
+ isActive: input.isActive,
+ ...(textChanged ? { textEn: null, translationStatus: 'PENDING' as const } : {}),
+ };
+
+ const saved = existing
+ ? await prisma.message.update({ where: { id: existing.id }, data })
+ : await prisma.message.create({ data: { ...data, createdBy: gate.value.email } });
+
+ await recordAudit({
+ userEmail: gate.value.email,
+ action: existing ? 'message.update' : 'message.create',
+ entity: 'Message',
+ entityId: saved.id,
+ diff: diffOf(
+ existing
+ ? {
+ texte: existing.textFr,
+ actif: existing.isActive,
+ priorité: existing.priority,
+ début: existing.startsAt,
+ fin: existing.endsAt,
+ }
+ : null,
+ {
+ texte: textFr,
+ actif: input.isActive,
+ priorité: input.priority,
+ début: data.startsAt,
+ fin: data.endsAt,
+ },
+ ),
+ });
+
+ revalidateAll();
+ return { ok: true, value: { id: saved.id } };
+}
+
+/**
+ * Translates a saved message.
+ *
+ * Called separately from the save so a slow or broken translation service can
+ * never cost the shop its message. Failure is recorded on the row and shown in
+ * the UI with a retry, rather than raised.
+ */
+export async function translateMessage(id: string): Promise> {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const message = await prisma.message.findUnique({ where: { id } });
+ if (!message) {
+ return { ok: false, error: 'Ce message a été supprimé.' };
+ }
+ if (message.translationStatus === 'MANUAL') {
+ return { ok: false, error: 'La traduction a été saisie à la main; elle n’est pas écrasée.' };
+ }
+
+ const outcome = await translateToEnglish(message.textFr);
+
+ if (!outcome.ok) {
+ await prisma.message.update({ where: { id }, data: { translationStatus: 'ERROR' } });
+ revalidateAll();
+ return { ok: false, error: outcome.error };
+ }
+
+ await prisma.message.update({
+ where: { id },
+ data: { textEn: outcome.text, translationStatus: 'DONE' },
+ });
+
+ revalidateAll();
+ return { ok: true, value: { textEn: outcome.text } };
+}
+
+/** A hand-written translation is never overwritten while the French stands. */
+export async function setManualTranslation(id: string, textEn: string): Promise {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const trimmed = textEn.trim();
+ const existing = await prisma.message.findUnique({ where: { id } });
+ if (!existing) {
+ return { ok: false, error: 'Ce message a été supprimé.' };
+ }
+
+ await prisma.message.update({
+ where: { id },
+ data: {
+ textEn: trimmed || null,
+ translationStatus: trimmed ? 'MANUAL' : 'PENDING',
+ },
+ });
+
+ await recordAudit({
+ userEmail: gate.value.email,
+ action: 'message.translate_manual',
+ entity: 'Message',
+ entityId: id,
+ diff: diffOf({ anglais: existing.textEn }, { anglais: trimmed || null }),
+ });
+
+ revalidateAll();
+ return { ok: true, value: undefined };
+}
+
+export async function deleteMessage(id: string): Promise {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const existing = await prisma.message.findUnique({ where: { id } });
+ if (!existing) {
+ return { ok: false, error: 'Ce message a déjà été supprimé.' };
+ }
+
+ await prisma.message.delete({ where: { id } });
+
+ await recordAudit({
+ userEmail: gate.value.email,
+ action: 'message.delete',
+ entity: 'Message',
+ entityId: id,
+ diff: diffOf({ texte: existing.textFr }, null),
+ });
+
+ revalidateAll();
+ return { ok: true, value: undefined };
+}
+
+function revalidateAll(): void {
+ revalidatePath('/admin');
+ revalidatePath('/admin/messages');
+}
diff --git a/app/admin/messages/page.tsx b/app/admin/messages/page.tsx
new file mode 100644
index 0000000..ca60ede
--- /dev/null
+++ b/app/admin/messages/page.tsx
@@ -0,0 +1,54 @@
+import { auth } from '@/lib/auth';
+import { prisma } from '@/lib/db';
+import { selectActiveMessage } from '@/lib/screen/viewmodel';
+
+import MessageManager, { type MessageRow } from './MessageManager';
+
+export const metadata = { title: 'Messages — ITA ITO' };
+
+export default async function MessagesPage() {
+ const [session, rows] = await Promise.all([
+ auth(),
+ prisma.message.findMany({ orderBy: [{ isActive: 'desc' }, { priority: 'desc' }, { createdAt: 'desc' }] }),
+ ]);
+
+ const now = new Date();
+
+ // Which message the screen is actually showing, decided by the same function
+ // the renderer uses — so the badge cannot drift from the panel.
+ const live = selectActiveMessage(
+ rows.map((row) => ({
+ textFr: row.textFr,
+ textEn: row.textEn,
+ startsAt: row.startsAt,
+ endsAt: row.endsAt,
+ priority: row.priority,
+ isActive: row.isActive,
+ })),
+ now,
+ );
+
+ const messages: MessageRow[] = rows.map((row) => ({
+ id: row.id,
+ textFr: row.textFr,
+ textEn: row.textEn,
+ translationStatus: row.translationStatus,
+ startsAt: row.startsAt ? row.startsAt.toISOString().slice(0, 16).replace('T', ' ') : null,
+ endsAt: row.endsAt ? row.endsAt.toISOString().slice(0, 16).replace('T', ' ') : null,
+ priority: row.priority,
+ isActive: row.isActive,
+ isLive: live?.textFr === row.textFr && row.isActive,
+ }));
+
+ return (
+
+
Messages
+
+ Le bandeau bas de l’écran. Saisi en français, traduit automatiquement en anglais. Un message
+ libre prend la place du bandeau automatique « horaires exceptionnels ».
+
+
+
+
+ );
+}
diff --git a/app/api/admin/preview/route.ts b/app/api/admin/preview/route.ts
new file mode 100644
index 0000000..ad5fb7d
--- /dev/null
+++ b/app/api/admin/preview/route.ts
@@ -0,0 +1,29 @@
+import { publicBaseUrl } from '@/lib/config';
+import { encodeScreen } from '@/lib/screen/encode';
+import { renderScreenSvg } from '@/lib/screen/render';
+import { buildCurrentScreen } from '@/lib/screen/service';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * The panel as it stands right now, as a PNG for the browser.
+ *
+ * Deliberately the same pipeline the device is served from — payload, SVG,
+ * threshold — so what the admin sees is what the shop window shows, down to
+ * the last thresholded pixel. A preview drawn any other way would eventually
+ * disagree with reality, and quietly.
+ *
+ * Behind /api/admin, so the middleware requires a session.
+ */
+export async function GET(request: Request) {
+ const { payload } = await buildCurrentScreen(new Date(), publicBaseUrl(request));
+ const png = encodeScreen(await renderScreenSvg(payload), 'png');
+
+ return new Response(new Uint8Array(png), {
+ headers: {
+ 'Content-Type': 'image/png',
+ // It reflects live data; a cached preview is a misleading preview.
+ 'Cache-Control': 'no-store',
+ },
+ });
+}
diff --git a/tests/integration/messages-action.test.ts b/tests/integration/messages-action.test.ts
new file mode 100644
index 0000000..99f4fda
--- /dev/null
+++ b/tests/integration/messages-action.test.ts
@@ -0,0 +1,174 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { prisma } from '@/lib/db';
+import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract';
+
+import { hasDatabase, resetDatabase } from './helpers';
+
+const caller = { email: 'test@ita-ito.test', role: 'admin' as const };
+const gate = vi.hoisted(() => ({
+ result: { ok: true, value: { email: 'test@ita-ito.test', role: 'admin' as const } },
+}));
+const translation = vi.hoisted(() => ({
+ outcome: { ok: true, text: 'Closed today', model: 'loxi:haiku' } as
+ | { ok: true; text: string; model: string }
+ | { ok: false; error: string },
+ calls: 0,
+}));
+
+vi.mock('@/lib/auth/actions', () => ({ requireAdmin: async () => gate.result }));
+vi.mock('next/cache', () => ({ revalidatePath: () => undefined }));
+vi.mock('@/lib/translation/service', () => ({
+ translateToEnglish: async () => {
+ translation.calls += 1;
+ return translation.outcome;
+ },
+}));
+
+const { saveMessage, setManualTranslation, translateMessage, deleteMessage } = await import(
+ '@/app/admin/messages/actions'
+);
+
+const base = {
+ textFr: 'Fermé aujourd’hui',
+ startsAt: null,
+ endsAt: null,
+ priority: 0,
+ isActive: true,
+};
+
+describe.skipIf(!hasDatabase)('message actions', () => {
+ beforeEach(async () => {
+ gate.result = { ok: true, value: caller };
+ translation.outcome = { ok: true, text: 'Closed today', model: 'loxi:haiku' };
+ translation.calls = 0;
+ await resetDatabase();
+ await prisma.auditLog.deleteMany();
+ });
+
+ async function create(overrides = {}) {
+ const result = await saveMessage({ ...base, ...overrides });
+ if (!result.ok) {
+ throw new Error(result.error);
+ }
+ return result.value.id;
+ }
+
+ it('saves a message awaiting translation', async () => {
+ const id = await create();
+ const row = await prisma.message.findUniqueOrThrow({ where: { id } });
+
+ // The save does not translate: a slow service must never cost the shop
+ // its message.
+ expect(row.translationStatus).toBe('PENDING');
+ expect(row.textEn).toBeNull();
+ expect(translation.calls).toBe(0);
+ });
+
+ it('translates on demand and records the result', async () => {
+ const id = await create();
+ expect(await translateMessage(id)).toMatchObject({ ok: true });
+
+ const row = await prisma.message.findUniqueOrThrow({ where: { id } });
+ expect(row.textEn).toBe('Closed today');
+ expect(row.translationStatus).toBe('DONE');
+ });
+
+ it('marks a failed translation without losing the message', async () => {
+ const id = await create();
+ translation.outcome = { ok: false, error: 'quota dépassé' };
+
+ const result = await translateMessage(id);
+ expect(result.ok).toBe(false);
+
+ const row = await prisma.message.findUniqueOrThrow({ where: { id } });
+ expect(row.translationStatus).toBe('ERROR');
+ expect(row.textFr).toBe(base.textFr);
+ });
+
+ it('keeps a hand-written translation and refuses to overwrite it', async () => {
+ const id = await create();
+ await setManualTranslation(id, ' Closed for the day ');
+
+ const row = await prisma.message.findUniqueOrThrow({ where: { id } });
+ expect(row.textEn).toBe('Closed for the day');
+ expect(row.translationStatus).toBe('MANUAL');
+
+ const retry = await translateMessage(id);
+ expect(retry.ok).toBe(false);
+ expect(translation.calls).toBe(0);
+ expect((await prisma.message.findUniqueOrThrow({ where: { id } })).textEn).toBe(
+ 'Closed for the day',
+ );
+ });
+
+ it('clears a hand-written translation once the French changes', async () => {
+ const id = await create();
+ await setManualTranslation(id, 'Closed for the day');
+
+ await saveMessage({ ...base, id, textFr: 'Ouverture retardée' });
+
+ const row = await prisma.message.findUniqueOrThrow({ where: { id } });
+ // A translation of text that has changed is worse than no translation.
+ expect(row.textEn).toBeNull();
+ expect(row.translationStatus).toBe('PENDING');
+ });
+
+ it('keeps the translation when only the schedule changes', async () => {
+ const id = await create();
+ await translateMessage(id);
+
+ await saveMessage({ ...base, id, priority: 5, isActive: false });
+
+ const row = await prisma.message.findUniqueOrThrow({ where: { id } });
+ expect(row.textEn).toBe('Closed today');
+ expect(row.translationStatus).toBe('DONE');
+ expect(row.priority).toBe(5);
+ expect(row.isActive).toBe(false);
+ });
+
+ it('empties a translation back to pending', async () => {
+ const id = await create();
+ await setManualTranslation(id, 'Closed for the day');
+ await setManualTranslation(id, ' ');
+
+ const row = await prisma.message.findUniqueOrThrow({ where: { id } });
+ expect(row.textEn).toBeNull();
+ expect(row.translationStatus).toBe('PENDING');
+ });
+
+ it('refuses an empty message and one that would not fit', async () => {
+ expect((await saveMessage({ ...base, textFr: ' ' })).ok).toBe(false);
+ expect((await saveMessage({ ...base, textFr: 'a'.repeat(MESSAGE_MAX_CHARS + 1) })).ok).toBe(
+ false,
+ );
+ expect(await prisma.message.count()).toBe(0);
+ });
+
+ it('refuses a validity window that ends before it starts', async () => {
+ const result = await saveMessage({
+ ...base,
+ startsAt: '2026-10-01T10:00',
+ endsAt: '2026-09-01T10:00',
+ });
+ expect(result.ok).toBe(false);
+ });
+
+ it('deletes a message and logs what it said', async () => {
+ const id = await create();
+ await prisma.auditLog.deleteMany();
+
+ expect(await deleteMessage(id)).toMatchObject({ ok: true });
+ expect(await prisma.message.count()).toBe(0);
+
+ const entry = await prisma.auditLog.findFirst();
+ expect(entry?.action).toBe('message.delete');
+ expect(entry?.diff).toMatchObject({ texte: { before: base.textFr, after: null } });
+ });
+
+ it('refuses a read-only account', async () => {
+ gate.result = { ok: false, error: 'Ce compte est en lecture seule.' } as never;
+ expect((await saveMessage(base)).ok).toBe(false);
+ expect(await prisma.message.count()).toBe(0);
+ });
+});