Kundgodkännande före arbete — förslag, besked och spärrat arbete

Verkstaden kan inte längre utföra föreslaget arbete utan spårbart besked
från kunden.

- Ny händelse atgardsforslag: föreslagen åtgärd (förifylld ur
  felorsaksanalysen) med uppskattad kostnad — kunddelbar och visad i
  Live Share, där kunden ser förslaget och sitt registrerade besked
- Ny händelse kundbeslut: godkänt/avböjt/delvis med obligatorisk kanal
  (telefon, på plats, e-post, SMS, delningslänk) och motivering vid
  avböjt/delvis; loggposten bär vem i verkstaden som tog emot beskedet
- "Dokumentera utförd åtgärd" är låst medan ett förslag saknar besked
  och förblir låst vid avböjt — vägen "Ingen åtgärd utförd" är öppen
  och hänvisar till beskedet
- Kvalitetsgrinden: besked obligatoriskt när arbete utförts, plus hård
  flagga "Utfört arbete trots avböjt åtgärdsförslag"
- Rapporten får avsnittet "Åtgärdsförslag och kundens besked";
  demoärendet visar hela kedjan förslag → godkännande → åtgärd

Avgränsning: kunden lämnar sitt besked via kontakt med verkstaden som
registrerar det. Publikt godkännande direkt i delningslänken kräver en
skrivande publik endpoint och hanteras separat.

