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:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -654,6 +654,8 @@ components:
|
||||
- historik_kontrollerad
|
||||
- matarstallning
|
||||
- video
|
||||
- atgardsforslag
|
||||
- kundbeslut
|
||||
- atgard_utford
|
||||
- kvalitetskontroll
|
||||
- reproducering
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 80–100 km/h.",
|
||||
modell: "demo",
|
||||
}),
|
||||
post(86, {
|
||||
typ: "atgardsforslag",
|
||||
beskrivning: "Balansera om höger framhjul och genomför ny provkörning i 80–100 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.",
|
||||
|
||||
@@ -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})` : ""}`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user