Garantiregimer, försäkringsvillkor och felanmälan i ärendet

---- Garantier (ALVA-SPEC-040) -----------------------------------------

Skiljelinjen mellan LAGSTADGAT och AVTALAT bär allt annat, och den
förväxlas dagligen åt bägge håll. Överraskande lite av det verkstaden
kallar garanti är lagstadgat:

  nybilsgaranti      avtalad. Ingen lag i EU eller USA kräver den.
  rostskyddsgaranti  avtalad. Ingen lag kräver den, någonstans.
  lackgaranti        avtalad.
  drivlinegaranti    avtalad.
  avgasgaranti       LAGSTADGAD i bägge, och den enda som är det.

Att kalla rostskyddet lagstadgat är fel; att kalla avgasgarantin en
tillverkarutfästelse är fel åt andra hållet, och det andra felet kostar
kunden pengar — en katalysator på en sexårig bil offereras rutinmässigt
som en betald reparation när den inte är det.

Varje lagstadgad post bär källa och kontrolldatum: 2019/771 (2 år,
säljaren — inte tillverkaren), MVBER 461/2010 förlängd till 2028,
715/2007 (5 år/100 000 km), 2024/1257 (8 år/160 000 km, batteri 80 %
vid 5 år och 72 % vid 8), CAA § 207 (2/24 000 och 8/80 000 miles),
Magnuson–Moss § 2302(c), CARB (3/50 000 och 7/70 000), ACC II (70 % SoH
vid 8 år/100 000 miles).

Bedömningen skiljer INOM, UTANFÖR och OKLART. Saknas underlag blir det
oklart, aldrig utanför — att påstå att en garanti gått ut när underlaget
inte räcker är det enda felet här som kostar kunden pengar.

---- Försäkring (ALVA-SPEC-041) ----------------------------------------

Modulen avgör ingenting och kan inte avgöra något. Vad som gäller
bestäms av kundens eget försäkringsbrev och villkoren vid tecknandet —
inte av vad ett bolag skriver i dag och inte av vad vi noterat. Testerna
låser att resultatet inte innehåller något fält som liknar ett utfall,
och att ett fordon utanför samtliga noterade gränser ändå inte får ett
nej: registret kan vara ofullständigt och produkten en annan.

Det som står överst är de sju villkoren som avgör en maskinskadefråga.
Ålder och sträcka är två av dem; de fem andra avgör oftare. Registret
med If, Folksam, Trygg-Hansa, Svedea, WaterCircles och Hedvig ligger
under, med källa och avläsningsdatum per post, och märks som föråldrat
efter 180 dagar i stället för att se aktuellt ut.

---- Felanmälan i ärendet (ALVA-PROC-0050) -----------------------------

En anmälningsknapp som bara finns i en portalvy nås aldrig av den som
står med händerna i en bil. Den ligger därför i varje ärende, och tar
med sitt sammanhang självt: ärende, metodik, plattformsversion och
spår-id. Teknikern minns inte plattformsversionen, och att fråga efter
den är att lägga över vårt problem på verkstaden.

Sammanhanget HÄRLEDS och kan inte smugglas: ett regnr i anropet släpps
inte igenom. En supportanmälan är inte ett skäl att flytta
personuppgifter till ett annat system. Bevisat i integrationstestet.

Anmälan är oföränderlig och statusen en projektion av inläggen — samma
skäl som för fakturan: en anmälan vars historia kan skrivas om är inte
ett underlag när någon frågar hur länge felet var känt. Verkstaden
svarar i sitt eget ärende; status sätts av supporten med egen nyckel.

---- Historikkontrollen -------------------------------------------------

Kravet fanns redan: klienten öppnar inte metodiken förrän
pre-diagnostiken är besvarad, och ett nej kräver skriven motivering.
Det var dock aldrig BEVISAT att spärren håller. Genomgången prövar nu
att metodiken inte går att nå medan historikfrågan står obesvarad — i
det enda ögonblick det går att pröva, eftersom svaret därefter redan är
avgivet.

