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:
@@ -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 />} />
|
||||
|
||||
@@ -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);
|
||||
// Magnuson–Moss ä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å Magnuson–Moss", () => {
|
||||
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’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>
|
||||
);
|
||||
}
|
||||
@@ -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’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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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’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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user