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
+1
View File
@@ -38,6 +38,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
| Pre-diagnostik | ✅ Ingen felsökning förrän grundkontrollerna är gjorda eller motiverade: fordonshistorik — tidigare ärenden på samma fordon hämtas automatiskt med sina felorsaker (server i inloggat läge, lokala storen annars) och orsakskedjan kopplas med ett tryck; Ja/Nej med obligatorisk orsak → kvalitetsvarning, **ingående mätarställning** (foto av instrumentpanelen, bildtolkningen föreslår värdet), kundens felbeskrivning verifierad och tidiga observationer hanterade. Metodiken låses upp först därefter. **Utgående mätarställning** fotograferas inför avslut och blir obligatorisk i grinden när ärendet stängs. |
| Symptomverifiering (SVP) | ✅ Kundens beskrivning ≠ konstaterat fel: beskrivningen dokumenteras ordagrant, förtydligas via metodikens symptomfrågor (generiska metodiken har SVP-setet när/var/hur) och reproduceras — Ja (hur/förhållanden), Delvis (vad kunde/kunde inte) eller Nej (obligatorisk motivering). Rapportens beviskedja skiljer kundens beskrivning, verifierad observation, felorsaksanalys och rekommenderad åtgärd; formuleringen "kunde inte reproduceras under de förhållanden som rådde" används i stället för "felet konstaterat" (kodat även i orkesterns grundprompt). |
| Felorsaksanalys | ✅ Obligatorisk före avslut: konstaterad avvikelse (kvalitetsregeln avvisar "trasig/defekt/sliten" utan förklaring), orsakskategorier (inkl. Okänd orsak med krav på motivering), minst en evidenskälla som valideras mot loggen, säkerhetsnivå (medel/låg kräver stärkande kontroller) och rekommenderad åtgärd. Avslutsknappen spärrad tills SVP + felorsak finns; kvalitetsgrinden gör båda obligatoriska vid stängning. Eget avsnitt i slutrapporten. |
| Kundgodkännande | ✅ Åtgärdsförslag lämnas till kund innan arbetet påbörjas (förifyllt ur felorsaksanalysen, med uppskattad kostnad) och **visas i Live Share** — kunden ser vad som föreslås. Kundens besked registreras med utfall, kanal (telefon/på plats/e-post/SMS/delningslänk) och motivering vid avböjt. Åtgärdsknappen är låst tills beskedet finns och förblir låst vid avböjt; kvalitetsgrinden flaggar hårt om arbete utförts trots avböjt förslag. Publikt godkännande direkt i delningslänken är en medveten avgränsning. |
| Åtgärd och kvalitetskontroll | ✅ Arbetsflödets sista led: åtgärden dokumenteras (vad som gjordes + delar) eller motiveras varför den uteblev (kunden avböjde, väntar på reservdel …). Har en åtgärd utförts krävs **kvalitetskontroll** — symptomet borta / kvarstår / delvis / kunde inte verifieras, med beskrivning av hur det verifierades under samma förhållanden som symptomet reproducerades. Kvarstående symptom flaggas i grinden i stället för att döljas. Avslutsknappen är spärrad tills hela kedjan symptomverifiering → felorsak → åtgärd → kvalitetskontroll är komplett; rapporten har ett eget avsnitt ”Utförd åtgärd och verifiering”. |
| Ärendeidentitet | ✅ Fordonsobjektet som röd tråd: identiteten (AO-nummer, claim-/garantinummer, skadenummer, regnr, VIN, miltal, kund) registreras en gång — normalt via arbetsorderskanningen — och återanvänds i identitetsraden i arbetsytan (med ärendetypsval), låst panel överst i Live Share, slutrapportens första sida (Ärendeinformation + Fordonsinformation) och exporten. |
| Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. |
+24
View File
@@ -76,6 +76,7 @@ alla obligatoriska rader är gröna:
| Fordonshistorik kontrollerad eller motiverad | Obligatorisk |
| Ingående mätarställning dokumenterad | Obligatorisk |
| Kundens felbeskrivning verifierad | Rekommenderas |
| Kundens besked på åtgärdsförslaget | Obligatoriskt när arbete utförts |
| Åtgärd dokumenterad eller motiverad | Obligatorisk vid avslut |
| Kvalitetskontroll genomförd | Obligatorisk vid avslut efter utförd åtgärd |
| Utgående mätarställning | Obligatorisk vid avslut |
@@ -161,6 +162,29 @@ Flottdatan är redan igång: **felorsaksstatistiken** i arbetsledarvyn
organisationen — vilka komponenter fallerar av slitage, vilka efter
tidigare reparationer, vilka tyder på konstruktionsproblem.
## Kundgodkännande före arbete
Verkstaden får aldrig utföra föreslaget arbete utan att kundens besked är
registrerat och spårbart:
- **Åtgärdsförslaget** skrivs i guiden (förifyllt ur felorsaksanalysens
rekommenderade åtgärd) med eventuell uppskattad kostnad, och **visas
för kunden i Live Share** — det är kunddelbart material.
- **Kundens besked** registreras med utfall (godkänt/avböjt/delvis),
**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 och när.
- **Knappen "Dokumentera utförd åtgärd" är låst** så länge ett förslag
saknar besked — och förblir låst vid avböjt besked. Vägen "Ingen
åtgärd utförd" är öppen och hänvisar till det registrerade beskedet.
- Kvalitetsgrinden kräver registrerat besked när arbete utförts, och
flaggar konflikten *"Utfört arbete trots avböjt åtgärdsförslag"* som
ett hårt fel.
Avgränsning: kunden godkänner i dagsläget genom kontakt med verkstaden —
plattformen registrerar beskedet. Publikt godkännande direkt i
delningslänken kräver en skrivande publik endpoint och hanteras separat.
## Åtgärdsfasen (Repair & Verification)
Loopen som symptomverifieringen öppnade sluts här — ett ärende kan inte
+2
View File
@@ -654,6 +654,8 @@ components:
- historik_kontrollerad
- matarstallning
- video
- atgardsforslag
- kundbeslut
- atgard_utford
- kvalitetskontroll
- reproducering
+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) => {