Ticket Detail
service-desk/ui/ticket-detaillive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-7, tailwind-3.4, alchemy-ui |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton |
| Renders | service-desk/ticket-lifecycle, service-desk/queue-routing-assignment, service-desk/telegram-agent-channel, service-desk/email-intake-channel, service-desk/notification-delivery |
| Entry | alchemy_labs/frontend/src/pages/desk/DeskTicket.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
The client's own window onto one ticket: a tenant (or their shared practice account) opens /desk/:id and reads the status in one plain German sentence, the full message thread with a reply composer, the task type's required fields and any file it still needs, every attachment, and, only for the person who raised the ticket, or a contact designated to act for them, the Abnahme (accept), Nachbesserung (request rework) and cancel buttons the ticket's current status allows. The invariant that makes it trustworthy: every button rendered is a transition the backend would actually accept right now (allowedTransitions mirrors the server's own status machine, SPEC §4.3), so a client is never shown a control that answers with a refusal, and a gated one (an incomplete ticket blocking in_progress) carries the reason next to it rather than failing silently.
It makes visible five backend recipes: the status machine and required-field gating itself (ticket-lifecycle), the queue an unassigned ticket sits in before an agent picks it up (queue-routing-assignment, not directly rendered here but the reason assignee can read "noch offen"), the two channels that can also write to the same thread this screen reads (telegram-agent-channel, email-intake-channel, a message's channel field and email_sent_at are this screen's only trace of either), and the notification that told the client a reply or a status change was waiting for them (notification-delivery). None of those five write anywhere a client can see except through this screen's next read of the ticket.
When to use
Trigger phrases: "the ticket page", "my ticket", "Abnahme", "Nachbesserung", "reply to the ticket", "the client portal detail view", "why can't I close this ticket". Surface: SPA, a private Labs page behind PrivateRoute, reached at /desk/:id; mounted with DeskSurfaceProvider value="portal" (surface.tsx), which is a statement about the SCREEN, not the viewer, the comment at line 49 is explicit that this reads German "role or not (R1)": an Alchemy agent opening a client's ticket through this same route still reads German, because the surface decides the language and the role decides only which buttons exist (deskFormat.ts Surface type doc comment).
Not this recipe: the ticket list this page is linked back from (/desk, a different route, not covered), the agency-side queue and its own ticket view (mounted with surface="agency" instead, English copy, a DeskAgent dependency, a different recipe), the five backend recipes this page is a window onto (backend_modules above, none of which a client reaches directly), and TicketForm.tsx's FieldInput-driven inline field editor, which this screen imports the type of but never mounts (canEdit={false} always, Frontend).
Stack assumptions
S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react ^19.1.1, react-router-dom ^7.8.2, tailwindcss ^3.4.17. Component library: none directly, the same as bookkeeping/ui/bookkeeping-financial-dashboard, DeskTicket.tsx and its six child files (MessageThread.tsx, MessageComposer.tsx, StatusActions.tsx, TicketFieldsCard.tsx, desk/ui.tsx, ActingEmailBadge.tsx) import no @prima-modules/alchemy-ui atom; every button, input, card and chip is a hand-styled div/button/textarea carrying the source's own component classes (card, chip, label, field, field-sm, btn-primary, btn-ghost, btn-quiet). rendering.design_system is still alchemy-ui here, for the same reason the bookkeeping dashboard's recipe gives: this page renders inside Navigation = AppShell, which is alchemy-ui-based, and the ink/ink-40/ink-60/purple/warn/danger/success classes its own markup uses are the same @prima-modules/tokens layer alchemy-ui bundles (both generated from frontend/tailwind.config.js, decision D1a). Data-fetch style: services/serviceDeskService.ts's own fetch-based client (not the api axios singleton S2 names, a second, desk-specific client, Authorization: Bearer from getAccessToken() plus an X-Workspace-ID header sent as a per-workspace default, though every route this screen calls is workspace-derived from the ticket's own row, not from that header, Security and tenancy). Copy language: German, de-CH, unconditionally, this recipe's surface is always 'portal' (Stack assumptions above), so unlike its sibling identity-auth/ui/admin-users-console and billing-credits/ui/subscription-manager, there is no English branch anywhere on this screen to accidentally show a client.
Data model
Client state (DeskTicket.tsx lines 20 to 22), all in the page component:
ticket: TicketDetail | null // null until the first successful read
config: DeskConfig | null // GET /api/desk/config, fetched once and reused across loads (line 27)
error: string | null // errorText(e, 'portal') on any throw from load()
role, type and unfilledFileFields (lines 41 to 45) are derived on every render, not stored: role = config?.my_role ?? 'requester'; type = config?.task_types.find(t => t.slug === ticket?.type_slug); unfilledFileFields is type.fields filtered to file fields with no matching field_key in ticket.attachments, which is what makes the composer's "fehlt: …" dropdown and TicketFieldsCard's own upload row appear or not. TicketDetail (serviceDeskService.ts line 550) and DeskConfig (line 367) are each much larger than what this screen reads, number, blocked_by_us, capacity_month, events, routing_events, time_entries, timings, links, tags, pipeline_key on the ticket; business_hours, categories, capacity_hours_per_month, requester_contacts, config_version on the config, none of it rendered by DeskTicket.tsx, so the demo's own trimmed interfaces (demo.tsx header comment) carry only the subset this page actually touches, not a redefinition of the real shape.
The demo's props (demo.tsx): { workspace, account: { userId, email, name }, status: 'loading' | 'ok' | 'error', ticket: TicketDetail | null, config: DeskConfig | null, error?: string | null }. account stands in for useAuth().user, which the source reads three separate times (ActingEmailBadge's accountEmail/accountName, MessageThread's accountEmail, StatusActions's userId), one prop here, three call sites, same as the source's one user object.
Browser storage: nothing of its own beyond S2's accessToken/refreshToken; serviceDeskService.ts's getWorkspaceId() (line 18 to 25) also reads currentWorkspaceId / workspaceId / X-Workspace-ID from localStorage as a fallback header, unused by any route this specific screen calls (Security and tenancy).
API surface
Two reads on mount, four writes across the child components, every one of them workspace-free (ActiveMember/DeskMember-shaped auth, the ticket id itself scopes the row):
| Call | Route | Parsed | Rendered as |
|---|---|---|---|
getTicket(id) | GET /api/desk/tickets/{id} | the whole TicketDetail body | everything below the header |
getDeskConfig() | GET /api/desk/config | DeskConfig, fetched once (config ? Promise.resolve(config) : getDeskConfig(), line 27) and reused on every later refetch | role, type, the review-window sentence, the class/task-type labels |
acceptTicket(id) / changeStatus(id, to, {reason|message}) | POST /api/desk/tickets/{id}/accept | POST /api/desk/tickets/{id}/status | the updated TicketDetail on success | onChanged(next) replaces ticket in place, no refetch (StatusActions.tsx line 55 to 60) |
postMessage(id, {body, is_internal, is_decision, attachments}) | POST /api/desk/tickets/{id}/messages | nothing read directly; onSent re-runs load() | the new bubble appears on the next getTicket (MessageComposer.tsx line 44 to 51) |
uploadToTicket(id, file, {field_key, kind}) | POST /api/desk/tickets/{id}/attachments | the new Attachment; when it carries a field_key, onSent() / onUploaded() re-runs load() (MessageComposer.tsx line 64 to 66, TicketFieldsCard.tsx's FileRow line 236) | the attachment chip, and, because the upload revalidates the ticket server-side, a field that was missing can flip fields_complete true on the next read |
downloadAttachment(id, att) | GET /api/desk/tickets/{id}/attachments/{att.id} (implied by AttachmentChip's click handler) | a file, not JSON | the browser's own download, no screen state changes |
Error bodies: every one of the eighteen-plus codes in ERROR_TEXT_DE (serviceDeskService.ts line 59 to 106) is a machine code the backend chooses and this screen's surface ('portal', always) resolves to the fixed German sentence, fields_incomplete specifically gets its own richer sentence in StatusActions.tsx (Es fehlen noch Pflichtangaben: …, appending the actual missing labels via missingFieldLabels), every other code falls through to errorText(e, 'portal')'s table lookup. No WebSocket, no postMessage.
Application logic
DeskTicketmounts withticket: null,config: null,error: null;useEffectfiresload()once peridchange (lines 24 to 39). While!ticket && !error, the page renders only the back-link and a bare<Spinner/>, the shell (Navigation/AppShell) is already painted (line 58).load()awaitsgetTicket(id)andgetDeskConfig()in parallel (skipping the config fetch on a retry once it is already in state), sets both on success and clearserror, or sets the fixed sentence on any throw (lines 26 to 33).role,typeandunfilledFileFieldsare recomputed fromconfig/ticketon every render (lines 41 to 45); none of the three is itself state.- The status banner's color and sentence come straight from
statusSentence(ticket.status)and a two-way class ternary (waiting_on_client/client_review→ warn colors, every other status → purple); the review-window sentence appears only whenstatus === 'client_review'ANDclient_review_started_atis set ANDconfighas loaded (three-way guard, line 82), nothing on the client side actually enforces the timeout, the sentence is honest about that: "the backend never auto-closes" per the inline comment at lines 84 to 86 (SPEC §9 Q8). StatusActionsis mounted wheneverrole !== 'viewer'(line 93), but itself renders nothing (options.length === 0returnsnull,StatusActions.tsxline 47) unlessallowedTransitions(status, role, {...})returns at least one option for this exact status/role pair, for arequester, that is onlynew/accepted(a lone "Auftrag zurückziehen" cancel) andclient_review("Abnehmen" plus "Nachbesserung anfordern"); every other status shows a client no action buttons at all, by design, not by omission.mayDecide = isOwnTicket || isDesignatedgates every requester-side transition (deskFormat.tsline 344): the person who raised the ticket, or a contact the workspace has designated (config.my_is_designated, resolved server-side fromZiffer 3.15's rule, never a client-supplied flag), readingticket.requester.is_designatedhere would ask the wrong question (about the ticket's original requester, not the current viewer), which is exactly the bug the comment atStatusActions.tsxlines 49 to 52 documents having shipped once.- The reply composer is mounted whenever
role !== 'viewer' && ticket.status !== 'cancelled'(line 119); its placeholder swaps to "Deine Antwort – damit geht es weiter." only onwaiting_on_client, every other status (includingdone) gets "Deine Nachricht ans Team", adoneticket still accepts a reply, it is just no longer expected to move the status, which is what the separateInfoNoteright below it exists to say (line 128). TicketFieldsCardalways renders withcanEdit={false}(line 162), a client never gets the inline field editorTicketForm.tsx'sFieldInputimplements for agents; the only way a client changes a field on this screen is by uploading the file afile-typed field is still missing, gated bycanUpload = role !== 'viewer' && status !== 'cancelled'(line 163), the identical guard the composer uses.- The aside's five-row summary card, the attachments card and the intake-check Q/A card are each independently conditional (
type &&,attachments.length > 0,intake_check && questions.length > 0), a ticket with no configured task type, no attachments yet and a skipped intake check renders the summary card alone.
Invariants
- Every action button shown is a transition the backend would accept:
allowedTransitionsis the client-side mirror of the same status machineticket-lifecycle's backend enforces (SPEC §4.3); afieldGate-disabled button is shown, not hidden, with the reason stated next to it, so an agent (or a designated client) discovers a rule instead of a 409 (StatusActions.tsxcomment, line 100 to 102 in the source header). - Who may decide is never read off the ticket's own
requester.is_designated: it is always the VIEWER's ownconfig.my_is_designated, resolved server-side (Application logic step 6), the one bug this file's own comments record having already happened. - The surface is the language, the role is the buttons: nothing on this screen ever reads
roleto decide which language a sentence is in, and nothing ever readssurfaceto decide which buttons exist (deskFormat.ts'sSurfacedoc comment, restated atDeskTicket.tsxline 49). - A
doneorcancelledticket is never silently mutable: the composer disappears entirely oncancelled(a stornoed ticket takes no further replies), and stays open ondonespecifically so a client can still be heard, with an explicit note that the status will not move again from a reply alone. - Nothing this screen renders is computed locally from a write: every action (
acceptTicket,changeStatus,postMessage,uploadToTicket) either replacesticketwith the server's own returned row or triggers a fullload()refetch, there is no local optimistic status flip anywhere in this file.
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
default server rendered, hydrated in view Leistungen-Seite: Text und Bilder aktualisieren
- kontakt@atelier-nordwind.example10. Sept. 2026, 11:05von lukas@atelier-nordwind.exampleKönnen wir den Leistungen-Text bis Ende Monat anpassen? Ich schicke gleich das Referenzdokument mit.
- Jonas Berger10. Sept. 2026, 12:12E-MailDanke, wir haben den Auftrag übernommen und melden uns mit einem Vorschlag.
waiting-on-client server rendered, hydrated in view Leistungen-Seite: Text und Bilder aktualisieren
- kontakt@atelier-nordwind.example10. Sept. 2026, 11:05von lukas@atelier-nordwind.exampleKönnen wir den Leistungen-Text bis Ende Monat anpassen? Ich schicke gleich das Referenzdokument mit.
- Jonas Berger10. Sept. 2026, 12:12E-MailDanke, wir haben den Auftrag übernommen und melden uns mit einem Vorschlag.
- Jonas Berger10. Sept. 2026, 13:30E-MailKannst du uns das Referenzdokument noch zuschicken? Ohne das können wir nicht starten.
client-review server rendered, hydrated in view Leistungen-Seite: Text und Bilder aktualisieren
- kontakt@atelier-nordwind.example10. Sept. 2026, 11:05von lukas@atelier-nordwind.exampleKönnen wir den Leistungen-Text bis Ende Monat anpassen? Ich schicke gleich das Referenzdokument mit.
- Jonas Berger10. Sept. 2026, 12:12E-MailDanke, wir haben den Auftrag übernommen und melden uns mit einem Vorschlag.
- Jonas Berger12. Sept. 2026, 10:00EntscheidE-MailDer neue Text ist live. Bitte kurz gegenprüfen, ob alles passt – dann kannst du den Auftrag abnehmen.
done server rendered, hydrated in view Leistungen-Seite: Text und Bilder aktualisieren
- kontakt@atelier-nordwind.example10. Sept. 2026, 11:05von lukas@atelier-nordwind.exampleKönnen wir den Leistungen-Text bis Ende Monat anpassen? Ich schicke gleich das Referenzdokument mit.
- Jonas Berger10. Sept. 2026, 12:12E-MailDanke, wir haben den Auftrag übernommen und melden uns mit einem Vorschlag.
- Jonas Berger12. Sept. 2026, 10:00EntscheidE-MailDer neue Text ist live. Bitte kurz gegenprüfen, ob alles passt – dann kannst du den Auftrag abnehmen.
- kontakt@atelier-nordwind.example12. Sept. 2026, 11:10Passt so, danke!
cancelled server rendered, hydrated in view Leistungen-Seite: Text und Bilder aktualisieren
- kontakt@atelier-nordwind.example10. Sept. 2026, 11:05von lukas@atelier-nordwind.exampleKönnen wir den Leistungen-Text bis Ende Monat anpassen? Ich schicke gleich das Referenzdokument mit.
- Jonas Berger10. Sept. 2026, 12:12E-MailStorniert auf deinen Wunsch – melde dich, falls der Auftrag doch noch aktuell wird.
viewer-role server rendered, hydrated in view Leistungen-Seite: Text und Bilder aktualisieren
- kontakt@atelier-nordwind.example10. Sept. 2026, 11:05von lukas@atelier-nordwind.exampleKönnen wir den Leistungen-Text bis Ende Monat anpassen? Ich schicke gleich das Referenzdokument mit.
- Jonas Berger10. Sept. 2026, 12:12E-MailDanke, wir haben den Auftrag übernommen und melden uns mit einem Vorschlag.
- Jonas Berger12. Sept. 2026, 10:00EntscheidE-MailDer neue Text ist live. Bitte kurz gegenprüfen, ob alles passt – dann kannst du den Auftrag abnehmen.
loading server rendered, hydrated in view error server rendered, hydrated in view service-desk/ui/ticket-detail/demo.tsx
/**
* Live demo for service-desk/ui/ticket-detail.
*
* Labs' `DeskTicket.tsx` (feat/service-desk, 62ba4fc, 210 lines) — the client
* portal's single-ticket screen — rebuilt as a presentational component. The
* real page reads `useParams`, `useAuth` and two network calls (`getTicket`,
* `getDeskConfig`) inside a `useCallback`/`useEffect` pair; this rebuild
* replaces all three with props (`status`, `ticket`, `config`, `error`,
* `account`, `workspace`) and renders inside the shell demo the way every
* private page is (`Navigation` is a thin delegator to `AppShell`, same
* convention as billing-credits/ui/subscription-manager and
* identity-auth/ui/admin-users-console).
*
* DeskTicket.tsx pulls in six further source files that are one recipe's
* worth of screen: `MessageThread.tsx` (+ its `AttachmentChip`),
* `MessageComposer.tsx`, `StatusActions.tsx`, `TicketFieldsCard.tsx`,
* `desk/ui.tsx` (`StatusChip`, `ClassChip`, `SlaBadge`, `Spinner`,
* `ErrorNote`, `InfoNote`, `Modal`) and `ActingEmailBadge.tsx`. A demo folder
* may only hold its own recipe's code (AUTHORING.md, "The folder"), so all
* seven are collapsed into this one file, each kept as its own function in
* the source's own order, with the source's own JSX and class names.
*
* What is kept byte-faithful: every class name (`card`, `chip`, `label`,
* `field`, `field-sm`, `btn-primary`, `btn-ghost`, `btn-quiet`, the
* `ink`/`ink-40`/`ink-60`/`purple`/`warn`/`danger`/`success` palette), the
* German copy (`statusSentence`, every button label, every fallback
* sentence), the status-machine mirror (`allowedTransitions`,
* `deskFormat.ts` lines 267 to 349) and the required-field mirror
* (`missingFieldLabels`, `isFieldMissing`), the component tree and its
* conditionals (banner colour, composer placeholder, the Abnahme /
* Nachbesserung / cancel buttons, the aside cards), and `fmtZurich` /
* `slaState`'s explicit `timeZone: 'Europe/Zurich'` (unlike the
* subscription-manager sibling's date formatter, this one already pins both
* locale and time zone, so it needs no fixed-table replacement to render
* identically in Node and a browser).
*
* What necessarily differs, and why:
* - `Surface` is dropped as a parameter. The real components are shared with
* the agency ticket page and take `surface: 'agency' | 'portal'` on every
* call; DeskTicket.tsx always passes `'portal'`, so every `en ? … : …`
* ternary in the five child components always took the German branch here.
* That branch is what is kept; the English one is source code this screen
* never reaches, not a simplification of behaviour.
* - `canEdit` is always `false` on this route (`TicketFieldsCard`'s only
* caller here passes it literally). Its `editing` branch — the inline
* field-by-field editor built on `TicketForm.tsx`'s `FieldInput`, agent-only
* in the real app — is therefore dead code on the client portal and is not
* ported; only the read view plus the `file`-field upload row (open to any
* non-viewer member, §4.4) is.
* - Every write is a prop, not a network call: `acceptTicket` / `changeStatus`
* (`StatusActions`), `postMessage` / `uploadToTicket` (`MessageComposer`),
* `uploadToTicket` again (`TicketFieldsCard`'s file row) and
* `downloadAttachment` (`AttachmentChip`) all reach the network in the
* source; here the buttons and inputs render, the modal opens and closes,
* and the composer's textarea is a real controlled input, but nothing
* leaves the component and no state a caller passed in changes — the same
* "inert action" convention subscription-manager's top-up/cancel buttons
* and admin-users-console's confirm-gated row actions use.
* - `slaState`'s `now` defaults to `new Date()` in the source; every call
* here passes a fixed reference timestamp instead, so the SLA badge's text
* ("noch 3 h 40 min", say) renders identically on every run rather than
* drifting with the wall clock.
* - `TicketDetail`, `DeskConfig` and their nested types are trimmed to the
* fields this screen actually reads (`serviceDeskService.ts` carries a much
* larger shape used by the ticket list, the agency queue and the metrics
* pages — `number`, `blocked_by_us`, `capacity_month`, `events`,
* `routing_events`, `time_entries`, `timings`, `links`, `tags`,
* `pipeline_key` and more are never rendered by `DeskTicket.tsx` and are
* left out here, not silently dropped from the real interface).
*
* Genuine gap, not a demo simplification: `.btn-primary`, `.btn-ghost`,
* `.btn-quiet`, `.field`, `.field-sm` and `.label` are real classes in
* `alchemy_labs/frontend/src/index.css` (`@layer components`, lines 240 to
* 431) that this screen's markup depends on, but `packages/alchemy-ui/src/
* base.css` in this kit currently ports only `.card`, `.tnum`, `.ctx-link`
* and `.chip` from that same layer (its own header comment names exactly
* those four). Until a docs-host extraction pass adds the other six, every
* button and every text input on this recipe's rendered frame falls back to
* unstyled browser chrome for those classes specifically — render-smoke
* still passes (it checks that the tree renders, not that a class resolves),
* but a visual `compare.mjs` run against the real app will show it.
*/
import React, { useState } from 'react';
import { defineStates } from '@prima-modules/demo';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
/* ---- types (trimmed serviceDeskService.ts + deskFormat.ts, portal-only) - */
type TicketStatus = 'new' | 'accepted' | 'in_progress' | 'waiting_on_client' | 'solved' | 'client_review' | 'done' | 'cancelled';
type DeskRole = 'agent' | 'requester' | 'viewer';
type TaskFieldType = 'text' | 'textarea' | 'url' | 'date' | 'select' | 'multiselect' | 'checkbox' | 'file';
interface TaskTypeField {
key: string;
/** The client's label. Authoritative, required, German (LANGUAGE.md R2). */
label: string;
type: TaskFieldType;
required?: boolean;
options?: string[];
option_labels?: Record<string, string>;
accept?: string[];
help?: string;
placeholder?: string;
}
interface TaskType {
slug: string;
label: string;
fields: TaskTypeField[];
required_any_of?: string[][];
}
interface DeskClass {
key: string;
label: string;
}
/** GET /api/desk/config's shape, as this screen reads it. */
interface DeskConfig {
classes: DeskClass[];
task_types: TaskType[];
review_timeout_working_days: number;
my_role: DeskRole;
/** Is the viewer a named contact / deputy (Ziffer 3.15)? */
my_is_designated?: boolean;
/** WHICH mailbox is signed in, under a shared practice account. */
acting_email?: string | null;
shared_account?: boolean;
}
interface UserRef {
id?: string | null;
name?: string | null;
email?: string | null;
}
interface RequesterRef {
id?: string | null;
email: string;
name?: string | null;
is_designated: boolean;
acting_email?: string | null;
}
interface SlaInfo {
due: string;
responded_at?: string | null;
state: 'open' | 'met' | 'breached';
remaining_seconds: number;
}
interface Attachment {
id: string;
filename: string;
mime: string;
size_bytes: number;
kind: string;
field_key?: string | null;
created_at: string;
}
interface DeskMessage {
id: string;
ticket_id: string;
direction: 'in' | 'out';
channel: string;
author?: UserRef | null;
body: string;
is_internal: boolean;
is_decision: boolean;
email_sent_at?: string | null;
/** Who wrote it, when the author is a shared account. */
acting_email?: string | null;
attachments: Attachment[];
created_at: string;
}
/** What the portal stores on the ticket after the intake check (§4.8.4), trimmed to the two arrays this screen renders. */
interface IntakeCheckResult {
questions?: string[];
answers?: string[];
}
interface TicketDetail {
id: string;
ticket_key: string;
title: string;
type_slug: string;
type_label: string;
class_key: string;
class_label: string;
status: TicketStatus;
requester: RequesterRef;
assignee?: UserRef | null;
received_at: string;
client_review_started_at?: string | null;
done_at?: string | null;
sla: SlaInfo;
fields: Record<string, unknown>;
fields_complete: boolean;
missing_fields: string[];
intake_check?: IntakeCheckResult | null;
messages: DeskMessage[];
attachments: Attachment[];
}
interface TransitionOption {
to: TicketStatus;
label: string;
needs?: 'message' | 'reason';
quiet?: boolean;
hint?: string;
disabled?: boolean;
}
/* ---- props -------------------------------------------------------------- */
export interface Props {
workspace: ShellWorkspace;
/** The signed-in account (`useAuth().user`); `userId` decides `isOwnTicket`. */
account: { userId?: string | null; email?: string | null; name?: string | null };
/** `loading` is the first fetch (line 58); `error` is `load()` throwing (line 31 to 33). */
status: 'loading' | 'ok' | 'error';
ticket: TicketDetail | null;
config: DeskConfig | null;
error?: string | null;
}
/* ---- a fixed reference time, so the SLA badge renders the same every run - */
const NOW = new Date('2026-09-12T09:30:00Z');
/* ---- copy + formatting helpers (deskFormat.ts, German branch only) ------ */
const STATUS_DE: Record<TicketStatus, string> = {
new: 'Neu',
accepted: 'Angenommen',
in_progress: 'In Arbeit',
waiting_on_client: 'Wartet auf dich',
solved: 'Gelöst',
client_review: 'Zur Prüfung',
done: 'Erledigt',
cancelled: 'Storniert',
};
function statusLabel(status: TicketStatus): string {
return STATUS_DE[status] ?? status;
}
/** Sentence for the portal detail header — client copy, German only (deskFormat.ts line 112 to 131). */
function statusSentence(status: TicketStatus): string {
switch (status) {
case 'new':
return 'Dein Auftrag ist eingegangen. Wir melden uns innerhalb der zugesagten Reaktionszeit.';
case 'accepted':
return 'Jemand aus dem Team hat deinen Auftrag übernommen.';
case 'in_progress':
return 'Wir arbeiten daran.';
case 'waiting_on_client':
return 'Wir warten auf deine Antwort – schreib uns unten, dann geht es weiter.';
case 'solved':
return 'Von unserer Seite ist der Auftrag gelöst. Die Übergabe folgt gleich.';
case 'client_review':
return 'Bitte prüfe das Ergebnis. Passt alles? Dann schliess den Auftrag ab – sonst sag uns, was nachzubessern ist.';
case 'done':
return 'Erledigt. Danke!';
case 'cancelled':
return 'Dieser Auftrag wurde storniert.';
default:
return '';
}
}
function classShort(classKey: string): string {
return classKey.toUpperCase();
}
const ZURICH = 'Europe/Zurich';
/** "4. Sept. 2026, 14:05" — explicit locale and time zone both, so this renders
* identically in Node (render-smoke) and a browser (deskFormat.ts line 165 to 178). */
function fmtZurich(iso: string | null | undefined, withTime = true): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return new Intl.DateTimeFormat('de-CH', {
timeZone: ZURICH,
day: 'numeric',
month: 'short',
year: 'numeric',
hourCycle: 'h23',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
}).format(d);
}
/** "1 h 20 min", "2 T 3 h", "45 min", "unter 1 min" (deskFormat.ts line 220 to 232). */
function fmtDuration(seconds: number | null | undefined): string {
if (seconds == null || !Number.isFinite(seconds)) return '—';
const s = Math.max(0, Math.round(seconds));
if (s < 60) return 'unter 1 min';
const m = Math.floor(s / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
const rm = m % 60;
if (h < 24) return rm ? `${h} h ${rm} min` : `${h} h`;
const d = Math.floor(h / 24);
const rh = h % 24;
return rh ? `${d} T ${rh} h` : `${d} T`;
}
type SlaVariant = 'ok' | 'soon' | 'breached' | 'met' | 'late';
/** Chip variant + relative text for the first-response clock (deskFormat.ts line 240 to 258). */
function slaState(sla: SlaInfo, now: Date = NOW): { variant: SlaVariant; text: string } {
if (sla.responded_at) {
const late = new Date(sla.responded_at).getTime() > new Date(sla.due).getTime();
return late ? { variant: 'late', text: 'verspätet beantwortet' } : { variant: 'met', text: 'beantwortet' };
}
const remaining = (new Date(sla.due).getTime() - now.getTime()) / 1000;
if (remaining < 0) return { variant: 'breached', text: `überfällig seit ${fmtDuration(-remaining)}` };
const text = `noch ${fmtDuration(remaining)}`;
if (remaining < 2 * 3600) return { variant: 'soon', text };
return { variant: 'ok', text };
}
function displayName(u?: { name?: string | null; email?: string | null } | null): string {
if (!u) return '—';
return u.name || u.email || '—';
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
function optionLabel(field: Pick<TaskTypeField, 'option_labels'> | undefined, value: unknown): string {
const v = value == null ? '' : String(value);
return field?.option_labels?.[v] || v;
}
function fieldValueText(field: TaskTypeField | undefined, value: unknown): string {
if (value == null || value === '') return '';
if (Array.isArray(value)) return value.map((v) => optionLabel(field, v)).join(', ');
if (typeof value === 'boolean') return value ? 'Ja' : 'Nein';
if (field && (field.type === 'select' || field.type === 'multiselect')) return optionLabel(field, value);
return String(value);
}
/** Field keys as their labels, for a sentence; an `"a|b"` group reads as a choice. */
function missingFieldLabels(type: TaskType | undefined, missing: string[] | undefined): string[] {
const one = (k: string) => type?.fields.find((x) => x.key === k)?.label ?? k;
return (missing ?? []).map((k) => (k.includes('|') ? k.split('|').map(one).join(' oder ') : one(k)));
}
function isFieldMissing(missing: string[] | undefined, key: string): boolean {
return (missing ?? []).some((k) => k === key || k.split('|').includes(key));
}
function fieldLabel(type: TaskType | undefined, key: string | null | undefined): string | null {
if (!key) return null;
return type?.fields.find((x) => x.key === key)?.label ?? key;
}
/* ---- status machine mirror (deskFormat.ts line 297 to 375, both roles) -- */
function allowedTransitions(
status: TicketStatus,
role: DeskRole,
o: { fieldsComplete?: boolean; isOwnTicket?: boolean; isDesignated?: boolean; unassigned?: boolean },
): TransitionOption[] {
const gate = o.fieldsComplete === false;
const fieldGate = gate ? { disabled: true, hint: 'Erst möglich, wenn alle Pflichtangaben da sind.' } : {};
const cancel: TransitionOption = { to: 'cancelled', label: 'Stornieren', needs: 'reason', quiet: true };
if (role === 'viewer') return [];
if (role === 'agent') {
switch (status) {
case 'new':
return [{ to: 'accepted', label: 'Annehmen' }, { to: 'in_progress', label: 'Annehmen & starten', ...fieldGate }, cancel];
case 'accepted':
return [
...(o.unassigned ? [{ to: 'accepted' as TicketStatus, label: 'Annehmen' }] : []),
{ to: 'in_progress', label: 'Arbeit starten', ...fieldGate },
cancel,
];
case 'in_progress':
return [
{ to: 'waiting_on_client', label: 'Rückfrage stellen', needs: 'message', hint: 'Pausiert die Uhr, mailt die Frage.' },
{ to: 'solved', label: 'Gelöst & zur Prüfung', needs: 'message', hint: 'Mit Übergabetext geht es direkt zur Prüfung durch den Kunden.' },
cancel,
];
case 'waiting_on_client':
return [{ to: 'in_progress', label: 'Weiterarbeiten' }, cancel];
case 'solved':
return [
{ to: 'client_review', label: 'Zur Prüfung senden', needs: 'message' },
{ to: 'in_progress', label: 'Nachbessern', quiet: true },
cancel,
];
case 'client_review':
return [
{ to: 'done', label: 'Abschliessen (Frist abgelaufen)', hint: 'Nur nach Ablauf der Prüffrist; sonst nimmt der Kunde ab.' },
{ to: 'in_progress', label: 'Nachbessern', quiet: true },
cancel,
];
case 'done':
return [{ to: 'in_progress', label: 'Wieder öffnen', quiet: true }];
default:
return [];
}
}
// requester — Ziffer 3.15: Storno, Abnahme and Nachbesserung belong to the
// person who ordered the work or to a designated contact / deputy.
const mayDecide = o.isOwnTicket || o.isDesignated;
if (!mayDecide) return [];
switch (status) {
case 'new':
case 'accepted':
return [{ to: 'cancelled', label: 'Auftrag zurückziehen', quiet: true }];
case 'client_review':
return [
{ to: 'done', label: 'Abnehmen' },
{ to: 'in_progress', label: 'Nachbesserung anfordern', needs: 'message', quiet: true },
];
default:
return [];
}
}
/* ---- desk/ui.tsx: chips, spinner, inline notices, modal ------------------ */
const STATUS_CLS: Record<TicketStatus, string> = {
new: 'bg-purple-100 text-purple-700',
accepted: 'bg-purple-50 text-purple-700',
in_progress: 'bg-ink/5 text-ink',
waiting_on_client: 'bg-warn-bg text-warn',
solved: 'bg-success-bg text-success',
client_review: 'bg-purple text-white',
done: 'bg-success-bg text-success',
cancelled: 'bg-ink/5 text-ink-60',
};
/** This screen never passes `blocked` (only the ticket list does), so that branch is not ported. */
function StatusChip({ status }: { status: TicketStatus }) {
return (
<span className="inline-flex items-center gap-1 flex-wrap">
<span className={`chip ${STATUS_CLS[status] ?? 'bg-ink/5 text-ink-60'}`}>{statusLabel(status)}</span>
</span>
);
}
const CLASS_CLS: Record<string, string> = { k1: 'bg-danger-bg text-danger', k2: 'bg-warn-bg text-warn', k3: 'bg-ink/5 text-ink-60' };
function ClassChip({ classKey }: { classKey: string }) {
return <span className={`chip ${CLASS_CLS[classKey] ?? 'bg-ink/5 text-ink-60'}`}>{classShort(classKey)}</span>;
}
const SLA_CLS: Record<SlaVariant, string> = {
ok: 'text-ink-60',
soon: 'text-warn font-semibold',
breached: 'text-danger font-semibold',
met: 'text-success',
late: 'text-danger',
};
function SlaBadge({ sla }: { sla: SlaInfo }) {
const s = slaState(sla);
return (
<span className={`inline-flex flex-col leading-tight text-[12.5px] ${SLA_CLS[s.variant]}`}>
<span>{s.text}</span>
<span className="text-[11px] text-ink-40 font-normal tnum">bis {fmtZurich(sla.due)}</span>
</span>
);
}
function Spinner() {
return <div className="py-12 text-center text-ink-60 text-[14px]">Laden…</div>;
}
function ErrorNote({ text }: { text?: string | null }) {
if (!text) return null;
return <div className="rounded-xl bg-danger-bg text-danger text-[13.5px] px-3.5 py-2.5">{text}</div>;
}
function InfoNote({ children }: { children: React.ReactNode }) {
return <div className="rounded-xl bg-purple-50 text-purple-700 text-[13.5px] px-3.5 py-2.5">{children}</div>;
}
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-ink/40 p-4" onClick={onClose}>
<div className="card w-full max-w-lg p-6" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="flex items-start justify-between gap-4 mb-4">
<h2 className="text-[18px] font-bold text-ink">{title}</h2>
<button type="button" onClick={onClose} className="text-ink-40 hover:text-ink text-[20px] leading-none" aria-label="Schliessen">
×
</button>
</div>
{children}
</div>
</div>
);
}
/* ---- ActingEmailBadge.tsx ------------------------------------------------ */
/**
* "Du bist als lukas@… angemeldet · Konto kontakt@…" — a practice signs in
* to ONE shared account from several mailboxes; `acting_email` names the
* person at the keyboard, the account's own address names the practice.
* Renders nothing for an ordinary login, where the two are the same address.
*/
function ActingEmailBadge({ config, accountEmail, accountName, className = '' }: { config: DeskConfig | null | undefined; accountEmail?: string | null; accountName?: string | null; className?: string }) {
const acting = config?.acting_email ?? null;
if (!config?.shared_account || !acting) return null;
const account = (accountName || accountEmail || '').trim();
return (
<div className={`rounded-xl bg-purple-50 text-purple-700 text-[12.5px] px-3.5 py-2 ${className}`} data-testid="acting-email-badge">
Du bist als <span className="font-semibold">{acting}</span> angemeldet
{account && <span className="text-purple-700/70">{' · '}Konto {account}</span>}
</div>
);
}
/** The same fact for a message header: shown only when it differs from the account it acted through. */
function ActingEmailLine({ actingEmail, accountEmail }: { actingEmail?: string | null; accountEmail?: string | null }) {
const acting = (actingEmail || '').trim().toLowerCase();
if (!acting) return null;
if (acting === (accountEmail || '').trim().toLowerCase()) return null;
return (
<span className="text-[11.5px] text-ink-40" data-testid="acting-email-line">
von {acting}
</span>
);
}
/* ---- MessageThread.tsx ---------------------------------------------------- */
/** Messages ordered by time. `in` (client) left, `out` (us) right. Internal
* notes never reach the portal (the backend filters them before this array
* arrives), so `is_internal` is never true in this recipe's sample data. */
function MessageThread({ messages, type, accountEmail }: { messages: DeskMessage[]; type?: TaskType; accountEmail?: string | null }) {
if (messages.length === 0) return <div className="text-[14px] text-ink-40 py-6 text-center">Noch keine Nachrichten.</div>;
return (
<ol className="space-y-3">
{messages.map((m) => {
const ours = m.direction === 'out';
const who = ours ? displayName(m.author) || 'Alchemy' : displayName(m.author) || 'Du';
return (
<li key={m.id} className={`flex ${ours ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-2xl px-4 py-3 text-[14px] leading-relaxed ${m.is_internal ? 'bg-warn-bg/70 text-ink border border-warn/20' : ours ? 'bg-purple-50 text-ink' : 'bg-white border border-line text-ink'}`}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11.5px] text-ink-60 mb-1.5">
<span className="font-semibold text-ink">{who}</span>
<span className="tnum">{fmtZurich(m.created_at)}</span>
{m.is_decision && <span className="chip bg-purple-100 text-purple-700">Entscheid</span>}
{m.channel === 'email' && <span className="chip bg-ink/5 text-ink-60">E-Mail</span>}
<ActingEmailLine actingEmail={m.acting_email} accountEmail={m.author?.email ?? accountEmail} />
</div>
<div className="whitespace-pre-wrap break-words">{m.body}</div>
{m.attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{m.attachments.map((a) => (
<AttachmentChip key={a.id} att={a} type={type} />
))}
</div>
)}
</div>
</li>
);
})}
</ol>
);
}
/** The download click reaches `downloadAttachment` (a network call) in the
* source; here it is inert, the same convention as every other write in
* this file (header comment). */
function AttachmentChip({ att, type }: { att: Attachment; type?: TaskType }) {
const forField = fieldLabel(type, att.field_key);
return (
<span className="inline-flex items-center gap-1.5">
<button type="button" className="chip bg-white border border-line text-ink hover:border-purple/40 hover:text-purple max-w-[16rem]" title={`${att.filename} · ${fmtBytes(att.size_bytes)}`}>
<span aria-hidden>📎</span>
<span className="truncate">{att.filename}</span>
<span className="text-ink-40 font-medium">{fmtBytes(att.size_bytes)}</span>
{forField && <span className="text-ink-40 font-medium">· {forField}</span>}
</button>
</span>
);
}
/* ---- MessageComposer.tsx --------------------------------------------------- */
/** Reply box. `postMessage` and `uploadToTicket` are network calls in the
* source; Send and Attach are inert here, the textarea and the file-field
* select stay real controlled inputs. */
function MessageComposer({ placeholder, fileFields = [] }: { placeholder?: string; fileFields?: TaskTypeField[] }) {
const [body, setBody] = useState('');
const [fieldKey, setFieldKey] = useState('');
return (
<div className="rounded-2xl border p-4 border-line bg-white">
<textarea className="field min-h-[96px] bg-transparent" value={body} onChange={(e) => setBody(e.target.value)} placeholder={placeholder ?? 'Deine Nachricht ans Team'} />
<div className="flex flex-wrap items-center gap-3 mt-3">
<button type="button" className="btn-primary" disabled={!body.trim()}>
Senden
</button>
<label className="btn-quiet cursor-pointer text-[13px]">
<input type="file" className="hidden" accept="application/pdf,image/jpeg,image/png,image/webp,text/plain,.docx" onChange={() => {}} />
📎 Datei anhängen
</label>
{fileFields.length > 0 && (
<label className="inline-flex items-center gap-1.5 text-[12.5px] text-ink-60">
<span className="text-warn font-semibold">fehlt:</span>
<select className="field-sm" value={fieldKey} onChange={(e) => setFieldKey(e.target.value)}>
<option value="">nur anhängen</option>
{fileFields.map((f) => (
<option key={f.key} value={f.key}>
als {f.label}
</option>
))}
</select>
</label>
)}
<span className="text-[11.5px] text-ink-40 ml-auto">⌘↵ sendet</span>
</div>
</div>
);
}
/* ---- StatusActions.tsx ----------------------------------------------------- */
/**
* Only the legal transitions for this status + role. A transition that needs
* text opens a small modal (kept, local `useState`); the actual write
* (`acceptTicket` / `changeStatus`) is a network call in the source and is
* inert here — confirming the modal just closes it, the same convention as
* every other write in this file.
*/
function StatusActions({ ticket, role, myIsDesignated, userId, type }: { ticket: TicketDetail; role: DeskRole; myIsDesignated: boolean; userId?: string | null; type?: TaskType }) {
const [pending, setPending] = useState<TransitionOption | null>(null);
const [text, setText] = useState('');
const options = allowedTransitions(ticket.status, role, {
fieldsComplete: ticket.fields_complete,
isOwnTicket: !!userId && ticket.requester.id === userId,
isDesignated: myIsDesignated,
unassigned: !ticket.assignee?.id,
});
if (options.length === 0) return null;
const click = (opt: TransitionOption) => {
if (opt.needs) {
setPending(opt);
setText('');
}
};
const modalTitle = (opt: TransitionOption) =>
opt.to === 'waiting_on_client'
? 'Rückfrage an den Kunden'
: opt.to === 'cancelled'
? 'Warum wird storniert?'
: opt.to === 'in_progress' && role !== 'agent'
? 'Was soll nachgebessert werden?'
: 'Übergabetext für die Prüfung durch den Kunden';
return (
<div>
<div className="flex flex-wrap gap-2">
{options.map((opt) => (
<button
key={opt.to + opt.label}
type="button"
disabled={!!opt.disabled}
onClick={() => click(opt)}
className={`${opt.quiet ? 'btn-ghost' : 'btn-primary'} ${opt.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={opt.hint}
>
{opt.label}
</button>
))}
</div>
{options.some((o) => o.hint) && (
<div className="text-[12px] mt-2 space-y-0.5">
{options
.filter((o) => o.hint)
.map((o) => (
<div key={o.to} className={o.disabled ? 'text-warn' : 'text-ink-40'}>
{o.label}: {o.hint}
{o.disabled && o.to === 'in_progress' && !ticket.fields_complete && missingFieldLabels(type, ticket.missing_fields).length > 0 && (
<> Es fehlt: {missingFieldLabels(type, ticket.missing_fields).join(', ')}.</>
)}
</div>
))}
</div>
)}
{pending && (
<Modal title={modalTitle(pending)} onClose={() => setPending(null)}>
<textarea
className="field min-h-[120px]"
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={
pending.needs === 'reason'
? 'Grund (wird dem Kunden mitgeteilt)'
: pending.to === 'waiting_on_client'
? 'Deine Frage – geht per E-Mail raus und pausiert die Uhr.'
: pending.to === 'in_progress'
? 'Was passt noch nicht?'
: 'Was wurde gemacht, was soll der Kunde prüfen? Geht per E-Mail raus.'
}
/>
{pending.to === 'solved' && (
<div className="text-[12px] text-ink-40 mt-2">
Ohne Text bleibt das Ticket auf «Gelöst» und wartet auf die Übergabe; mit Text geht es sofort zur Prüfung durch den Kunden.
</div>
)}
<div className="flex items-center gap-2 mt-4">
<button type="button" className="btn-primary" onClick={() => setPending(null)}>
{pending.label}
</button>
<button type="button" className="btn-quiet" onClick={() => setPending(null)}>
Abbrechen
</button>
</div>
</Modal>
)}
</div>
);
}
/* ---- TicketFieldsCard.tsx --------------------------------------------------- */
/**
* The type's required fields on an existing ticket. `canEdit` is always
* `false` on this route (DeskTicket.tsx line 162), so the source's inline
* editor branch (`editing`, `FieldInput`, agent-only in the real app) never
* runs here and is not ported; only the read view and the `file`-field
* upload row are.
*/
function TicketFieldsCard({ ticket, type, canUpload }: { ticket: TicketDetail; type: TaskType; canUpload: boolean }) {
const fields = type.fields;
const missingLabels = missingFieldLabels(type, ticket.missing_fields);
const attachmentFor = (key: string) => ticket.attachments.find((a) => a.field_key === key);
return (
<div className="card p-4 text-[13px]">
<div className="flex items-center justify-between gap-2">
<div className="label mb-0">Pflichtangaben</div>
<span className={`chip ${ticket.fields_complete ? 'bg-success-bg text-success' : 'bg-warn-bg text-warn'}`}>
{ticket.fields_complete ? 'vollständig' : ticket.missing_fields.length === 1 ? '1 Angabe fehlt' : `${ticket.missing_fields.length} Angaben fehlen`}
</span>
</div>
{!ticket.fields_complete && missingLabels.length > 0 && (
<div className="mt-2 rounded-xl bg-warn-bg/60 px-3 py-2 text-[12.5px] text-warn">Es fehlt noch: {missingLabels.join(', ')}. Vorher kann die Arbeit nicht starten.</div>
)}
<dl className="mt-3 space-y-2">
{fields.map((f) => {
const missing = isFieldMissing(ticket.missing_fields, f.key);
const att = attachmentFor(f.key);
const text = fieldValueText(f, ticket.fields[f.key]);
return (
<div key={f.key}>
<dt className={`text-[11.5px] ${missing ? 'text-warn font-semibold' : 'text-ink-40'}`}>
{f.label}
{f.required && ' *'}
{missing && ' – fehlt'}
</dt>
<dd className="text-ink break-words whitespace-pre-wrap">
{f.type === 'file' ? (
att ? (
<AttachmentChip att={att} type={type} />
) : canUpload ? (
<FileRow field={f} missing={missing} />
) : (
<span className="text-ink-40">—</span>
)
) : text ? (
text
) : (
<span className="text-ink-40">—</span>
)}
</dd>
</div>
);
})}
</dl>
</div>
);
}
/** Upload for one `file` field, `bare` form (the requester's own way to
* complete an incomplete ticket, §4.4). `uploadToTicket` is a network call
* in the source; the file input here is inert. */
function FileRow({ field: f, missing }: { field: TaskTypeField; missing: boolean }) {
return (
<label className={`btn-ghost cursor-pointer inline-flex text-[12.5px] ${missing ? 'border-warn' : ''}`}>
<input type="file" className="hidden" accept={(f.accept ?? []).join(',') || undefined} onChange={() => {}} />
Datei wählen
</label>
);
}
/* ---- DeskTicket.tsx --------------------------------------------------------- */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-3">
<span className="text-ink-40 shrink-0">{label}</span>
<span className="text-ink text-right min-w-0">{value}</span>
</div>
);
}
/** Client portal ticket: status in plain words, thread, reply, Abnahme. */
function DeskTicketPage({ account, status, ticket, config, error }: Omit<Props, 'workspace'>) {
const role = config?.my_role ?? 'requester';
const type = config?.task_types.find((t) => t.slug === ticket?.type_slug);
const unfilledFileFields = (type?.fields ?? []).filter((f) => f.type === 'file' && !ticket?.attachments.some((a) => a.field_key === f.key));
return (
<div className="max-w-4xl">
<div className="mb-3">
<a href="/desk" className="text-[13px] text-ink-60 hover:text-purple">
← Deine Aufträge
</a>
</div>
{error && <ErrorNote text={error} />}
{status === 'loading' && <Spinner />}
{ticket && (
<>
<div className="flex flex-wrap items-end justify-between gap-4 mb-7">
<div>
<div className="text-[12px] font-semibold uppercase tracking-[0.14em] text-purple mb-1.5">Service Desk · {ticket.ticket_key}</div>
<h1 className="text-[28px] leading-none font-extrabold tracking-[-0.02em] text-ink">{ticket.title}</h1>
</div>
<div className="flex flex-col items-end gap-1">
<StatusChip status={ticket.status} />
<span className="text-[12px] text-ink-40">{ticket.type_label}</span>
</div>
</div>
<ActingEmailBadge config={config} accountEmail={account.email} accountName={account.name} className="mb-4" />
<div className={`rounded-2xl px-5 py-4 mb-5 text-[15px] ${ticket.status === 'waiting_on_client' || ticket.status === 'client_review' ? 'bg-warn-bg text-warn' : 'bg-purple-50 text-purple-700'}`}>
<div className="font-semibold">{statusSentence(ticket.status)}</div>
{ticket.status === 'client_review' && ticket.client_review_started_at && config && (
<div className="text-[12.5px] mt-1 opacity-80">
Meldest du dich {config.review_timeout_working_days} Werktage nicht, darf das Team den Auftrag selbst abschliessen.
</div>
)}
</div>
{role !== 'viewer' && (
<div className="mb-6">
<StatusActions ticket={ticket} role={role} userId={account.userId} myIsDesignated={!!config?.my_is_designated} type={type} />
</div>
)}
<div className="grid md:grid-cols-[1fr_16rem] gap-5">
<div className="space-y-4">
<div className="card p-5">
<div className="label">Verlauf</div>
<MessageThread messages={ticket.messages} type={type} accountEmail={account.email} />
</div>
{role !== 'viewer' && ticket.status !== 'cancelled' && (
<MessageComposer placeholder={ticket.status === 'waiting_on_client' ? 'Deine Antwort – damit geht es weiter.' : 'Deine Nachricht ans Team'} fileFields={unfilledFileFields} />
)}
{ticket.status === 'done' && <InfoNote>Der Auftrag ist erledigt. Schreibst du hier, sehen wir es trotzdem.</InfoNote>}
</div>
<aside className="space-y-4">
<div className="card p-4 text-[13px] space-y-2">
<Row label="Eingang" value={fmtZurich(ticket.received_at)} />
<Row
label="Klasse"
value={
<span className="inline-flex items-center gap-1.5">
<ClassChip classKey={ticket.class_key} /> {ticket.class_label}
</span>
}
/>
<Row label="Reaktionszeit" value={<SlaBadge sla={ticket.sla} />} />
<Row label="Zuständig" value={ticket.assignee ? displayName(ticket.assignee) : 'noch offen'} />
<Row
label="Angefragt von"
value={
ticket.requester.acting_email ? (
<span className="inline-flex flex-col items-end">
<span>{ticket.requester.acting_email}</span>
<span className="text-[11.5px] text-ink-40">{ticket.requester.name || ticket.requester.email}</span>
</span>
) : (
ticket.requester.name || ticket.requester.email
)
}
/>
{ticket.done_at && <Row label={ticket.status === 'cancelled' ? 'Storniert' : 'Erledigt'} value={fmtZurich(ticket.done_at)} />}
</div>
{type && <TicketFieldsCard ticket={ticket} type={type} canUpload={role !== 'viewer' && ticket.status !== 'cancelled'} />}
{ticket.attachments.length > 0 && (
<div className="card p-4 text-[13px]">
<div className="label">Dateien</div>
<div className="flex flex-col gap-1.5">
{ticket.attachments.map((a) => (
<AttachmentChip key={a.id} att={a} type={type} />
))}
</div>
</div>
)}
{ticket.intake_check && (ticket.intake_check.questions?.length ?? 0) > 0 && (
<div className="card p-4 text-[13px]">
<div className="label">Rückfragen bei der Aufnahme</div>
<ol className="space-y-2">
{ticket.intake_check.questions!.map((q, i) => (
<li key={i}>
<div className="text-ink">{q}</div>
<div className="text-ink-60 whitespace-pre-wrap">{ticket.intake_check?.answers?.[i] || <span className="text-ink-40">nicht beantwortet</span>}</div>
</li>
))}
</ol>
</div>
)}
</aside>
</div>
</>
)}
</div>
);
}
/* ---- sample data (Atelier Nordwind, the fictional tenant billing-credits/
ui/subscription-manager's demo already established, kept for continuity) - */
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Atelier Nordwind', slug: 'atelier-nordwind' };
const elena = { userId: 'usr_82', email: 'kontakt@atelier-nordwind.example', name: 'Elena Roth' };
const contentType: TaskType = {
slug: 'content_update',
label: 'Content-Anpassung',
fields: [
{ key: 'seite', label: 'Betroffene Seite', type: 'url', required: true, placeholder: 'atelier-nordwind.example/leistungen' },
{ key: 'prioritaet', label: 'Priorität', type: 'select', required: true, options: ['normal', 'dringend'], option_labels: { normal: 'Normal', dringend: 'Dringend' } },
{ key: 'frist', label: 'Wunschtermin', type: 'date', required: false },
{ key: 'freigabe_noetig', label: 'Freigabe durch Praxisleitung nötig', type: 'checkbox', required: false },
{ key: 'referenz_pdf', label: 'Referenzdokument', type: 'file', required: true, accept: ['application/pdf'] },
],
};
const config: DeskConfig = {
classes: [
{ key: 'k1', label: 'Störung' },
{ key: 'k2', label: 'Wichtig' },
{ key: 'k3', label: 'Normal' },
],
task_types: [contentType],
review_timeout_working_days: 3,
my_role: 'requester',
my_is_designated: true,
acting_email: null,
shared_account: false,
};
const sharedAccountConfig: DeskConfig = { ...config, acting_email: 'lukas@atelier-nordwind.example', shared_account: true };
const respondedSla: SlaInfo = { due: '2026-09-10T14:00:00Z', responded_at: '2026-09-10T10:12:00Z', state: 'met', remaining_seconds: 0 };
const jonas: UserRef = { id: 'agt_4', name: 'Jonas Berger', email: 'jonas@alchemy.zuerich.example' };
const referenzAttachment: Attachment = { id: 'att_1', filename: 'referenz-nordwind.pdf', mime: 'application/pdf', size_bytes: 482_331, kind: 'reference', field_key: 'referenz_pdf', created_at: '2026-09-11T08:14:00Z' };
const baseTicket: Omit<TicketDetail, 'status' | 'fields' | 'fields_complete' | 'missing_fields' | 'attachments' | 'messages' | 'done_at' | 'client_review_started_at'> = {
id: 'tk_1',
ticket_key: 'NORD-118',
title: 'Leistungen-Seite: Text und Bilder aktualisieren',
type_slug: 'content_update',
type_label: 'Content-Anpassung',
class_key: 'k2',
class_label: 'Wichtig',
requester: { id: 'usr_82', email: 'kontakt@atelier-nordwind.example', name: 'Elena Roth', is_designated: true, acting_email: null },
assignee: jonas,
received_at: '2026-09-10T09:05:00Z',
sla: respondedSla,
};
const filledFields = { seite: 'https://atelier-nordwind.example/leistungen', prioritaet: 'dringend', frist: '2026-09-20', freigabe_noetig: true };
const requestMessage: DeskMessage = {
id: 'm1',
ticket_id: 'tk_1',
direction: 'in',
channel: 'portal',
author: { id: null, name: null, email: 'kontakt@atelier-nordwind.example' },
body: 'Können wir den Leistungen-Text bis Ende Monat anpassen? Ich schicke gleich das Referenzdokument mit.',
is_internal: false,
is_decision: false,
email_sent_at: null,
acting_email: 'lukas@atelier-nordwind.example',
attachments: [],
created_at: '2026-09-10T09:05:00Z',
};
const ackMessage: DeskMessage = {
id: 'm2',
ticket_id: 'tk_1',
direction: 'out',
channel: 'email',
author: jonas,
body: 'Danke, wir haben den Auftrag übernommen und melden uns mit einem Vorschlag.',
is_internal: false,
is_decision: false,
email_sent_at: '2026-09-10T10:12:00Z',
acting_email: null,
attachments: [],
created_at: '2026-09-10T10:12:00Z',
};
const handoverMessage: DeskMessage = {
id: 'm3',
ticket_id: 'tk_1',
direction: 'out',
channel: 'email',
author: jonas,
body: 'Der neue Text ist live. Bitte kurz gegenprüfen, ob alles passt – dann kannst du den Auftrag abnehmen.',
is_internal: false,
is_decision: true,
email_sent_at: '2026-09-12T08:00:00Z',
acting_email: null,
attachments: [],
created_at: '2026-09-12T08:00:00Z',
};
export const states = defineStates<Props>({
default: {
label: '"In Arbeit": no requester action for this status, full thread, complete fields, attachments and the answered intake check all shown',
props: {
workspace,
account: elena,
status: 'ok',
config: sharedAccountConfig,
ticket: {
...baseTicket,
status: 'in_progress',
fields: filledFields,
fields_complete: true,
missing_fields: [],
attachments: [referenzAttachment],
messages: [{ ...requestMessage, attachments: [referenzAttachment] }, ackMessage],
done_at: null,
client_review_started_at: null,
intake_check: { questions: ['Ist die Domain bereits verifiziert?', 'Gibt es ein bestehendes Redesign-Konzept?'], answers: ['Ja, seit letzter Woche.', ''] },
},
},
},
'waiting-on-client': {
label: 'Wartet auf dich: the amber banner, the composer’s placeholder swap, the missing file field gating "Pflichtangaben" and offered in the reply composer',
props: {
workspace,
account: elena,
status: 'ok',
config: sharedAccountConfig,
ticket: {
...baseTicket,
status: 'waiting_on_client',
fields: { seite: filledFields.seite, prioritaet: filledFields.prioritaet },
fields_complete: false,
missing_fields: ['referenz_pdf'],
attachments: [],
messages: [requestMessage, ackMessage, { ...handoverMessage, id: 'm2b', is_decision: false, body: 'Kannst du uns das Referenzdokument noch zuschicken? Ohne das können wir nicht starten.', created_at: '2026-09-10T11:30:00Z' }],
done_at: null,
client_review_started_at: null,
intake_check: null,
},
},
},
'client-review': {
label: 'Zur Prüfung: Abnahme / Nachbesserung buttons, the review-window sentence, the handover message marked as a decision',
props: {
workspace,
account: elena,
status: 'ok',
config,
ticket: {
...baseTicket,
status: 'client_review',
fields: filledFields,
fields_complete: true,
missing_fields: [],
attachments: [referenzAttachment],
messages: [requestMessage, ackMessage, handoverMessage],
done_at: null,
client_review_started_at: '2026-09-12T08:00:00Z',
intake_check: null,
},
},
},
done: {
label: 'Erledigt: the "still writes here" info note under a still-open composer, the "Erledigt" date row, no action buttons for the requester',
props: {
workspace,
account: elena,
status: 'ok',
config,
ticket: {
...baseTicket,
status: 'done',
fields: filledFields,
fields_complete: true,
missing_fields: [],
attachments: [referenzAttachment],
messages: [requestMessage, ackMessage, handoverMessage, { ...handoverMessage, id: 'm4', direction: 'in', channel: 'portal', author: { id: null, name: null, email: elena.email }, body: 'Passt so, danke!', is_decision: false, acting_email: null, created_at: '2026-09-12T09:10:00Z' }],
done_at: '2026-09-12T09:10:00Z',
client_review_started_at: '2026-09-12T08:00:00Z',
intake_check: null,
},
},
},
cancelled: {
label: 'Storniert: composer and info note both gone, no action buttons, the "Storniert" date row in the same slot "Erledigt" uses',
props: {
workspace,
account: elena,
status: 'ok',
config,
ticket: {
...baseTicket,
status: 'cancelled',
fields: { seite: filledFields.seite },
fields_complete: false,
missing_fields: ['prioritaet', 'referenz_pdf'],
attachments: [],
messages: [requestMessage, { ...ackMessage, id: 'm2c', body: 'Storniert auf deinen Wunsch – melde dich, falls der Auftrag doch noch aktuell wird.', is_decision: false }],
done_at: '2026-09-11T16:00:00Z',
client_review_started_at: null,
intake_check: null,
},
},
},
'viewer-role': {
label: 'A read-only viewer on the same "Zur Prüfung" ticket client-review shows with actions: no Abnahme/Nachbesserung buttons, no composer, no upload row',
props: {
workspace,
account: { userId: 'usr_90', email: 'buchhaltung@atelier-nordwind.example', name: 'Team Buchhaltung' },
status: 'ok',
config: { ...config, my_role: 'viewer', my_is_designated: false },
ticket: {
...baseTicket,
requester: { ...baseTicket.requester, id: 'usr_82' },
status: 'client_review',
fields: filledFields,
fields_complete: true,
missing_fields: [],
attachments: [referenzAttachment],
messages: [requestMessage, ackMessage, handoverMessage],
done_at: null,
client_review_started_at: '2026-09-12T08:00:00Z',
intake_check: null,
},
},
},
loading: {
label: 'First fetch: the shell paints, only a spinner where the ticket will be (DeskTicket.tsx line 58)',
props: { workspace, account: elena, status: 'loading', config: null, ticket: null, error: null },
height: 420,
},
error: {
label: 'load() threw: the fixed portal error sentence, no ticket at all',
props: { workspace, account: elena, status: 'error', config: null, ticket: null, error: 'Auftrag konnte nicht geladen werden.' },
height: 420,
},
});
export default function Demo({ workspace: ws, account, status, ticket, config, error, galleryMode }: Props & { galleryMode?: boolean }) {
const viewer: ShellUser = { name: account.name ?? account.email ?? '', email: account.email ?? '', isAdmin: false };
return (
<Shell domains={DOMAINS} user={viewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/desk" hasTour={false} hideChrome={galleryMode}>
<DeskTicketPage account={account} status={status} ticket={ticket} config={config} error={error} />
</Shell>
);
}
Component tree, from the recipe
<Route /desk/:id> PrivateRoute (children)
Navigation = AppShell
DeskSurfaceProvider value="portal"
div.max-w-4xl
Link "← Deine Auftraege"
[error] ErrorNote (line 57)
[!ticket && !error] Spinner (line 58)
[ticket]
PageHeader eyebrow "Service Desk · {ticket_key}", title, actions: StatusChip + type_label (line 61 to 70)
ActingEmailBadge (shared-account only, line 72 to 78)
status banner div (warn colors on waiting_on_client|client_review, else purple)
statusSentence(status)
[client_review && client_review_started_at] review-window sentence (line 80 to 91)
[role !== viewer] StatusActions
buttons: allowedTransitions(status, role, {...}) (Abnahme | Nachbesserung anfordern | Stornieren | agent transitions)
[opt.needs] Modal: textarea + confirm/cancel (line 93 to 105)
grid md:grid-cols-[1fr_16rem]
left card "Verlauf" > MessageThread (ol of message bubbles, each optional AttachmentChip row)
[role !== viewer && status !== cancelled] MessageComposer (textarea, Senden, Datei anhaengen, missing-field select)
[status === done] InfoNote "Auftrag ist erledigt..." (line 108 to 129)
aside card: Row x5 (Eingang, Klasse+ClassChip, Reaktionszeit+SlaBadge, Zustaendig, Angefragt von [+ acting_email], [done_at] Erledigt|Storniert)
[type] TicketFieldsCard (Pflichtangaben chip, missing banner, dl of field key/value, file field -> AttachmentChip | upload row)
[attachments.length] card "Dateien" > AttachmentChip list
[intake_check.questions.length] card "Rueckfragen bei der Aufnahme" > ol of Q + A (line 131 to 193) The rendering is the live rebuild ticket-detail/demo.tsx, one file collapsing all seven source files (AUTHORING.md "The folder", a demo may only hold its own recipe's code, so MessageThread, MessageComposer, StatusActions, TicketFieldsCard, desk/ui.tsx and ActingEmailBadge are each ported as their own function inside this one module rather than imported). It is mounted inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx, imported the same relative way billing-credits/ui/subscription-manager's demo imports it) on /desk, so "Service Desk" is the active rail entry; the member viewer is never an admin, matching the client-portal scope.
Component tree: the frontmatter's rendering.tree, with line numbers against DeskTicket.tsx at 62ba4fc.
States (rendering.states, in order): default (in_progress, the status with no requester-facing action button at all, full message thread including an inbound attachment, a complete Pflichtangaben card, the answered/unanswered intake-check pair, and the shared-account ActingEmailBadge since the request itself was written by a colleague under the practice's shared login); waiting-on-client (the amber banner and the composer's placeholder swap, a genuinely incomplete ticket, the required referenz_pdf file still missing, so the "Pflichtangaben" card's warning banner, the composer's "fehlt: …" dropdown and the aside's bare upload row are all visible at once, the actual point where the file-upload path this recipe's scope names is exercised); client-review (the Abnahme / Nachbesserung anfordern button pair, the review-window sentence, a handover message rendered with the "Entscheid" chip is_decision sets); done (the composer still mounted with its "still writes here" InfoNote underneath, no action buttons, the "Erledigt" date row); cancelled (composer and info note both gone, the one status that hides the reply box entirely, no action buttons, an incomplete Pflichtangaben card left exactly as it was when the ticket was withdrawn, and the "Storniert" date row sharing the same slot done uses for "Erledigt"); viewer-role (the same client_review ticket client-review renders WITH its two action buttons, here with role: 'viewer': no StatusActions block at all, no composer, and TicketFieldsCard's upload row gone too, so this state's only difference from client-review is every write-capable control disappearing, a deliberate contrast pair); loading (the bare spinner, no ticket, no config); error (load()'s fixed portal sentence, no ticket, no partial content underneath it, the source shows the error note and nothing else, ticket && (...) never opens).
Tokens and classes as the source uses them, none from an atom: card for every boxed section; chip for every pill (StatusChip, ClassChip, the Entscheid/E-Mail message tags, Pflichtangaben's completeness badge); label for the small caps section headers ("Verlauf", "Pflichtangaben", "Dateien", "Rückfragen bei der Aufnahme"); field/field-sm for the composer's textarea and the missing-field <select>; btn-primary/btn-ghost/btn-quiet for every button, the quiet flag on a TransitionOption choosing the ghost variant for a de-emphasized action (Nachbessern, Stornieren); the ink/ink-40/ink-60 text scale and the warn/danger/success/purple palette pairs (*-bg backgrounds with the matching text color) throughout. dark: variants are absent from this file entirely (unlike billing-credits/ui/subscription-manager's Gotchas, which flags them on its own page), this screen has none to drop.
Responsive: grid md:grid-cols-[1fr_16rem] stacks the aside under the thread below md, the one layout break on the page; message bubbles cap at max-w-[85%] regardless of viewport. Empty state: MessageThread renders "Noch keine Nachrichten." when messages.length === 0 (not given its own top-level state here since every state ships at least one message, a real branch not separately demoed). Error state: error. Loading state: loading.
What the demo deliberately does not render: the source's en (agency) branch of every ternary in the five child components (Stack assumptions, demo.tsx header comment); TicketFieldsCard's inline field editor (canEdit is always false on this route, Application logic step 8); any actual network effect of a write, acceptTicket, changeStatus, postMessage, uploadToTicket, downloadAttachment are all inert here, the textarea and file-field <select> stay real controlled inputs, the modal opens and closes, but nothing a caller passed in ever changes (demo.tsx header comment, the same convention billing-credits/ui/subscription-manager's top-up/cancel buttons and identity-auth/ui/admin-users-console's confirm-gated row actions already establish).
Security & tenancy
What the browser may hold: S2's two tokens, never a payment credential (this domain has none); the fetched ticket (title, thread, fields, attachments metadata) is the tenant's own and stays in memory. serviceDeskService.ts's API_URL (import.meta.env.VITE_API_URL) is the only baked value. Origin and CSRF: the Bearer header, not a cookie, the same as S2. Surface gating: this screen is mounted under LABS_SURFACES the same way billing-credits/ui/subscription-manager's three routes are (app_fastapi.py), so a session with the wrong surface gets refused before any handler runs.
Tenancy: per-ticket, not per-workspace header, the id in the URL is the only scoping token this screen sends; X-Workspace-ID is attached by the service's default header builder (getWorkspaceId()) but not read by any route this screen calls (Data model), the same kind of "attached but ignored" surprise billing-credits/ui/subscription-manager's Gotchas documents for its own three routes, worth checking against the actual backend recipe rather than assumed. Hidden by CSS versus enforced: the requester-side action buttons are gated twice, once by role !== 'viewer' at the page level (line 93), once inside allowedTransitions itself (role === 'viewer' returns [] unconditionally, deskFormat.ts line 320), so even a hand-crafted prop combination that got past the first gate would still render no buttons; the actual write is refused server-side regardless, per this recipe's Invariants. Attacker-controlled input this screen sends: message bodies, status-change reason/message text, uploaded files (server-validated for type and size per the file_type_not_allowed/file_too_large codes in ERROR_TEXT_DE). What renders unescaped: nothing; React escapes every string, no dangerouslySetInnerHTML.
Tests
- Colocated with the demo:
ticket-detail/demo.test.tsx(vitest, jsdom,@testing-library/react,afterEach(cleanup)per the root config'sglobals: false, the same layoutidentity-auth/ui/admin-users-console's test uses): each state'sStatusChiptext matchesSTATUS_DE[status];defaultandwaiting-on-clientrender a different number ofStatusActionsbuttons (zero versus zero, both requester-inactionable statuses, a real assertion worth having precisely because it is easy to assume the opposite);client-reviewrenders exactly two buttons andviewer-role(the same ticket) renders zero;cancelledrenders noMessageComposertextarea, every other non-viewer-rolestate does;donerenders theInfoNotetext, no other state does; no state's markup contains an access- or refresh-token shape. - The kit's render smoke:
node tooling/verify/render-smoke.mjs service-desk/ui/ticket-detailrenders all eight states withrenderToStringandfetchstubbed to reject. - Labs at 62ba4fc has no dedicated unit test of
DeskTicket.tsxor its six child files (grep -rl DeskTicket frontend/src --include='*.test.*'is empty) and no Playwright spec naming/desk/:idspecifically; the status machine and required-field logic these components mirror client-side belongs todeskFormat.test.tsif one exists, not to this recipe. Backend coverage of the six routes this page calls belongs toticket-lifecycle's own suite. - What a new app adds: a jsdom test asserting
allowedTransitionsin the demo and the backend's own transition table agree for every(status, role)pair (a drift here is silent, both sides are hand-maintained); a Playwright spec that opens/desk/:idas a designated deputy (not the original requester) on aclient_reviewticket and asserts the Abnahme button is present, the gapmayDecide's own comment names as a bug that already shipped once.
Deploy & ops
Build target: the Labs Vite bundle (S2), the desk route group. Env baked at build: VITE_API_URL, S2's own. CSP and CORS: nothing of its own; mounted under the SPA's existing allowance, gated by LABS_SURFACES. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the schema the six routes this screen calls read and write (tickets, messages, attachments, status events) belongs to ticket-lifecycle and its siblings. Argus: a synthetic session calling GET /api/desk/tickets/{seeded-id} returning 200 with a status field is this page's liveness; no health signal of its own beyond the fixed error sentence.
Gotchas
- 2026-09-12 (measured at 62ba4fc):
.btn-primary,.btn-ghost,.btn-quiet,.field,.field-smand.labelare real classes this screen's markup depends on (alchemy_labs/frontend/src/index.csslines 240 to 431,@layer components), butpackages/alchemy-ui/src/base.cssin this kit ports only.card,.tnum,.ctx-linkand.chipfrom that same layer, its own header comment names exactly those four. Acompare.mjsrun against this recipe will show every button and text input on the rendered frame in unstyled browser chrome until a docs-host extraction pass adds the other six;render-smoke.mjsstill passes, since it checks that the tree renders, not that a class resolves to a rule (demo.tsx header comment). - 2026-09-12 (measured at 62ba4fc):
role === 'agent'is a real, reachable value ofconfig.my_roleon this exact route, the inline comment atDeskTicket.tsxline 49 says explicitly that an Alchemy agent opening a client's ticket through the portal link still reads German, only the button set changes. This recipe's states all userequesterorviewer; the agent-role button set (Annehmen, Rückfrage stellen, Gelöst & zur Prüfung, and so on, in German, on this same screen) is real source code (allowedTransitions'srole === 'agent'branch, ported faithfully in the demo) but is not given its own demoed state, a genuine gap, not filled with a ninth invented state. - 2026-09-12 (measured at 62ba4fc):
slaState'snowparameter defaults tonew Date(); this screen'sSlaBadgecalls it with no override, so the "noch 3 h 40 min" / "überfällig seit …" text drifts with the wall clock on every real page load. The demo pins a fixed reference timestamp instead (demo.tsxheader comment) for a stable render; a livecompare.mjscapture against the real app will show a different relative-time string than the demo's frame unless the two are captured at the same instant, a timing sensitivity worth flagging in the Acceptance check's tolerance, not a bug in either side. - 2026-09-12 (measured at 62ba4fc):
TicketFieldsCard'sonChangedprop type is(next?: TicketDetail) => void | Promise<void>and its only caller passesasync (next) => (next ? setTicket(next) : await load())(line 165), a file upload through the card's ownFileRowcallsonUploaded()with no argument (TicketFieldsCard.tsxline 236), which takes theawait load()branch, a full refetch, while a save from the (never-mounted-here) edit branch would have taken the cheapersetTicket(next)branch. SincecanEditis alwaysfalseon this route (Application logic step 8), only the refetch branch is ever actually exercised here, noted as measured, not a defect.
Reference implementation
Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root:
frontend/src/pages/desk/DeskTicket.tsx(210 lines): imports (1 to 14), the component and its state (17 to 22),load(24 to 35),role/type/unfilledFileFields(41 to 45), the back-link and error/loading returns (52 to 58),PageHeader(61 to 70),ActingEmailBadge(72 to 78), the status banner (80 to 91),StatusActionsmount (93 to 105), the thread/composer column (107 to 129), the aside (131 to 193),Row(203 to 210).frontend/src/components/desk/MessageThread.tsx(117 lines):MessageThread(10 to 90),AttachmentChip(92 to 117).frontend/src/components/desk/MessageComposer.tsx(156 lines): state andsend(28 to 55),addFile(57 to 71), the JSX (73 to 156).frontend/src/components/desk/StatusActions.tsx(177 lines):run(55 to 76),click(78 to 85),modalTitle(87 to 100), the button row and modal (108 to 176).frontend/src/components/desk/TicketFieldsCard.tsx(238 lines): the non-editing read branch (150 to 195),FileRow(203 to 238); the editing branch (137 to 149) is imported-but-unreachable on this route (Gotchas above notes why it is still ported nowhere in the demo).frontend/src/components/desk/ui.tsx(198 lines):StatusChip(18 to 41),ClassChip(49 to 65),SlaBadge(76 to 88),Modal(135 to 154),ErrorNote/InfoNote/Spinner(157 to 176).frontend/src/components/desk/ActingEmailBadge.tsx(67 lines):ActingEmailBadge(19 to 51),ActingEmailLine(57 to 67).frontend/src/components/desk/surface.tsx:DeskSurfaceProvider/useDeskSurface.frontend/src/components/Navigation.tsx(22 lines): the delegator toAppShell.frontend/src/lib/deskFormat.ts(line ranges cited inline above):statusSentence(112 to 131),fmtZurich(165 to 178),slaState(240 to 258),allowedTransitions(297 to 375),missingFieldLabels/isFieldMissing(426 to 447).frontend/src/services/serviceDeskService.ts:TicketDetail(550 to 566),DeskConfig(367 to 390),getTicket/getDeskConfig/acceptTicket/changeStatus/postMessage/uploadToTicket/downloadAttachment,ERROR_TEXT_DE(59 to 106).- The demo:
modules/service-desk/ui/ticket-detail/demo.tsx; the shell it renders in:modules/ui-design-system/ui/appshell-navigation/demo.tsxandnav.tsx.
Deviations of the reference from this recipe, all dated 2026-09-12: the six missing button/field classes in the kit's own CSS bundle; the agent-role button set real but undemoed; slaState's wall-clock-relative text pinned to a fixed reference instead; every write inert.
Acceptance checks
From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, a tenant session), or from a new app built to this recipe:
grep -n "allowedTransitions(" src/components/desk/StatusActions.tsxprints one call site (line 46), matching this recipe's single mirrored copy indeskFormat.ts.grep -c "surface=\"portal\"\|surface: 'portal'" src/pages/desk/DeskTicket.tsxis nonzero on every child mount this page makes, confirming the surface is hard-pinned at this route (the demo's own simplification, Frontend).- Browser walk-through, signed in as the tenant who raised a
client_reviewticket: (a) open/desk/:id, observe the shell paint first and a single spinner where the ticket will be, then the header, the purple-to-warn banner swap and the review-window sentence; (b) click "Nachbesserung anfordern", type a message, confirm, observe the modal close and (once a real backend is wired) the status flip toin_progress; (c) sign in as a workspace member who is neither the requester nor designated, open the same ticket, confirm no Abnahme/Nachbesserung buttons render (mayDecidefalse); (d) sign in as aviewer-role account on the same ticket, confirm noStatusActionsblock, no composer, and the attachments card shows a plain chip with no upload control. - Demo gate:
node tooling/verify/render-smoke.mjs service-desk/ui/ticket-detailrenders all eight states, 0 failures. - Verifier:
node tooling/verify/compare.mjs service-desk/ui/ticket-detail --state client-review --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json(a tenant session with a seededclient_reviewticket) writes a report withpage_errors: [];mismatch_ratiois read for the header, banner and action-button row only, excluded: the SLA badge's relative-time text (Gotchas, the demo pins a fixed reference timestamp) and every unstyled control named in the first Gotchas entry, until the missing classes are added to the kit's CSS bundle.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.