429 enhetstester · 157 integrationskontroller mot riktig Postgres ·
genomgången 4/4 · portalspärren 16/16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 14:56:48 +00:00
parent 6790b60182
commit a94c228cab
22 changed files with 2160 additions and 5 deletions
+6
View File
@@ -29,6 +29,9 @@ import AlvaKunskapskallor from "./pages/alva/Kunskapskallor";
import AlvaAnalys from "./pages/alva/Analys";
import AlvaFakturor from "./pages/alva/Fakturor";
import AlvaIntegration from "./pages/alva/Integration";
import AlvaGarantier from "./pages/alva/Garantier";
import AlvaForsakring from "./pages/alva/Forsakring";
import AlvaSupport from "./pages/alva/Support";
import { Portalvakt } from "./pages/alva/Portalvakt";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
@@ -74,6 +77,9 @@ function AppContent() {
<Route path="/alva/portal/analys" element={<Portalvakt><AlvaAnalys /></Portalvakt>} />
<Route path="/alva/portal/integration" element={<Portalvakt><AlvaIntegration /></Portalvakt>} />
<Route path="/alva/portal/fakturor" element={<Portalvakt><AlvaFakturor /></Portalvakt>} />
<Route path="/alva/portal/garantier" element={<Portalvakt><AlvaGarantier /></Portalvakt>} />
<Route path="/alva/portal/forsakring" element={<Portalvakt><AlvaForsakring /></Portalvakt>} />
<Route path="/alva/portal/support" element={<Portalvakt><AlvaSupport /></Portalvakt>} />
<Route path="/felsokning" element={<Arendelista />} />
<Route path="/felsokning/nytt" element={<NyttArende />} />
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
+47
View File
@@ -361,6 +361,53 @@ export function Knapp({
* håller markeringen fri från tillstånd och fungerar innan skriptet
* kört.
*/
/**
* Textfält.
*
* Fanns inte förrän nu: inloggningen kodade sitt eget, och nästa vy som
* behövde ett hade blivit det tredje stället där samma ruta ritas för
* hand. Tre gånger är där ett designsystem tappas — inte i det stora
* beslutet utan i den lilla upprepningen.
*/
export function Textfalt({
label,
varde,
andra,
platshallare,
typ = "text",
komplettering,
}: {
label: string;
varde: string;
andra: (v: string) => void;
platshallare?: string;
typ?: "text" | "email" | "password";
komplettering?: string;
}) {
const id = `f-${label.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
return (
<div>
<label
htmlFor={id}
className="block text-[11px] font-semibold uppercase tracking-[0.08em]"
style={{ color: FARG.steel }}
>
{label}
</label>
<input
id={id}
type={typ}
value={varde}
autoComplete={komplettering}
placeholder={platshallare}
onChange={(e) => andra(e.target.value)}
className="mt-2 w-full border px-4 py-2 text-[14px]"
style={{ borderColor: FARG.lightSteel, background: FARG.white, color: FARG.graphite }}
/>
</div>
);
}
export function Tabell({ kolumner, rader }: { kolumner: string[]; rader: ReactNode[][] }) {
return (
<>
@@ -0,0 +1,143 @@
// @vitest-environment node
// ALVA-SPEC-041 · Försäkringsregistret.
//
// Ett enda krav bär hela modulen: den får aldrig uttala sig som om den
// avgjorde frågan. Testerna nedan låser den egenskapen från flera håll,
// därför att den kan gå förlorad på flera sätt — ett fält som heter
// `tackt`, ett tomt `utanfor` som läses som ett nej, en föråldrad post
// som ser aktuell ut.
import { describe, expect, it } from "vitest";
import {
AVGORANDE_VILLKOR,
BOLAG,
FORBEHALL,
GILTIGHETSTID_DAGAR,
bedom,
foraldrad,
spann,
} from "../../../../services/gemensam/forsakring.mjs";
describe("modulen avgör ingenting", () => {
it("resultatet innehåller inget fält som liknar ett utfall", () => {
const r = bedom({ alderAr: 3, mil: 4000 });
for (const förbjudet of ["tackt", "täckt", "covered", "utfall", "beslut", "godkand", "avslag"]) {
expect(Object.keys(r), förbjudet).not.toContain(förbjudet);
}
});
it("varje svar pekar vidare till kundens eget försäkringsbrev", () => {
expect(bedom({ alderAr: 1, mil: 100 }).nastaSteg).toMatch(/own policy/i);
expect(bedom({}).nastaSteg).toMatch(/own policy/i);
});
it("ett fordon utanför samtliga noterade gränser får ändå inget nej", () => {
// Registret kan vara ofullständigt, produkten en annan, och villkoren
// vid tecknandet andra. Att svara nej här vore att påstå mer än vi vet.
const r = bedom({ alderAr: 30, mil: 40_000 });
expect(r.inom).toEqual([]);
expect(r.utanfor.length).toBeGreaterThan(0);
expect(r.kvarstaende.length).toBeGreaterThan(0);
expect(r.nastaSteg).toBeTruthy();
});
it("förbehållet säger uttryckligen att ALVA inte avgör", () => {
expect(FORBEHALL).toMatch(/does not decide/i);
expect(FORBEHALL).toMatch(/the insurer decides/i);
});
});
describe("frågorna väger tyngre än gränserna", () => {
it("ålder och sträcka är två av sju villkor", () => {
expect(AVGORANDE_VILLKOR.length).toBe(7);
expect(AVGORANDE_VILLKOR.map((v) => v.id)).toContain("plotsligt");
expect(AVGORANDE_VILLKOR.map((v) => v.id)).toContain("underhall");
});
it("de fem som inte är ålder och sträcka återstår alltid", () => {
// Även för ett fordon som ligger välinom gränserna. Slitage och
// eftersatt underhåll avgör oftare än åldern gör.
const r = bedom({ alderAr: 1, mil: 500 });
expect(r.kvarstaende.map((v) => v.id)).toEqual(["plotsligt", "komponent", "underhall", "orsak", "sjalvrisk"]);
});
it("varje villkor bär en fråga, inte bara en rubrik", () => {
for (const v of AVGORANDE_VILLKOR) {
expect(v.fraga, v.id).toMatch(/\?$/);
expect(v.text, v.id).toBeTruthy();
}
});
});
describe("registret åldras synligt", () => {
it("varje bolag har källa och avläsningsdatum", () => {
for (const b of BOLAG) {
expect(b.kalla, b.namn).toMatch(/^https:\/\//);
expect(b.kontrollerad, b.namn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(b.produkter.length, b.namn).toBeGreaterThan(0);
}
});
it("en gammal post märks som föråldrad i stället för att tigas ihjäl", () => {
const gammal = { kontrollerad: "2020-01-01" };
expect(foraldrad(gammal)).toBe(true);
expect(foraldrad({ kontrollerad: "2026-08-01" }, new Date("2026-08-06"))).toBe(false);
// Precis över gränsen.
const nu = new Date("2026-08-06");
const strax = new Date(nu.getTime() - (GILTIGHETSTID_DAGAR + 1) * 86_400_000);
expect(foraldrad({ kontrollerad: strax.toISOString().slice(0, 10) }, nu)).toBe(true);
});
it("ett trasigt datum räknas som föråldrat, inte som giltigt", () => {
expect(foraldrad({ kontrollerad: "" })).toBe(true);
expect(foraldrad({ kontrollerad: "i somras" })).toBe(true);
});
it("föråldringen följer med i svaret", () => {
const gammalt = [{ ...BOLAG[0], kontrollerad: "2020-01-01" }];
const r = bedom({ alderAr: 1, mil: 100 }, gammalt);
expect(r.inom.every((p) => p.foraldrad)).toBe(true);
});
});
describe("gränserna läses som alternativa tak", () => {
it("ett överskridet mått räcker för att hamna utanför", () => {
// If: 10 år / 12 000 mil. Ett år gammal men 14 000 mil är utanför den
// produkten trots att åldern håller.
const r = bedom({ alderAr: 1, mil: 14_000, bolagId: "if" });
expect(r.utanfor.some((p) => p.produkt === "Maskinskadeförsäkring")).toBe(true);
// Men den större produkten går till 15 000 mil.
expect(r.inom.some((p) => p.produkt === "Stor bilförsäkring")).toBe(true);
});
it("halva underlaget ger oklart, inte inom", () => {
const r = bedom({ alderAr: 2, bolagId: "svedea" });
expect(r.inom).toEqual([]);
expect(r.oklart.length).toBeGreaterThan(0);
});
it("utan uppgifter alls är allting oklart", () => {
const r = bedom({});
expect(r.inom).toEqual([]);
expect(r.utanfor).toEqual([]);
expect(r.oklart.length).toBeGreaterThan(0);
});
it("samma bolag kan ha olika gränser för olika produkter", () => {
// Poängen med att inte slå ihop bolaget till en siffra.
const trygg = BOLAG.find((b) => b.id === "trygghansa");
const gränser = new Set(trygg.produkter.map((p) => `${p.maxAlderAr}/${p.maxMil}`));
expect(gränser.size).toBeGreaterThan(1);
});
});
describe("spannet beskriver marknaden utan att peka ut ett bolag", () => {
it("ger minsta och största noterade gräns", () => {
const s = spann();
expect(s.alderAr.minsta).toBeLessThan(s.alderAr.storsta);
expect(s.mil.minsta).toBeLessThan(s.mil.storsta);
});
it("ett tomt register ger null i stället för ett påhittat spann", () => {
expect(spann([])).toBeNull();
});
});
@@ -0,0 +1,166 @@
// @vitest-environment node
// ALVA-SPEC-040 · Garantiregimerna.
//
// Testerna handlar mindre om att siffrorna är rätt — det avgörs av
// källorna, inte av en assertion — än om två egenskaper som avgör om
// modulen är farlig att lita på:
//
// 1. Att varje lagstadgad regel bär en källa. En regel utan källa är ett
// påstående, och ett påstående om lag är värre än inget påstående.
// 2. Att bedömningen aldrig säger "utanför garanti" på ett underlag som
// inte räcker. Det är det enda felet i den här modulen som kostar
// kunden pengar.
import { describe, expect, it } from "vitest";
import {
FORBEHALL,
JURISDIKTIONER,
KONTROLLERAD,
REGIMER,
mojligenGallande,
regimerFor,
skyddadReparation,
} from "../../../../services/gemensam/garantier.mjs";
describe("varje regel går att spåra till sin källa", () => {
it("alla lagstadgade regler har källa och länk", () => {
for (const r of REGIMER.filter((x) => x.grund === "lag")) {
expect(r.källa, r.id).toBeTruthy();
expect(r.källänk, r.id).toMatch(/^https:\/\//);
}
});
it("varje regel är antingen lag eller avtal, aldrig något oklart däremellan", () => {
for (const r of REGIMER) expect(["lag", "avtal"], r.id).toContain(r.grund);
});
it("kontrolldatumet finns och står i förbehållet", () => {
expect(KONTROLLERAD).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(FORBEHALL).toContain(KONTROLLERAD);
expect(FORBEHALL).toMatch(/not legal advice/i);
});
});
describe("skiljelinjen mellan lagstadgat och avtalat hålls", () => {
// Den här förväxlingen är modulens hela existensberättigande. Skulle
// rosten någon gång märkas som lagstadgad vore modulen värre än ingen.
it("rostskydd, lack och nybil är AVTALADE — inget av dem har lagstöd", () => {
for (const id of ["AVTAL-ROST", "AVTAL-LACK", "AVTAL-NYBIL", "AVTAL-DRIVLINA"]) {
expect(REGIMER.find((r) => r.id === id).grund, id).toBe("avtal");
}
});
it("avgasgarantin är lagstadgad i bägge jurisdiktionerna", () => {
expect(REGIMER.find((r) => r.id === "US-AVGAS-PRESTANDA").grund).toBe("lag");
expect(REGIMER.find((r) => r.id === "US-AVGAS-HUVUDDELAR").grund).toBe("lag");
expect(REGIMER.find((r) => r.id === "EU-AVGAS-EURO6").grund).toBe("lag");
});
it("avtalade regimer utger sig inte för att ha en lagkälla", () => {
for (const r of REGIMER.filter((x) => x.grund === "avtal")) {
expect(r.källänk, r.id).toBeNull();
expect(r.källa, r.id).toMatch(/manufacturer/i);
}
});
it("konsumentköpet pekar på säljaren, inte tillverkaren", () => {
// Det vanligaste felet i verkstadsledet: kunden skickas till fel part.
const r = REGIMER.find((x) => x.id === "EU-KONFORMITET");
expect(r.bärare).toBe("säljaren");
expect(r.anmärkning).toMatch(/SELLER, not the manufacturer/);
});
});
describe("jurisdiktionen avgör vad som visas", () => {
it("Kalifornien ärver de federala reglerna", () => {
const ids = regimerFor("US-CA").map((r) => r.id);
// MagnusonMoss är federal och måste följa med — den är den regel
// verkstaden behöver mest, och att tappa den vore tyst skada.
expect(ids).toContain("US-MMWA");
expect(ids).toContain("US-CA-AVGAS");
});
it("EU visar inte amerikanska regler och tvärtom", () => {
expect(regimerFor("EU").map((r) => r.id)).not.toContain("US-MMWA");
expect(regimerFor("US").map((r) => r.id)).not.toContain("EU-MVBER");
});
it("avtalade kategorier visas i bägge", () => {
for (const j of ["EU", "US"]) {
expect(regimerFor(j).map((r) => r.id)).toContain("AVTAL-ROST");
}
});
it("varje jurisdiktion i listan ger minst en regim", () => {
for (const j of JURISDIKTIONER) expect(regimerFor(j.id).length, j.id).toBeGreaterThan(0);
});
});
describe("skyddet för oberoende reparation lyfts fram", () => {
it("EU pekar på MVBER och USA på MagnusonMoss", () => {
expect(skyddadReparation("EU").id).toBe("EU-MVBER");
expect(skyddadReparation("US").id).toBe("US-MMWA");
expect(skyddadReparation("US-CA").id).toBe("US-MMWA");
});
it("bägge säger att arbete här inte upphäver garantin", () => {
expect(skyddadReparation("EU").text).toMatch(/independent workshop/i);
expect(skyddadReparation("US").text).toMatch(/may not condition/i);
});
});
describe("bedömningen påstår aldrig mer än underlaget bär", () => {
it("ett nytt fordon ligger inom de federala avgasreglerna", () => {
const { inom } = mojligenGallande("US", { alderAr: 1, miles: 12_000 });
expect(inom.map((r) => r.id)).toContain("US-AVGAS-PRESTANDA");
expect(inom.map((r) => r.id)).toContain("US-AVGAS-HUVUDDELAR");
});
it("perioden är 'whichever comes first' — ett överskridet mått räcker", () => {
// Två år gammal men 40 000 miles: prestandagarantin är passerad på
// sträckan trots att åldern håller.
const { utanfor } = mojligenGallande("US", { alderAr: 2, miles: 40_000 });
expect(utanfor.map((r) => r.id)).toContain("US-AVGAS-PRESTANDA");
});
it("okänd ålder ger OKLART, aldrig utanför", () => {
// Det enda felet här som kostar kunden pengar är att påstå att en
// garanti gått ut när underlaget inte räcker.
const { utanfor, oklart } = mojligenGallande("US", {});
expect(utanfor).toEqual([]);
expect(oklart.map((r) => r.id)).toContain("US-AVGAS-HUVUDDELAR");
});
it("bara mätarställning utan ålder räcker inte för att avfärda", () => {
const { utanfor, oklart } = mojligenGallande("US", { miles: 10_000 });
expect(utanfor).toEqual([]);
expect(oklart.length).toBeGreaterThan(0);
});
it("men en passerad sträcka avfärdar även utan ålder", () => {
// Här RÄCKER underlaget: 200 000 miles är över varje federal gräns,
// oavsett hur gammalt fordonet är.
const { utanfor } = mojligenGallande("US", { miles: 200_000 });
expect(utanfor.map((r) => r.id)).toContain("US-AVGAS-HUVUDDELAR");
});
it("kilometer och miles räknas om åt bägge håll", () => {
// 100 000 miles är ~160 934 km, alltså över Euro 7:s 160 000 km.
const { utanfor } = mojligenGallande("EU", { alderAr: 2, miles: 100_000 });
expect(utanfor.map((r) => r.id)).toContain("EU-AVGAS-EURO7");
// Och 50 000 km är under.
const { inom } = mojligenGallande("EU", { alderAr: 2, km: 50_000 });
expect(inom.map((r) => r.id)).toContain("EU-AVGAS-EURO7");
});
it("regler utan tidsgräns gäller alltid, även utan uppgifter om fordonet", () => {
expect(mojligenGallande("EU", {}).inom.map((r) => r.id)).toContain("EU-MVBER");
expect(mojligenGallande("US", {}).inom.map((r) => r.id)).toContain("US-MMWA");
});
it("avtalade regimer bedöms inte alls — deras villkor känner vi inte", () => {
const allt = mojligenGallande("EU", { alderAr: 1, km: 1000 });
const ids = [...allt.inom, ...allt.utanfor, ...allt.oklart].map((r) => r.id);
expect(ids).not.toContain("AVTAL-ROST");
expect(ids).not.toContain("AVTAL-NYBIL");
});
});
@@ -0,0 +1,126 @@
// @vitest-environment node
// ALVA-PROC-0050 · Felanmälan.
//
// Två egenskaper prövas: att sammanhanget härleds och inte kan smugglas,
// och att statusen är en projektion. Den första är en dataskyddsfråga —
// en supportanmälan är inte ett skäl att flytta personuppgifter till ett
// annat system, och det är precis vad ett fritt sammanhangsfält skulle
// göra första gången någon klistrar in ett regnr "för tydlighetens skull".
import { describe, expect, it } from "vitest";
import {
FORBJUDET_I_SAMMANHANG,
STATUSAR,
TYPER,
granskaAnmalan,
sammanhang,
supportbeteckning,
supportstatus,
} from "../../../../services/gemensam/support.mjs";
describe("sammanhanget härleds och kan inte smugglas", () => {
it("bara kända fält följer med", () => {
const ut = sammanhang({
arendeId: "arende-1",
metodikId: "vibration",
steg: "4",
plattformsversion: "ALVA 1.0",
sparId: "abc",
});
expect(Object.keys(ut).sort()).toEqual(["arende", "metodik", "plattform", "spar", "steg"]);
});
it("identifierande fält släpps aldrig igenom", () => {
const smuggel = Object.fromEntries(FORBJUDET_I_SAMMANHANG.map((n: string) => [n, "ABC123"]));
const ut = sammanhang({ ...smuggel, arendeId: "arende-1" });
for (const n of FORBJUDET_I_SAMMANHANG) expect(Object.keys(ut), n).not.toContain(n);
// Och det som SKA följa med gör det fortfarande.
expect(ut.arende).toBe("arende-1");
});
it("tomma fält utelämnas i stället för att skickas som tomma strängar", () => {
expect(sammanhang({ arendeId: "", metodikId: null })).toEqual({});
expect(sammanhang()).toEqual({});
});
it("klientsträngen kapas — en user agent är inte en uppsats", () => {
expect(sammanhang({ klient: "x".repeat(500) }).klient.length).toBe(200);
});
});
describe("tröskeln för att anmäla är låg, men inte noll", () => {
it("en anmälan med rubrik och beskrivning tas emot", () => {
expect(
granskaAnmalan({
typ: "felanmalan",
rubrik: "Mätsteget vägrar komma",
beskrivning: "Kontrollen tar inte emot 2,4 men accepterar 2.4. Verkstaden skriver komma.",
}),
).toBeNull();
});
it("okänd typ avvisas", () => {
expect(granskaAnmalan({ typ: "hittepa", rubrik: "Något", beskrivning: "x".repeat(30) })).toBeTruthy();
});
it("en tom beskrivning avvisas — den som svarar ser inte skärmen", () => {
const fel = granskaAnmalan({ typ: "felanmalan", rubrik: "Funkar inte", beskrivning: "" });
expect(fel).toMatch(/ser inte din skärm/);
});
it("orimligt långa fält avvisas", () => {
expect(granskaAnmalan({ typ: "fraga", rubrik: "x".repeat(300), beskrivning: "y".repeat(30) })).toBeTruthy();
expect(granskaAnmalan({ typ: "fraga", rubrik: "Rubrik", beskrivning: "y".repeat(30_000) })).toBeTruthy();
});
it("alla tre typerna går att anmäla", () => {
for (const t of TYPER as { id: string }[]) {
expect(granskaAnmalan({ typ: t.id, rubrik: "En rubrik", beskrivning: "x".repeat(30) }), t.id).toBeNull();
}
});
});
describe("statusen är en projektion, inte ett fält", () => {
it("en anmälan utan inlägg är mottagen — utan att något skrivits", () => {
expect(supportstatus([])).toBe("mottagen");
expect(supportstatus()).toBe("mottagen");
});
it("senaste statusposten gäller", () => {
expect(
supportstatus([
{ typ: "status", status: "under_arbete" },
{ typ: "svar", text: "Vi tittar." },
{ typ: "status", status: "atgardad" },
]),
).toBe("atgardad");
});
it("en stängd anmälan kan öppnas igen", () => {
// Händer när felet visar sig inte vara borta.
expect(
supportstatus([
{ typ: "status", status: "stangd" },
{ typ: "status", status: "under_arbete" },
]),
).toBe("under_arbete");
});
it("okända statusvärden ignoreras i stället för att bli tillstånd", () => {
expect(supportstatus([{ typ: "status", status: "hittepa" }])).toBe("mottagen");
});
it("svar ensamma ändrar inte tillståndet", () => {
expect(supportstatus([{ typ: "svar", text: "Tack." }])).toBe("mottagen");
});
it("alla fyra tillstånd finns", () => {
expect(STATUSAR).toEqual(["mottagen", "under_arbete", "atgardad", "stangd"]);
});
});
describe("beteckningen följer nomenklaturen", () => {
it("ALVA-SUP-0001", () => {
expect(supportbeteckning(1)).toBe("ALVA-SUP-0001");
expect(supportbeteckning(1234)).toBe("ALVA-SUP-1234");
});
});
@@ -233,6 +233,49 @@ export async function hamtaFakturor(): Promise<Faktura[]> {
return ((await res.json()) as { fakturor: Faktura[] }).fakturor;
}
// Supportärenden (ALVA-PROC-0050). Anmälan sker från ärendet där felet
// uppstod; sammanhanget härleds av servern och skickas inte in.
export interface Supportinlagg {
typ: "svar" | "status";
text: string;
status: string | null;
fran: string;
skapad: string;
}
export interface Supportarende {
id: string;
beteckning: string;
arende_id: string | null;
typ: string;
rubrik: string;
beskrivning: string;
sammanhang: Record<string, string>;
status: string;
skapad: string;
anmald_av: string | null;
inlagg: Supportinlagg[];
}
export async function hamtaSupport(): Promise<Supportarende[]> {
const res = await plattformFetch("/api/support");
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { arenden: Supportarende[] }).arenden;
}
export async function anmalSupport(anmalan: {
typ: string;
rubrik: string;
beskrivning: string;
arendeId?: string;
sammanhang?: Record<string, string>;
}): Promise<{ beteckning: string }> {
const res = await plattformFetch("/api/support", { method: "POST", body: JSON.stringify(anmalan) });
const data = (await res.json().catch(() => ({}))) as { beteckning?: string; error?: string };
if (!res.ok) throw new Error(data.error ?? `Fel ${res.status}`);
return { beteckning: data.beteckning ?? "" };
}
// Märkesspecifika kopplingar. Uppgifterna lagras krypterat på servern
// och returneras alltid maskerade — klienten ser aldrig hemligheterna.
export interface LeverantorsFalt {
@@ -0,0 +1,189 @@
// ALVA-SPEC-041 · Försäkringsvillkor.
//
// Vyn får aldrig se ut som ett besked. Den är byggd så att det som står
// överst är FRÅGORNA — det som faktiskt avgör en maskinskadefråga — och
// registret därunder, som det underlag det är.
//
// Ordningen är inte kosmetisk. Läggs tabellen först läses den som ett
// svar, och de fem villkor som avgör oftare än åldern gör hamnar under
// vikningen.
import { useState } from "react";
import {
AVGORANDE_VILLKOR,
BOLAG,
FORBEHALL,
bedom,
foraldrad,
spann,
} from "../../../../services/gemensam/forsakring.mjs";
import { Block, Etikett, FARG, Rubrik, Tabell, Textfalt } from "@/alva/komponenter";
import { Ram } from "./Ram";
interface Produkt {
namn: string;
maxAlderAr: number;
maxMil: number;
}
interface Bolag {
id: string;
namn: string;
marknad: string;
produkter: Produkt[];
kalla: string;
kontrollerad: string;
}
interface Villkor {
id: string;
rubrik: string;
fraga: string;
text: string;
}
export default function Forsakring() {
const [alder, setAlder] = useState("");
const [mil, setMil] = useState("");
const tal = (v: string) => (v.trim() === "" || Number.isNaN(Number(v)) ? undefined : Number(v));
const utfall = bedom({ alderAr: tal(alder), mil: tal(mil) }) as {
inom: { bolag: string; produkt: string; foraldrad: boolean }[];
utanfor: { bolag: string; produkt: string }[];
oklart: { bolag: string; produkt: string }[];
nastaSteg: string;
};
const s = spann() as { alderAr: { minsta: number; storsta: number }; mil: { minsta: number; storsta: number } };
const angivet = tal(alder) !== undefined || tal(mil) !== undefined;
return (
<Ram portal>
<div className="mx-auto max-w-[1040px] px-6 py-12">
<Etikett>Reasoning aid</Etikett>
<div className="mt-2 mb-2">
<Rubrik niva={1}>Insurance conditions</Rubrik>
</div>
<p className="mb-8 max-w-[680px] text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
ALVA does not decide whether a claim is covered, and cannot. Cover follows the
customer&rsquo;s own policy and the terms in force when it was taken out. What this page gives
you is the shape of the question and what the register recorded, so the conversation starts
from something better than a guess.
</p>
<Block rubrik="What actually decides it" beteckning="ALVA-SPEC-041">
<p className="mb-4 max-w-[680px] text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
Age and distance are two of seven. The other five decide more claims than the two that get
asked about first.
</p>
<Tabell
kolumner={["Condition", "The question", "Why it matters"]}
rader={(AVGORANDE_VILLKOR as Villkor[]).map((v) => [
<span key="r" className="font-semibold">
{v.rubrik}
</span>,
<span key="f" className="text-[13px]">
{v.fraga}
</span>,
<span key="t" className="text-[12px] leading-[19px]" style={{ color: FARG.steel }}>
{v.text}
</span>,
])}
/>
</Block>
<Block rubrik="Where the vehicle sits against the register" beteckning="ALVA-SPEC-041">
<div className="grid max-w-[420px] gap-4 sm:grid-cols-2">
<Textfalt label="Vehicle age (years)" varde={alder} andra={setAlder} platshallare="6" />
<Textfalt label="Odometer (mil)" varde={mil} andra={setMil} platshallare="9 400" />
</div>
{!angivet ? (
<p className="mt-6 text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
Recorded limits range from {s.alderAr.minsta}{s.alderAr.storsta} years and{" "}
{s.mil.minsta.toLocaleString("sv-SE")}{s.mil.storsta.toLocaleString("sv-SE")} mil, depending
on insurer and product tier.
</p>
) : (
<div className="mt-6 grid gap-4">
{[
["Within recorded limits", utfall.inom],
["Beyond recorded limits", utfall.utanfor],
["Cannot be placed on what you entered", utfall.oklart],
].map(([rubrik, lista]) => {
const rader = lista as { bolag: string; produkt: string; foraldrad?: boolean }[];
return (
<div key={rubrik as string}>
<Etikett>{rubrik as string}</Etikett>
{rader.length === 0 ? (
<p className="mt-2 text-[13px]" style={{ color: FARG.steel }}>
</p>
) : (
<ul className="mt-2 grid gap-2">
{rader.map((r) => (
<li key={`${r.bolag}${r.produkt}`} className="text-[13px]">
<span className="font-semibold">{r.bolag}</span> · {r.produkt}
{r.foraldrad && (
<span className="ml-2 text-[11px] uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
register entry out of date
</span>
)}
</li>
))}
</ul>
)}
</div>
);
})}
{/* Står även när fordonet ligger utanför varje noterad
gräns. Registret kan vara ofullständigt, produkten en
annan, och villkoren vid tecknandet andra. */}
<p className="border-t pt-4 text-[13px] leading-[21px]" style={{ borderColor: FARG.lightSteel }}>
{utfall.nastaSteg}
</p>
</div>
)}
</Block>
<Block rubrik="Register" beteckning="ALVA-SPEC-041">
<Tabell
kolumner={["Insurer", "Product", "Age", "Distance", "Read"]}
rader={(BOLAG as Bolag[]).flatMap((b) =>
b.produkter.map((p) => [
<span key="b" className="font-semibold">
{b.namn}
</span>,
<span key="p" className="text-[13px]">
{p.namn}
</span>,
<span key="a" className="font-mono text-[12px]">
{p.maxAlderAr} yr
</span>,
<span key="m" className="font-mono text-[12px]">
{p.maxMil.toLocaleString("sv-SE")} mil
</span>,
<span
key="k"
className="font-mono text-[11px]"
style={{ color: foraldrad(b) ? FARG.graphite : FARG.steel }}
>
{b.kontrollerad}
{foraldrad(b) ? " — out of date" : ""}
</span>,
]),
)}
/>
<p className="mt-4 max-w-[680px] text-[12px] leading-[19px]" style={{ color: FARG.steel }}>
The register covers the insurers read so far and is not a market survey. Smaller insurers
and non-Swedish markets have to be added by the organization, with a source and a date
an entry without those two is not usable as a starting point for anything.
</p>
</Block>
<Block rubrik="Basis" beteckning="ALVA-SPEC-041">
<p className="max-w-[680px] text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
{FORBEHALL}
</p>
</Block>
</div>
</Ram>
);
}
+164
View File
@@ -0,0 +1,164 @@
// ALVA-SPEC-040 · Garantiregimer.
//
// Vyn har ett jobb och det är inte att vara fullständig: den ska göra
// skillnaden mellan LAGSTADGAT och AVTALAT omöjlig att missa, och lyfta
// de två regler som avgör om verkstaden får utföra arbetet alls.
//
// Ordningen är därför inte alfabetisk och inte kronologisk. Skyddet för
// oberoende reparation står först, eftersom det är det som ifrågasätts —
// ibland av kunden, som blivit tillsagd något annat.
import { useState } from "react";
import {
FORBEHALL,
JURISDIKTIONER,
KONTROLLERAD,
regimerFor,
skyddadReparation,
} from "../../../../services/gemensam/garantier.mjs";
import { Block, Etikett, FARG, Rubrik, Tabell } from "@/alva/komponenter";
import { Ram } from "./Ram";
interface Regim {
id: string;
jurisdiktion: string;
grund: "lag" | "avtal";
bärare: string;
rubrik: string;
kortform: string;
källa: string;
källänk: string | null;
text: string;
anmärkning: string;
avvikelser: { marknad: string; text: string }[];
}
const BARARE: Record<string, string> = {
säljaren: "Seller",
tillverkaren: "Manufacturer",
};
export default function Garantier() {
const [jurisdiktion, setJurisdiktion] = useState("EU");
const regimer = regimerFor(jurisdiktion) as Regim[];
const lag = regimer.filter((r) => r.grund === "lag");
const avtal = regimer.filter((r) => r.grund === "avtal");
const skydd = skyddadReparation(jurisdiktion) as Regim | null;
return (
<Ram portal>
<div className="mx-auto max-w-[1040px] px-6 py-12">
<Etikett>Regulatory</Etikett>
<div className="mt-2 mb-2">
<Rubrik niva={1}>Warranty regimes</Rubrik>
</div>
<p className="mb-8 max-w-[680px] text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
What the law requires as a floor, and what the manufacturer merely promises. The two are
confused daily on the shop floor, and the confusion runs in both directions a corrosion
warranty treated as statutory, an emissions warranty treated as a favour.
</p>
{/* Jurisdiktionen först: allt nedanför beror på den, och ett svar
för fel marknad är sämre än inget svar. */}
<div className="mb-8">
<Etikett>Jurisdiction</Etikett>
<div className="mt-2 flex flex-wrap gap-2">
{(JURISDIKTIONER as { id: string; namn: string }[]).map((j) => {
const vald = j.id === jurisdiktion;
return (
<button
key={j.id}
type="button"
onClick={() => setJurisdiktion(j.id)}
aria-pressed={vald}
className="border px-4 py-2 text-[12px] font-semibold uppercase tracking-[0.08em]"
style={{
background: vald ? FARG.graphite : FARG.white,
color: vald ? FARG.white : FARG.steel,
borderColor: vald ? FARG.graphite : FARG.lightSteel,
}}
>
{j.namn}
</button>
);
})}
</div>
</div>
{skydd && (
<Block rubrik="Servicing here does not void the warranty" beteckning={skydd.id}>
<p className="max-w-[680px] text-[14px] leading-[23px]">{skydd.text}</p>
<p className="mt-4 max-w-[680px] text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
{skydd.anmärkning}
</p>
<p className="mt-4 font-mono text-[11px]" style={{ color: FARG.steel }}>
{skydd.källa}
</p>
</Block>
)}
<Block rubrik="Statutory — applies regardless of any warranty booklet" beteckning="ALVA-SPEC-040">
<Tabell
kolumner={["Regime", "Period", "Liable", "Source"]}
rader={lag.map((r) => [
<span key="r">
<span className="font-semibold">{r.rubrik}</span>
<span className="mt-2 block text-[12px] leading-[19px]" style={{ color: FARG.steel }}>
{r.text}
</span>
{r.anmärkning && (
<span className="mt-2 block text-[12px] leading-[19px]" style={{ color: FARG.graphite }}>
{r.anmärkning}
</span>
)}
{r.avvikelser.map((a) => (
<span key={a.marknad} className="mt-2 block text-[12px] leading-[19px]" style={{ color: FARG.steel }}>
{a.marknad}: {a.text}
</span>
))}
</span>,
<span key="p" className="font-mono text-[12px]">
{r.kortform}
</span>,
<span key="b" className="text-[12px]" style={{ color: FARG.steel }}>
{BARARE[r.bärare] ?? r.bärare}
</span>,
<span key="k" className="font-mono text-[11px]" style={{ color: FARG.steel }}>
{r.källa}
</span>,
])}
/>
</Block>
<Block rubrik="Contractual — the manufacturer's own promise" beteckning="ALVA-SPEC-040">
<p className="mb-4 max-w-[680px] text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
None of the following has any statutory basis in the EU or the United States. Terms are set
by the manufacturer and vary by brand, market and model year, so the ranges below describe
what occurs on the market not what any given brand undertakes. Read the vehicle&rsquo;s own
warranty terms before quoting a period to a customer.
</p>
<Tabell
kolumner={["Warranty", "Typical", "Note"]}
rader={avtal.map((r) => [
<span key="r" className="font-semibold">
{r.rubrik}
</span>,
<span key="p" className="text-[12px]" style={{ color: FARG.steel }}>
{r.kortform}
</span>,
<span key="n" className="text-[12px] leading-[19px]" style={{ color: FARG.steel }}>
{r.text} {r.anmärkning}
</span>,
])}
/>
</Block>
<Block rubrik="Basis" beteckning={`Verified ${KONTROLLERAD}`}>
<p className="max-w-[680px] text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
{FORBEHALL}
</p>
</Block>
</div>
</Ram>
);
}
+3
View File
@@ -21,6 +21,9 @@ const PORTAL = [
{ till: "/alva/portal/analys", text: "Analysis" },
{ till: "/alva/portal/kunskapskallor", text: "Knowledge sources" },
{ till: "/alva/portal/integration", text: "Integration" },
{ till: "/alva/portal/garantier", text: "Warranty" },
{ till: "/alva/portal/forsakring", text: "Insurance" },
{ till: "/alva/portal/support", text: "Support" },
{ till: "/alva/portal/fakturor", text: "Invoices" },
{ till: "/felsokning", text: "Diagnostics" },
];
+157
View File
@@ -0,0 +1,157 @@
// ALVA-PROC-0050 · Supportärenden.
//
// Listan över organisationens anmälningar. Själva anmälandet sker inte
// här utan i ärendet där felet uppstod — se Felanmalan i felsökningsvyn.
// En anmälningsknapp som bara finns i en portalvy nås aldrig av den som
// står med händerna i en bil.
import { useEffect, useState } from "react";
import { STATUSAR, TYPER } from "../../../../services/gemensam/support.mjs";
import { hamtaSupport, plattformAktiv, type Supportarende } from "@/felsokning/plattform";
import { Block, Demonstration, Etikett, FARG, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter";
import { Ram } from "./Ram";
const STATUSAV: Record<string, "passed" | "pending" | "failed" | "not_applicable"> = {
mottagen: "pending",
under_arbete: "pending",
atgardad: "passed",
stangd: "not_applicable",
};
const STATUSTEXT: Record<string, string> = {
mottagen: "Received",
under_arbete: "In progress",
atgardad: "Resolved",
stangd: "Closed",
};
// Vad som visas när ingen plattform är konfigurerad. Exemplet är märkt.
const EXEMPEL: Supportarende[] = [
{
id: "demo-1",
beteckning: "ALVA-SUP-0001",
arende_id: "ALVA-CASE-91821",
typ: "felanmalan",
rubrik: "Measurement step will not accept a value from the pressure tester",
beskrivning:
"The check asks for a reading in bar. Entering 2,4 is rejected as invalid; entering 2.4 is accepted. The workshop uses a comma.",
sammanhang: { metodik: "vibration", steg: "4", plattform: "ALVA 1.0" },
status: "atgardad",
skapad: "2026-08-04T09:12:00Z",
anmald_av: "Anna",
inlagg: [],
},
];
export default function Support() {
const skarpt = plattformAktiv();
const [arenden, setArenden] = useState<Supportarende[] | null>(null);
const [fel, setFel] = useState("");
useEffect(() => {
if (!skarpt) return;
let avbruten = false;
hamtaSupport()
.then((a) => !avbruten && setArenden(a))
.catch((orsak) => !avbruten && setFel(orsak instanceof Error ? orsak.message : String(orsak)));
return () => {
avbruten = true;
};
}, [skarpt]);
// Misslyckad hämtning visar felet, aldrig exemplet: data som ser äkta
// ut men inte är det är värre än ett synligt fel.
const lista = skarpt ? arenden : EXEMPEL;
return (
<Ram portal>
<div className="mx-auto max-w-[1040px] px-6 py-12">
<Etikett>Support</Etikett>
<div className="mt-2 mb-2">
<Rubrik niva={1}>Reported issues</Rubrik>
</div>
<p className="mb-8 max-w-[680px] text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
Every case carries a report button. A report filed from the case brings its own context
methodology, step, platform version, trace id so the first reply can be an answer rather
than a question.
</p>
{!skarpt && (
<Demonstration>
The entry below is an example. Connected to a platform instance this page lists the
organization&rsquo;s own reports.
</Demonstration>
)}
<Block rubrik="Reports" beteckning="ALVA-PROC-0050">
{fel ? (
<p className="text-[13px]" style={{ color: FARG.steel }}>
Reports could not be retrieved: {fel}
</p>
) : !lista ? (
<p className="text-[13px]" style={{ color: FARG.steel }}>
Retrieving reports.
</p>
) : lista.length === 0 ? (
<p className="text-[13px]" style={{ color: FARG.steel }}>
Nothing has been reported.
</p>
) : (
<Tabell
kolumner={["Reference", "Kind", "Subject", "Case", "Status"]}
rader={lista.map((s) => [
<span key="b" className="font-mono text-[12px]">
{s.beteckning}
</span>,
<span key="t" className="text-[12px]" style={{ color: FARG.steel }}>
{(TYPER as { id: string; rubrik: string }[]).find((t) => t.id === s.typ)?.rubrik ?? s.typ}
</span>,
<span key="r">
<span className="font-semibold">{s.rubrik}</span>
<span className="mt-2 block text-[12px] leading-[19px]" style={{ color: FARG.steel }}>
{Object.entries(s.sammanhang ?? {})
.map(([n, v]) => `${n}: ${v}`)
.join(" · ")}
</span>
</span>,
<span key="a" className="font-mono text-[11px]" style={{ color: FARG.steel }}>
{s.arende_id ?? "—"}
</span>,
<span key="s" className="flex flex-wrap items-center gap-2">
<Statusmärke status={STATUSAV[s.status] ?? "pending"} />
<span className="text-[11px] uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
{STATUSTEXT[s.status] ?? s.status}
</span>
</span>,
])}
/>
)}
</Block>
<Block rubrik="States" beteckning="ALVA-PROC-0050">
<p className="mb-4 max-w-[680px] text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
A report is never edited. Replies and state changes are separate entries, and the state is
derived from them so the record can still answer how long a fault was known about.
</p>
<Tabell
kolumner={["State", "Meaning"]}
rader={(STATUSAR as string[]).map((s) => [
<span key="s" className="font-semibold">
{STATUSTEXT[s] ?? s}
</span>,
<span key="m" className="text-[12px]" style={{ color: FARG.steel }}>
{s === "mottagen"
? "Filed. This is the state a report has the moment it exists — nothing has to be written for it to be true."
: s === "under_arbete"
? "Support has taken it on."
: s === "atgardad"
? "Fixed, and the fix is out."
: "Closed. A closed report can be reopened — which happens when the fault turns out not to be gone."}
</span>,
])}
/>
</Block>
</div>
</Ram>
);
}
@@ -2567,10 +2567,116 @@ function RapportFlik({
Genererad ur ärendets händelselogg · Observationer och mätvärden redovisas utan slutsatser som saknar stöd.
</p>
<Ritningsstampel arende={arende} />
<Felanmalan arende={arende} metodik={metodik ?? null} />
</>
);
}
/**
* Felanmälan — i ärendet, inte någon annanstans.
*
* En anmälningsknapp som bara finns i en portalvy nås aldrig av den som
* står med händerna i en bil. Den ligger därför i varje ärende, och tar
* med sitt sammanhang självt: ärende, metodik, öppet steg och
* plattformsversion. Teknikern minns inte plattformsversionen, och att
* fråga efter den är att lägga över vårt problem på verkstaden.
*
* Anmälan lagras aldrig i ärendets händelselogg. Loggen är protokollet
* över vad som gjordes med FORDONET; att ett fel i vårt system anmäldes
* hör inte dit, och skulle följa med in i en rapport som ska hålla i en
* tvist.
*/
function Felanmalan({ arende, metodik }: { arende: Arende; metodik: Metodik | null }) {
const [oppen, setOppen] = useState(false);
const [typ, setTyp] = useState("felanmalan");
const [rubrik, setRubrik] = useState("");
const [text, setText] = useState("");
const [utfall, setUtfall] = useState<{ ok?: string; fel?: string }>({});
const [skickar, setSkickar] = useState(false);
const skicka = async () => {
setSkickar(true);
setUtfall({});
try {
const { anmalSupport, plattformAktiv } = await import("@/felsokning/plattform");
if (!plattformAktiv()) {
setUtfall({ fel: "Ingen plattform är konfigurerad — anmälan kan inte skickas härifrån." });
return;
}
const { beteckning } = await anmalSupport({
typ,
rubrik,
beskrivning: text,
arendeId: arende.id,
sammanhang: {
metodikId: metodik?.id ?? "",
plattformsversion: PLATTFORMSVERSION,
klient: typeof navigator === "undefined" ? "" : navigator.userAgent,
},
});
setUtfall({ ok: beteckning });
setRubrik("");
setText("");
} catch (orsak) {
setUtfall({ fel: orsak instanceof Error ? orsak.message : String(orsak) });
} finally {
setSkickar(false);
}
};
if (!oppen) {
return (
<p className="mt-4 text-center">
<button
type="button"
onClick={() => setOppen(true)}
className="text-[12px] uppercase tracking-wide text-[#4A5560] underline"
>
Rapportera ett problem med systemet
</button>
</p>
);
}
return (
<Panel rubrik="Rapportera ett problem med systemet">
<p className="mb-2 text-[12px] text-[#707070]">
Ärende, metodik och plattformsversion följer med automatiskt. Inget om fordonet eller kunden
skickas.
</p>
<div className="mb-2 grid grid-cols-3 gap-2">
{[
["felanmalan", "Fel"],
["fraga", "Fråga"],
["forbattring", "Förbättring"],
].map(([id, etikett]) => (
<StorKnapp key={id} variant={typ === id ? "primar" : "sekundar"} onClick={() => setTyp(id)}>
{etikett}
</StorKnapp>
))}
</div>
<TextFalt label="Vad gäller det?" varde={rubrik} satt={setRubrik} platshallare="Kort rubrik" />
<TextFalt
label="Vad hände, och vad väntade du dig?"
varde={text}
satt={setText}
platshallare="Den som svarar ser inte din skärm."
flerRad
/>
{utfall.ok && <p className="mt-2 text-[13px] text-[#1E6B34]">Anmäld som {utfall.ok}.</p>}
{utfall.fel && <p className="mt-2 text-[13px] text-[#8B1A1A]">{utfall.fel}</p>}
<div className="mt-2 grid grid-cols-2 gap-2">
<StorKnapp onClick={skicka} disabled={skickar || rubrik.trim().length < 5 || text.trim().length < 20}>
{skickar ? "Skickar" : "Skicka anmälan"}
</StorKnapp>
<StorKnapp variant="sekundar" onClick={() => setOppen(false)}>
Stäng
</StorKnapp>
</div>
</Panel>
);
}
/**
* Ritningsstämpel.
*