Publikt kundgodkännande i delningslänken

Kunden kan nu godkänna eller avböja åtgärdsförslaget direkt i sin
Live Share-länk — API:ts enda skrivande publika väg, med sex spärrar
som var och en verifieras i integrationstestet:

1. endast delningar på kundnivå (partner-/internlänkar får aldrig svara
   åt kunden) och aldrig återkallade
2. ärendets ursprungliga delningskod saknar registrerad nivå och kan
   inte heller svara
3. det måste finnas ett åtgärdsförslag att svara på
4. ett besked per ärende — svaret kan inte ändras i efterhand
5. endast godkant/avbojt/delvis plus kommentar på högst 500 tecken;
   inget annat kan skrivas till loggen den vägen
6. takt-begränsning per delningskod (429)

Beskedet loggas som kundbeslut med kanal "Delningslänk" och avsändaren
"Kund via delningslänk"; verkstadens egna registreringar (telefon, på
plats …) fungerar precis som förut, och kvalitetsgrindens krav gäller
oförändrat. Beslutspanelen visas bara på den publika kundlänken —
interna och partnervyer förblir helt skrivskyddade.

73 vitest-tester, integrationstest (åtta nya kontroller för spärrarna)
och OpenAPI-validering gröna. Därmed är även den sista dokumenterade
avgränsningen utöver externa fordonsdatabaser avklarad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 12:25:14 +00:00
parent e22e0262e4
commit 64b2124cd8
8 changed files with 295 additions and 6 deletions
+68 -1
View File
@@ -4,14 +4,75 @@
// hypoteser) ingår aldrig i underlaget som når hit externt, och filtreras
// bort även lokalt.
import { useState } from "react";
import type { Arende } from "./domain";
import { KUNDBESLUT_LABEL, handelseRubrik } from "./domain";
import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader, videor } from "./projektioner";
import { metodikForArende } from "./store";
import { tidDatum, tidKlockslag } from "./format";
import { FelsokningSkal, Panel } from "./ui";
import { FelsokningSkal, Panel, StorKnapp } from "./ui";
import { IkonCheck, IkonKlocka, IkonUppdatera } from "./ikoner";
// Kundens svar på åtgärdsförslaget — den enda skrivande åtgärden i hela
// delningsvyn. Beskedet kan lämnas en gång och kan inte ändras här.
function BeslutsKnappar({
vidBeslut,
}: {
vidBeslut: (beslut: "godkant" | "avbojt", kommentar: string) => Promise<string | null>;
}) {
const [val, setVal] = useState<"" | "godkant" | "avbojt">("");
const [kommentar, setKommentar] = useState("");
const [skickar, setSkickar] = useState(false);
const [fel, setFel] = useState("");
return (
<div className="mt-2 border-t border-[#DDDDDD] pt-2 print:hidden">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Ditt besked till verkstaden
</p>
<div className="mb-2 grid grid-cols-2 gap-2">
<StorKnapp variant={val === "godkant" ? "primar" : "sekundar"} onClick={() => setVal("godkant")}>
Godkänn åtgärden
</StorKnapp>
<StorKnapp variant={val === "avbojt" ? "fara" : "sekundar"} onClick={() => setVal("avbojt")}>
Avböj
</StorKnapp>
</div>
{val && (
<>
<label className="mb-2 block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
{val === "avbojt" ? "Kort motivering (valfritt)" : "Kommentar (valfritt)"}
</span>
<input
value={kommentar}
maxLength={500}
onChange={(e) => setKommentar(e.target.value)}
className="w-full rounded border border-[#ADADAD] bg-white px-2.5 py-1.5 text-[13px] focus:border-[#00437A] focus:outline-none"
/>
</label>
{fel && <p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">{fel}</p>}
<StorKnapp
disabled={skickar}
onClick={async () => {
setSkickar(true);
setFel("");
const felmeddelande = await vidBeslut(val, kommentar);
if (felmeddelande) setFel(felmeddelande);
setSkickar(false);
}}
>
{skickar ? "Skickar …" : "Skicka besked"}
</StorKnapp>
<p className="mt-1 text-[11px] text-[#707070]">
Beskedet registreras i ärendet och kan inte ändras här kontakta verkstaden om du ändrar dig.
</p>
</>
)}
</div>
);
}
function IdentitetsPanel({ arende, avslutat }: { arende: Arende; avslutat: boolean }) {
const idn = arendeidentitet(arende);
const falt: [string, string | undefined][] = [
@@ -45,10 +106,14 @@ export function DelatArendeVy({
nu,
notis,
redanFiltrerad = false,
vidBeslut,
}: {
arende: Arende;
nu: string;
notis: string;
// Satt endast på den publika kundlänken: kunden kan svara på ett
// åtgärdsförslag. Utan den är vyn helt skrivskyddad.
vidBeslut?: (beslut: "godkant" | "avbojt", kommentar: string) => Promise<string | null>;
// Sant när servern redan filtrerat per behörighetsnivå (publik delning) —
// då renderas händelserna som de kom, inklusive t.ex. hypoteser på
// partner-/internnivå.
@@ -116,6 +181,8 @@ export function DelatArendeVy({
Ditt besked: {KUNDBESLUT_LABEL[beslut.handelse.beslut]}{" "}
<span className="font-normal text-[#4A5560]">(registrerat via {beslut.handelse.kanal})</span>
</p>
) : vidBeslut ? (
<BeslutsKnappar vidBeslut={vidBeslut} />
) : (
<p className="mt-2 border-t border-[#DDDDDD] pt-2 text-[12px] text-[#707070]">
Inväntar ditt besked kontakta verkstaden för att godkänna eller avböja.