69 vitest-tester, integrationstest, OpenAPI-validering och klicktest
(blockerat arbete utan/vid avböjt besked) gröna.

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 10:34:44 +00:00
parent 636df0559d
commit 5428684cca
9 changed files with 375 additions and 3 deletions
+32 -1
View File
@@ -5,7 +5,7 @@
// bort även lokalt.
import type { Arende } from "./domain";
import { handelseRubrik } 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";
@@ -59,6 +59,10 @@ export function DelatArendeVy({
const avslutat = arAvslutat(arende);
const bilder = foton(arende);
const klipp = videor(arende);
// Åtgärdsförslaget är avsett för kunden — det visas alltid överst i
// delningsvyn, tillsammans med det besked som registrerats.
const forslag = arende.handelser.filter((p) => p.handelse.typ === "atgardsforslag");
const beslut = [...arende.handelser].reverse().find((p) => p.handelse.typ === "kundbeslut");
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
const kundposter = redanFiltrerad
? arende.handelser
@@ -93,6 +97,33 @@ export function DelatArendeVy({
</Panel>
)}
{forslag.length > 0 && (
<Panel rubrik="Åtgärdsförslag">
{forslag.map((p) => {
const h = p.handelse;
if (h.typ !== "atgardsforslag") return null;
return (
<div key={p.id} className="mb-2 last:mb-0">
<p className="text-[14px]">{h.beskrivning}</p>
{h.uppskattadKostnad && (
<p className="text-[13px] text-[#4A5560]">Uppskattad kostnad: {h.uppskattadKostnad}</p>
)}
</div>
);
})}
{beslut?.handelse.typ === "kundbeslut" ? (
<p className="mt-2 border-t border-[#DDDDDD] pt-2 text-[13px] font-semibold">
Ditt besked: {KUNDBESLUT_LABEL[beslut.handelse.beslut]}{" "}
<span className="font-normal text-[#4A5560]">(registrerat via {beslut.handelse.kanal})</span>
</p>
) : (
<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.
</p>
)}
</Panel>
)}
<Panel rubrik="Aktuell status">
{b.utfordaKontroller.map((k, i) => (
<p key={`u${i}`} className="py-0.5 text-[14px]"><span className="text-[#1E6B34]"><IkonCheck /></span> {k.text}</p>
+56
View File
@@ -445,3 +445,59 @@ describe("Åtgärdsfasen och kvalitetskontroll", () => {
}
});
});
describe("Kundgodkännande före arbete", () => {
const FORSLAG: Handelse = {
typ: "atgardsforslag",
beskrivning: "Balansera om höger framhjul och genomför ny provkörning.",
uppskattadKostnad: "890 kr",
};
const ATGARD: Handelse = { typ: "atgard_utford", beskrivning: "Balanserade om höger framhjul.", utford: true };
it("lämnat förslag kräver registrerat besked innan arbetet utförs", async () => {
const { atgardsforslag, kundbeslut } = await import("../ecm");
const utanBesked = byggArende([OBJEKT, ...PREDIAG, FORSLAG, ATGARD]);
expect(atgardsforslag(utanBesked)).toHaveLength(1);
expect(kundbeslut(utanBesked)).toBeUndefined();
const rad = kvalitetsgrind(utanBesked, VIBRATION_METODIK).find((r) => r.id === "kundgodkannande");
expect(rad?.kravs).toBe(true);
expect(rad?.ok).toBe(false);
expect(rad?.detalj).toContain("registrera kundens besked");
const medBesked = byggArende([
OBJEKT,
...PREDIAG,
FORSLAG,
{ typ: "kundbeslut", beslut: "godkant", kanal: "Telefon" },
ATGARD,
]);
const rad2 = kvalitetsgrind(medBesked, VIBRATION_METODIK).find((r) => r.id === "kundgodkannande");
expect(rad2?.ok).toBe(true);
expect(rad2?.detalj).toContain("Telefon");
});
it("utfört arbete trots avböjt förslag flaggas som konflikt", () => {
const konflikt = byggArende([
OBJEKT,
...PREDIAG,
FORSLAG,
{ typ: "kundbeslut", beslut: "avbojt", kanal: "Telefon", kommentar: "Kunden vill vänta." },
ATGARD,
]);
const rad = kvalitetsgrind(konflikt, VIBRATION_METODIK).find((r) => r.id === "godkannande_konflikt");
expect(rad?.ok).toBe(false);
expect(rad?.kravs).toBe(true);
});
it("utan lämnat förslag ställs inget godkännandekrav", () => {
const utanForslag = byggArende([OBJEKT, ...PREDIAG, ATGARD]);
expect(kvalitetsgrind(utanForslag, VIBRATION_METODIK).some((r) => r.id === "kundgodkannande")).toBe(false);
});
it("demoärendet visar hela kundkedjan: förslag → godkännande → åtgärd", () => {
const demo = byggDemoArende(1);
const typer = demo.handelser.map((p) => p.handelse.typ);
expect(typer.indexOf("atgardsforslag")).toBeLessThan(typer.indexOf("kundbeslut"));
expect(typer.indexOf("kundbeslut")).toBeLessThan(typer.indexOf("atgard_utford"));
});
});
+13
View File
@@ -130,6 +130,19 @@ export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
nastaSteg: "Balansera om höger framhjul och genomför ny provkörning i 80100 km/h.",
modell: "demo",
}),
post(86, {
typ: "atgardsforslag",
beskrivning: "Balansera om höger framhjul och genomför ny provkörning i 80100 km/h.",
uppskattadKostnad: "890 kr inkl. moms",
}),
post(87, { typ: "kategori_byte", kategori: "kundkontakt" }),
post(87, {
typ: "kundbeslut",
beslut: "godkant",
kanal: "Telefon",
kommentar: "Kunden godkände ombalansering.",
}),
post(87, { typ: "kategori_byte", kategori: "aktiv_felsokning" }),
post(87, {
typ: "atgard_utford",
beskrivning: "Balanserade om höger framhjul och monterade ny balansvikt på fälgens insida.",
+27
View File
@@ -29,6 +29,13 @@ export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
paus: "Paus",
};
// Kundens besked på ett lämnat åtgärdsförslag.
export const KUNDBESLUT_LABEL: Record<"godkant" | "avbojt" | "delvis", string> = {
godkant: "Godkänt",
avbojt: "Avböjt",
delvis: "Delvis godkänt",
};
// Kvalitetskontrollens utfall efter utförd åtgärd.
export const KVALITETSKONTROLL_LABEL: Record<
"symptomet_borta" | "kvarstar" | "delvis" | "ej_verifierbar",
@@ -112,6 +119,22 @@ export type Handelse =
motivering?: string;
ytterligareKontroller?: string;
}
// Kundkommunikation: åtgärdsförslag lämnat till kund för godkännande
// innan arbetet påbörjas. Kunddelbart — visas i Live Share.
| {
typ: "atgardsforslag";
beskrivning: string;
uppskattadKostnad?: string;
uppskattadTid?: string;
}
// Kundens besked på förslaget, med kanal och tidpunkt (loggposten bär
// vem i verkstaden som tog emot beskedet).
| {
typ: "kundbeslut";
beslut: "godkant" | "avbojt" | "delvis";
kanal: string;
kommentar?: string;
}
// Åtgärdsfasen: vad som faktiskt gjordes — eller varför ingen åtgärd
// utfördes (kunden avböjde, väntar på delar …). Kopplas till den
// felorsak åtgärden svarar mot.
@@ -222,6 +245,10 @@ export function handelseRubrik(post: LoggPost): string {
: `Symptomet kunde inte reproduceras — ${h.beskrivning}`;
case "felorsak":
return `Felorsak (${TILLFORLITLIGHET_LABEL[h.sakerhet]}): ${h.avvikelse}${h.orsaker.join(", ")}`;
case "atgardsforslag":
return `Åtgärdsförslag till kund: ${h.beskrivning}${h.uppskattadKostnad ? ` — uppskattad kostnad ${h.uppskattadKostnad}` : ""}`;
case "kundbeslut":
return `Kundens besked (${h.kanal}): ${KUNDBESLUT_LABEL[h.beslut]}${h.kommentar ? `${h.kommentar}` : ""}`;
case "atgard_utford":
return h.utford
? `Åtgärd utförd: ${h.beskrivning}${h.delar ? ` (delar: ${h.delar})` : ""}`
+41
View File
@@ -209,6 +209,22 @@ export const INGEN_ATGARD_ORSAKER = [
"Kostnadsförslag lämnat, inväntar besked",
];
// Kanaler för kundens besked — beskedet ska alltid gå att härleda till
// ett faktiskt samtal, besök eller meddelande.
export const KUNDKANALER = ["Telefon", "På plats", "E-post", "SMS", "Delningslänk"];
export function atgardsforslag(arende: Arende) {
return arende.handelser.filter((p) => p.handelse.typ === "atgardsforslag");
}
export function kundbeslut(arende: Arende) {
let senaste: { beslut: "godkant" | "avbojt" | "delvis"; kanal: string; kommentar?: string } | undefined;
for (const post of arende.handelser) {
if (post.handelse.typ === "kundbeslut") senaste = post.handelse;
}
return senaste;
}
export function atgarder(arende: Arende) {
return arende.handelser.filter((p) => p.handelse.typ === "atgard_utford");
}
@@ -568,6 +584,31 @@ export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
: "Ingen åtgärd utförd; orsaken är dokumenterad.",
});
// Kundgodkännande: har ett åtgärdsförslag lämnats ska kundens besked
// vara registrerat innan arbetet utförs — arbete utan besked flaggas.
const forslag = atgardsforslag(arende);
const beslut = kundbeslut(arende);
if (forslag.length > 0 || beslut) {
rader.push({
id: "kundgodkannande",
rubrik: "Kundens besked på åtgärdsförslaget registrerat",
ok: !!beslut,
kravs: utfordAtgard,
detalj: beslut
? `${beslut.beslut === "godkant" ? "Godkänt" : beslut.beslut === "avbojt" ? "Avböjt" : "Delvis godkänt"} via ${beslut.kanal}.`
: "Åtgärdsförslag lämnat — registrera kundens besked innan arbetet utförs.",
});
if (beslut?.beslut === "avbojt" && utfordAtgard) {
rader.push({
id: "godkannande_konflikt",
rubrik: "Utfört arbete trots avböjt åtgärdsförslag",
ok: false,
kravs: true,
detalj: "Kunden avböjde åtgärden men arbete är dokumenterat som utfört — kontrollera underlaget.",
});
}
}
// Kvalitetskontroll: är symptomet borta? Krävs när en åtgärd faktiskt
// utförts — annars finns inget att verifiera.
const kk = kvalitetskontroll(arende);
+179 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Arende, Handelse, TidKategori, Tillforlitlighet } from "@/felsokning/domain";
import { KVALITETSKONTROLL_LABEL, TIDKATEGORI_LABEL, TILLFORLITLIGHET_LABEL, handelseRubrik } from "@/felsokning/domain";
import { KUNDBESLUT_LABEL, KVALITETSKONTROLL_LABEL, TIDKATEGORI_LABEL, TILLFORLITLIGHET_LABEL, handelseRubrik } from "@/felsokning/domain";
import type { Metodik, NastaSteg } from "@/felsokning/metodik";
import { nastaSteg } from "@/felsokning/metodik";
import {
@@ -45,15 +45,18 @@ import {
MARKOR_FELBESKRIVNING_VERIFIERAD,
MARKOR_INGA_TIDIGA_OBSERVATIONER,
INGEN_ATGARD_ORSAKER,
KUNDKANALER,
MARKOR_TIDIGA_OBSERVATIONER_KLARA,
aktivtRegelpaket,
atgarder,
atgardsforslag,
arendetyp,
felorsaker,
granskaAvvikelse,
grindGodkand,
kvalitetsgrind,
hamtaRegelpaket,
kundbeslut,
kvalitetskontroll,
preDiagnostik,
reproducering,
@@ -715,6 +718,19 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
const kk = kvalitetskontroll(arende);
const repro = reproducering(arende);
const forslag = atgardsforslag(arende);
const beslut = kundbeslut(arende);
const senasteFelorsak = felorsaker(arende).at(-1);
const foreslagenAtgard =
senasteFelorsak?.handelse.typ === "felorsak" ? senasteFelorsak.handelse.atgard : "";
const [forslagsLage, setForslagsLage] = useState(false);
const [forslagText, setForslagText] = useState("");
const [kostnad, setKostnad] = useState("");
const [beslutsVal, setBeslutsVal] = useState<"" | "godkant" | "avbojt" | "delvis">("");
const [kanal, setKanal] = useState("");
const [beslutsKommentar, setBeslutsKommentar] = useState("");
const [lage, setLage] = useState<"" | "utford" | "ingen">("");
const [beskrivning, setBeskrivning] = useState("");
const [delar, setDelar] = useState("");
@@ -746,9 +762,140 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
</p>
)}
{/* Kundkommunikation: förslaget lämnas för godkännande innan
arbetet påbörjas, och beskedet registreras med kanal. */}
{forslag.map((p) => {
const h = p.handelse;
if (h.typ !== "atgardsforslag") return null;
return (
<p key={p.id} className="border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
<span className="font-semibold">Åtgärdsförslag till kund:</span> {h.beskrivning}
{h.uppskattadKostnad && <span className="text-[#4A5560]"> · Uppskattad kostnad: {h.uppskattadKostnad}</span>}
</p>
);
})}
{beslut && (
<p className={`py-1 text-[13px] font-semibold ${beslut.beslut === "godkant" ? "text-[#1E6B34]" : beslut.beslut === "avbojt" ? "text-[#8B1A1A]" : "text-[#9A6700]"}`}>
Kundens besked ({beslut.kanal}): {KUNDBESLUT_LABEL[beslut.beslut]}
{beslut.kommentar && <span className="font-normal"> {beslut.kommentar}</span>}
</p>
)}
{forslag.length === 0 && !forslagsLage && poster.length === 0 && (
<StorKnapp
variant="sekundar"
className="mb-2"
onClick={() => {
setForslagsLage(true);
setForslagText(foreslagenAtgard);
}}
>
Lämna åtgärdsförslag till kund
</StorKnapp>
)}
{forslagsLage && (
<div className="mb-3 rounded border border-[#C6C6C6] bg-white p-2">
<TextFalt
label="Föreslagen åtgärd (visas för kunden i delningsvyn)"
varde={forslagText}
satt={setForslagText}
flerRad
rost
platshallare="T.ex. Balansera om höger framhjul och genomför ny provkörning."
/>
<TextFalt label="Uppskattad kostnad (valfritt)" varde={kostnad} satt={setKostnad} platshallare="T.ex. 1 200 kr inkl. moms" />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setForslagsLage(false)}>
Avbryt
</StorKnapp>
<StorKnapp
disabled={forslagText.trim().length < 10}
onClick={() => {
skicka({
typ: "atgardsforslag",
beskrivning: forslagText.trim(),
uppskattadKostnad: kostnad.trim() || undefined,
});
setForslagsLage(false);
setForslagText("");
setKostnad("");
}}
>
Lämna förslag
</StorKnapp>
</div>
</div>
)}
{forslag.length > 0 && !beslut && (
<div className="mb-3 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#9A6700]">
Registrera kundens besked innan arbetet påbörjas
</p>
<div className="mb-2 grid grid-cols-3 gap-2">
{(["godkant", "avbojt", "delvis"] as const).map((val) => (
<button
key={val}
onClick={() => setBeslutsVal(val)}
className={`min-h-9 rounded border text-[12px] font-semibold ${
beslutsVal === val ? "border-[#00437A] bg-[#00437A] text-white" : "border-[#ADADAD] bg-white text-[#333333]"
}`}
>
{KUNDBESLUT_LABEL[val]}
</button>
))}
</div>
{beslutsVal && (
<>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">Hur lämnades beskedet?</p>
<div className="mb-2 grid grid-cols-3 gap-1">
{KUNDKANALER.map((k) => (
<button
key={k}
onClick={() => setKanal(k)}
className={`min-h-8 rounded border px-1 text-[12px] font-medium ${
kanal === k ? "border-[#00437A] bg-[#D6E4F2] text-[#00437A]" : "border-[#C6C6C6] bg-white text-[#333333]"
}`}
>
{k}
</button>
))}
</div>
<TextFalt
label={beslutsVal === "godkant" ? "Kommentar (valfritt)" : "Kundens motivering (obligatorisk)"}
varde={beslutsKommentar}
satt={setBeslutsKommentar}
rost
/>
<StorKnapp
disabled={!kanal || (beslutsVal !== "godkant" && !beslutsKommentar.trim())}
onClick={() => {
skicka({
typ: "kundbeslut",
beslut: beslutsVal,
kanal,
kommentar: beslutsKommentar.trim() || undefined,
});
setBeslutsVal("");
setKanal("");
setBeslutsKommentar("");
}}
>
Registrera besked
</StorKnapp>
</>
)}
</div>
)}
{poster.length === 0 && !lage && (
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("utford")}>
<StorKnapp
variant="sekundar"
disabled={forslag.length > 0 && (!beslut || beslut.beslut === "avbojt")}
onClick={() => setLage("utford")}
>
Dokumentera utförd åtgärd
</StorKnapp>
<StorKnapp variant="sekundar" onClick={() => setLage("ingen")}>
@@ -806,6 +953,11 @@ function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse
))}
</div>
<TextFalt label="Eller egen orsak" varde={orsak && !INGEN_ATGARD_ORSAKER.includes(orsak) ? orsak : ""} satt={setOrsak} rost />
{beslut?.beslut === "avbojt" && (
<p className="mb-2 text-[11px] text-[#707070]">
Kunden har avböjt åtgärden via {beslut.kanal} beskedet finns registrerat i loggen.
</p>
)}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("")}>
Avbryt
@@ -2005,6 +2157,8 @@ function RapportFlik({
const orsakerLista = felorsaker(arende);
const atgardsRader = atgarder(arende);
const kkRad = kvalitetskontroll(arende);
const forslagRader = atgardsforslag(arende);
const beslutRad = kundbeslut(arende);
let matIn: string | undefined;
let matUt: string | undefined;
for (const p of arende.handelser) {
@@ -2161,6 +2315,29 @@ function RapportFlik({
);
})}
</Panel>
{(forslagRader.length > 0 || beslutRad) && (
<Panel rubrik="Åtgärdsförslag och kundens besked">
{forslagRader.map((p) => {
const h = p.handelse;
if (h.typ !== "atgardsforslag") return null;
return (
<div key={p.id}>
{identitetsRader([
["Föreslagen åtgärd", h.beskrivning],
["Uppskattad kostnad", h.uppskattadKostnad],
["Uppskattad tid", h.uppskattadTid],
])}
</div>
);
})}
{beslutRad &&
identitetsRader([
["Kundens besked", KUNDBESLUT_LABEL[beslutRad.beslut]],
["Lämnat via", beslutRad.kanal],
["Kommentar", beslutRad.kommentar],
])}
</Panel>
)}
<Panel rubrik="Utförd åtgärd och verifiering">
{atgardsRader.length === 0 && <p className="text-[14px] text-[#4A5560]">Ingen åtgärd dokumenterad ännu.</p>}
{atgardsRader.map((p) => {