Härda enligt revision 2: stäng schemat, ta bort dubbelregeln
C-5. granskaHändelse itererade schemats nycklar och aldrig händelsens, så okända fält accepterades och sparades ordagrant. Två garantier vilade på motsatsen: krypto-shreddingen skyddar en fast lista av fältnamn, så personuppgifter på en vanlig observation krypterades aldrig och överlevde raderingen — och delningsfiltret är typnivå, så samma fält gick ut i kundens delningslänk. Schemat är nu stängt, med varje valfritt fält deklarerat. Det gäller även `kalla`: protokollinläsningen fungerade bara därför att schemat var öppet. Avslaget är hårt, inte en tyst strykning. Verifierat mot riktig trafik — samtliga händelser som klienten faktiskt producerar passerar. M-7. Klienten upprepar inte längre grindens regel utan anropar grinda() och visar dess egna hinder. Villkoret hade glidit isär två gånger utan att något test märkte det. Det avslöjade omedelbart ett verkligt fel i grinden: den krävde textresultat även på kontroller vars krav är foto — trots att gränssnittet märker fältet "Observation (valfritt)". Servern hade alltså nekat avslut på nästan varje riktigt ärende, och det syntes inte så länge klienten hade ett eget och mildare villkor. Evidens graderas nu efter kontrollens eget krav. M-8. Protokollinläsningen svarar med utfall per händelse i stället för en siffra, 207 vid delvis lyckad inläsning, innehållshärledda id:n i stället för klockan, och en transaktion runt hela importen. M-9. En genererad webhookhemlighet lämnas ut en gång vid skapandet. m-8. Profilens vägslagning begränsas till egna egenskaper. m-10. Ett mätvärde som kommer ur en kontroll redovisas en gång. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -284,3 +284,169 @@ describe("metodikernas mätkontroller producerar mätevidens", () => {
|
||||
expect(underlagFinns(arende, "Mätresultat")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- C-5 · Schemat är stängt --------------------------------------------
|
||||
//
|
||||
// Revision 2 fann att granskaHändelse itererade schemats nycklar och
|
||||
// aldrig händelsens. Följden: vilket okänt fält som helst accepterades och
|
||||
// sparades ordagrant. Ett registreringsnummer på en vanlig observation
|
||||
// hamnade aldrig i krypteringslistan, överlevde därför raderingen, och
|
||||
// gick ut i kundens delningslänk eftersom delningsfiltret arbetar på
|
||||
// typnivå.
|
||||
//
|
||||
// Testerna nedan låser tre saker: att okända fält avvisas, att avslaget
|
||||
// är hårt (inte en tyst strykning), och att deklarationen inte kan glida
|
||||
// från domänmodellen åt något håll.
|
||||
describe("händelseschemat är stängt", () => {
|
||||
it("avvisar påhängda fält på en i övrigt giltig händelse", async () => {
|
||||
const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs");
|
||||
const fel = granskaHändelse({
|
||||
typ: "observation",
|
||||
text: "Kontroll av bromsok.",
|
||||
vin: "YV1DZ8256F2123456",
|
||||
personnummer: "19800101-1234",
|
||||
});
|
||||
expect(fel).toMatch(/okända fält/);
|
||||
expect(fel).toContain("vin");
|
||||
expect(fel).toContain("personnummer");
|
||||
});
|
||||
|
||||
it("avslaget är hårt — händelsen skrivs inte utan de okända fälten", async () => {
|
||||
// En tyst strykning vore värre än att spara skräpet: anroparen tror
|
||||
// att värdet finns i loggen och upptäcker motsatsen när det behövs.
|
||||
const { tillPost } = await import("../../../../services/gemensam/handelser.mjs");
|
||||
const { post, fel } = tillPost(
|
||||
{ id: "a1", handelse: { typ: "observation", text: "Kontroll.", vin: "ABC123" } },
|
||||
{ sub: "u1", namn: "Anna", org: "o1" },
|
||||
);
|
||||
expect(post).toBeUndefined();
|
||||
expect(fel).toMatch(/okända fält/);
|
||||
});
|
||||
|
||||
it("systemets egna fält passerar", async () => {
|
||||
const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs");
|
||||
expect(
|
||||
granskaHändelse({
|
||||
typ: "observation",
|
||||
text: "Kontroll av bromsok.",
|
||||
anvandarId: "u1",
|
||||
registrerad_tidpunkt: "2026-01-01T10:00:00.000Z",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("protokollinläsningens härkomstfält är deklarerat", async () => {
|
||||
// Integrationen fungerade tidigare bara därför att schemat var öppet.
|
||||
// Stängs schemat utan att `kalla` deklareras slutar den fungera.
|
||||
const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs");
|
||||
const { protokollTillHandelser } = await import("../../../../services/gemensam/integration.mjs");
|
||||
const handelser = protokollTillHandelser(
|
||||
{ dtcs: [{ code: "P0420", description: "Katalysator under tröskel" }], liveData: [{ name: "Lambda", value: "0,81", unit: "V" }] },
|
||||
{ felkoder: { vag: "dtcs", kod: "code", text: "description" },
|
||||
matvarden: { vag: "liveData", beskrivning: "name", varde: "value", enhet: "unit" } },
|
||||
"Diagnosinstrument, bås 3",
|
||||
);
|
||||
expect(handelser.length).toBe(2);
|
||||
for (const h of handelser) expect(granskaHändelse(h)).toBeNull();
|
||||
});
|
||||
|
||||
it("varje valfritt fält i schemat finns i domänmodellen", async () => {
|
||||
const { VALFRIA_FÄLT } = await import("../../../../services/gemensam/handelser.mjs");
|
||||
const domain = readFileSync("src/felsokning/domain.ts", "utf8");
|
||||
const ärvda = ["bilagaId", "bilagaHash", "dataUrl"];
|
||||
const avvikelser: string[] = [];
|
||||
for (const [typ, falt] of Object.entries(VALFRIA_FÄLT as Record<string, string[]>)) {
|
||||
const start = domain.indexOf(`typ: "${typ}"`);
|
||||
const block = domain.slice(start, start + 900);
|
||||
for (const f of falt) {
|
||||
if (ärvda.includes(f) || f === "kalla") continue; // Bilaga respektive härkomst
|
||||
if (!new RegExp(`\\b${f}\\?:`).test(block)) avvikelser.push(`${typ}.${f}`);
|
||||
}
|
||||
}
|
||||
expect(avvikelser).toEqual([]);
|
||||
});
|
||||
|
||||
it("varje valfritt fält i domänmodellen är deklarerat i schemat", async () => {
|
||||
// Den riktningen är den farliga: ett nytt valfritt fält i modellen
|
||||
// som ingen deklarerar avvisas i produktion, inte i testet.
|
||||
const { HÄNDELSESCHEMA, VALFRIA_FÄLT } = await import("../../../../services/gemensam/handelser.mjs");
|
||||
const domain = readFileSync("src/felsokning/domain.ts", "utf8");
|
||||
// Unionen måste avgränsas: annars svämmar den sista medlemmen in i
|
||||
// resten av filen och testet rapporterar andra typers fält.
|
||||
const fran = domain.indexOf("export type Handelse =");
|
||||
const till = domain.indexOf("\n\nexport ", fran);
|
||||
const union = domain.slice(fran, till > 0 ? till : undefined);
|
||||
// `(?!\s*\|)` sållar bort ai_svar-radernas egen typunion
|
||||
// (`typ: "observation" | "verifierat" | …`), som annars läses som en
|
||||
// ny unionsmedlem och förskjuter alla block efter den.
|
||||
const traffar = [...union.matchAll(/typ: "([a-z_]+)"(?!\s*\|)/g)];
|
||||
const typer = traffar.map((m) => m[1]);
|
||||
const avvikelser: string[] = [];
|
||||
|
||||
traffar.forEach((traff, i) => {
|
||||
const typ = traff[1];
|
||||
const start = traff.index!;
|
||||
const nasta = i + 1 < traffar.length ? traffar[i + 1].index! : union.length;
|
||||
const block = union.slice(start, nasta);
|
||||
const deklarerade = new Set([
|
||||
...Object.keys((HÄNDELSESCHEMA as Record<string, object>)[typ] ?? {}),
|
||||
...((VALFRIA_FÄLT as Record<string, string[]>)[typ] ?? []),
|
||||
]);
|
||||
for (const m of block.matchAll(/^\s+(\w+)\?:/gm)) {
|
||||
if (!deklarerade.has(m[1])) avvikelser.push(`${typ}.${m[1]}`);
|
||||
}
|
||||
});
|
||||
expect(avvikelser).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fotokontroller verifieras av foton ----------------------------------
|
||||
//
|
||||
// QUALITY-AUDIT-2 · M-7, funnet först när klienten slutade ha en egen,
|
||||
// mildare mening om avslut. Grinden krävde ett textresultat även på
|
||||
// kontroller vars krav är foto — trots att gränssnittet märker det fältet
|
||||
// "Observation (valfritt)". Servern hade alltså nekat avslut på nästan
|
||||
// varje riktigt ärende, och teknikern fått veta det först vid synk.
|
||||
describe("grinden räknar rätt sorts evidens per kontrolltyp", () => {
|
||||
const kontrollHandelser = (metodik: { steg: { id: string; kontroller?: { id: string; krav?: string; text: string }[] }[] }, medResultat: boolean) =>
|
||||
metodik.steg.flatMap((s) =>
|
||||
(s.kontroller ?? []).map((k) => ({
|
||||
typ: "kontroll_utford",
|
||||
stegId: s.id,
|
||||
kontrollId: k.id,
|
||||
text: k.text,
|
||||
...(medResultat && k.krav !== "foto" ? { resultat: "Uppmätt inom tolerans." } : {}),
|
||||
})),
|
||||
);
|
||||
|
||||
it("en fotokontroll utan textresultat är inte ett hinder när fotot finns", async () => {
|
||||
const { grinda } = await import("../../../../services/gemensam/grind.mjs");
|
||||
const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs");
|
||||
const fotokrav = VIBRATION.steg.flatMap((s: { kontroller?: { krav?: string }[] }) =>
|
||||
(s.kontroller ?? []).filter((k) => k.krav === "foto"),
|
||||
).length;
|
||||
expect(fotokrav).toBeGreaterThan(0);
|
||||
|
||||
const logg = [
|
||||
...kontrollHandelser(VIBRATION, true),
|
||||
...Array.from({ length: fotokrav }, (_, i) => ({ typ: "foto", beskrivning: `Hjul ${i + 1}` })),
|
||||
];
|
||||
const hinder = grinda(logg, VIBRATION).map((h: { id: string }) => h.id);
|
||||
expect(hinder).not.toContain("metodik_kontroller");
|
||||
expect(hinder).not.toContain("foton");
|
||||
});
|
||||
|
||||
it("men saknas fotona spärrar det fortfarande", async () => {
|
||||
const { grinda } = await import("../../../../services/gemensam/grind.mjs");
|
||||
const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs");
|
||||
const hinder = grinda(kontrollHandelser(VIBRATION, true), VIBRATION).map((h: { id: string }) => h.id);
|
||||
expect(hinder).toContain("foton");
|
||||
});
|
||||
|
||||
it("en mät- eller kommentarkontroll utan resultat spärrar fortfarande", async () => {
|
||||
const { grinda } = await import("../../../../services/gemensam/grind.mjs");
|
||||
const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs");
|
||||
const hinder = grinda(kontrollHandelser(VIBRATION, false), VIBRATION).map((h: { id: string }) => h.id);
|
||||
expect(hinder).toContain("metodik_kontroller");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,13 +266,31 @@ describe("klientens avslutsvillkor täcker hela grindens åtgärdskedja", () =>
|
||||
expect(await gateHinder(med)).not.toContain("kundbeslut");
|
||||
});
|
||||
|
||||
it("klienten kräver samma sak — kundbeslut ingår i underlagKlart", () => {
|
||||
// QUALITY-AUDIT-2 · M-7. Testet krävde tidigare att klienten räknade
|
||||
// upp grindens villkor. Det var fel sorts skydd: det låser bara fast
|
||||
// de villkor någon råkade tänka på, och nästa villkor som läggs till i
|
||||
// grinden glider isär precis som de två föregående gjorde.
|
||||
//
|
||||
// Nu krävs i stället att klienten inte har någon egen mening. Ett
|
||||
// uttryck kan inte glida från sig självt.
|
||||
it("klienten frågar grinden i stället för att upprepa den", () => {
|
||||
const kod = readFileSync("src/pages/felsokning/ArendeSida.tsx", "utf8");
|
||||
const rad = kod.match(/const underlagKlart =[\s\S]*?;/)?.[0] ?? "";
|
||||
expect(rad).toContain("reproducering(arende)");
|
||||
expect(rad).toContain("felorsaker(arende)");
|
||||
expect(rad).toContain("kvalitetskontroll(arende)");
|
||||
expect(rad).toContain("kundbeslut(arende)");
|
||||
expect(kod).toContain('from "../../../../services/gemensam/grind.mjs"');
|
||||
|
||||
const rad = kod.match(/const kanAvslutas =[\s\S]{0,200}?;/)?.[0] ?? "";
|
||||
expect(rad).toContain("hinder.length === 0");
|
||||
|
||||
// Ingen egen uppräkning av grindens villkor får finnas kvar.
|
||||
const uppraknat = kod.match(/const underlagKlart =[\s\S]{0,300}?;/)?.[0] ?? "";
|
||||
for (const villkor of ["reproducering(arende)", "felorsaker(arende)", "kvalitetskontroll(arende)", "kundbeslut(arende)"]) {
|
||||
expect(uppraknat, `klienten upprepar grindens villkor: ${villkor}`).not.toContain(villkor);
|
||||
}
|
||||
});
|
||||
|
||||
it("hindren som visas är grindens egna, med id och rubrik", () => {
|
||||
const kod = readFileSync("src/pages/felsokning/ArendeSida.tsx", "utf8");
|
||||
expect(kod).toMatch(/function Avslutshinder\(\{ hinder \}/);
|
||||
expect(kod).toContain("h.rubrik");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -163,6 +163,14 @@ export type Handelse =
|
||||
beslut: "godkant" | "avbojt" | "delvis";
|
||||
kanal: string;
|
||||
kommentar?: string;
|
||||
/**
|
||||
* Vem hos kunden som lämnade beskedet. Personuppgift: krypteras per
|
||||
* ärendenyckel och försvinner vid radering (IDENTIFIERANDE i
|
||||
* services/gemensam/personuppgifter.mjs). Fältet stod tidigare i
|
||||
* krypteringslistan utan att finnas i modellen — nu stämmer
|
||||
* modellen, schemat och krypteringen överens.
|
||||
*/
|
||||
kontaktperson?: string;
|
||||
}
|
||||
// Åtgärdsfasen: vad som faktiskt gjordes — eller varför ingen åtgärd
|
||||
// utfördes (kunden avböjde, väntar på delar …). Kopplas till den
|
||||
|
||||
@@ -155,11 +155,25 @@ export function ejKontrollerat(arende: Arende, metodik: Metodik): string[] {
|
||||
}
|
||||
|
||||
export function observationer(arende: Arende): string[] {
|
||||
// En kontroll vars krav är ett mätvärde loggar både mätvärdet och
|
||||
// kontrollen, så att mätningen finns som mätevidens. Rapporten ska
|
||||
// ändå bara redovisa värdet en gång: kontrollen bär det redan under
|
||||
// Utförda kontroller (QUALITY-AUDIT-2 · m-10).
|
||||
const iKontroll = new Set(
|
||||
arende.handelser
|
||||
.map((p) => p.handelse)
|
||||
.filter((h) => h.typ === "kontroll_utford" && h.resultat)
|
||||
.map((h) => (h as { text: string; resultat: string }).text + " " + (h as { resultat: string }).resultat),
|
||||
);
|
||||
|
||||
const resultat: string[] = [];
|
||||
for (const post of arende.handelser) {
|
||||
const h = post.handelse;
|
||||
if (h.typ === "observation") resultat.push(h.text);
|
||||
if (h.typ === "matvarde") resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
|
||||
if (h.typ === "matvarde") {
|
||||
if (iKontroll.has(h.beskrivning + " " + h.varde)) continue;
|
||||
resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
|
||||
}
|
||||
}
|
||||
return resultat;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fasFor, klaraFaser } from "../../../../services/gemensam/faser.mjs";
|
||||
import { Fasrad } from "@/alva/komponenter";
|
||||
import { Slutsatspanel } from "@/felsokning/Slutsats";
|
||||
import { sammanfatta } from "../../../../services/gemensam/sammanfattning.mjs";
|
||||
import { granskaSlutsats } from "../../../../services/gemensam/motivering.mjs";
|
||||
import { grinda } from "../../../../services/gemensam/grind.mjs";
|
||||
import { arendebeteckning, fasDefinition } from "@/alva/system";
|
||||
import {
|
||||
arAvslutat,
|
||||
@@ -1255,36 +1255,31 @@ function GuideFlik({
|
||||
|
||||
const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
|
||||
|
||||
// Avslut kräver hela kedjan: symptomverifiering, felorsaksanalys,
|
||||
// dokumenterad åtgärd (eller motiverat uteblivande) och — när en
|
||||
// åtgärd faktiskt utförts — kvalitetskontroll av att symptomet är borta.
|
||||
const atgardsposter = atgarder(arende);
|
||||
const utfordAtgard = atgardsposter.some((p) => p.handelse.typ === "atgard_utford" && p.handelse.utford);
|
||||
|
||||
// Underlaget: det som gör att en slutsats över huvud taget går att
|
||||
// motivera. Innan detta är på plats vore varför-frågan en begäran om
|
||||
// en gissning.
|
||||
// QUALITY-AUDIT-2 · M-7. Klienten upprepar inte längre grindens regel.
|
||||
//
|
||||
// Kundbeskedet hör till kedjan när arbete faktiskt utförts. Grinden på
|
||||
// servern har alltid krävt det; klienten gjorde det inte, med följden
|
||||
// att ett ärende gick att stänga på skärmen och nekas först vid synk.
|
||||
// Samma sorts glapp som slutsatsen hade — hittat genom att köra tio
|
||||
// ärenden hela vägen och jämföra mot grinden.
|
||||
const underlagKlart =
|
||||
!!reproducering(arende) &&
|
||||
felorsaker(arende).length > 0 &&
|
||||
atgardsposter.length > 0 &&
|
||||
(!utfordAtgard || (!!kvalitetskontroll(arende) && !!kundbeslut(arende)));
|
||||
|
||||
// ALVA-RULE-200. Samma granskning som kvalitetsgrinden på servern kör
|
||||
// — avsiktligt samma funktion, inte en klientkopia av regeln. En
|
||||
// tekniker ska aldrig få veta vid synk att ärendet inte gick att
|
||||
// stänga; hindret ska synas på skärmen där arbetet utförs.
|
||||
const slutsatsbrister = granskaSlutsats(
|
||||
[...arende.handelser].reverse().find((p) => p.handelse.typ === "slutsats")?.handelse,
|
||||
// Den gjorde det tidigare, för att kunna gråa ut avslutsknappen, och
|
||||
// villkoret gled isär från grinden två gånger: först saknades kravet på
|
||||
// en slutsats, sedan kravet på kundens besked. Ingen av gångerna
|
||||
// fångades av testerna — bägge hittades av att köra tio ärenden hela
|
||||
// vägen. Två självständiga uttryck för en regel glider alltid; frågan
|
||||
// är bara när det upptäcks.
|
||||
//
|
||||
// Nu ställs frågan till grinden själv, med den lokala loggen. Samma
|
||||
// funktion som servern kör, så svaret kan inte skilja sig. Att den
|
||||
// därmed blir strängare är avsikten: det servern skulle neka ska synas
|
||||
// på skärmen där arbetet utförs, inte vid synk när bilen har åkt.
|
||||
const hinder = grinda(
|
||||
arende.handelser.map((p) => p.handelse),
|
||||
);
|
||||
const kanAvslutas = underlagKlart && slutsatsbrister.length === 0;
|
||||
metodik,
|
||||
) as { id: string; rubrik: string; detalj?: string }[];
|
||||
|
||||
const kanAvslutas = hinder.length === 0;
|
||||
|
||||
// Slutsatspanelen visas när underlaget är helt — alltså när det enda
|
||||
// som återstår är varför-frågan. Att fråga tidigare vore att be om en
|
||||
// gissning; att fråga först vid knappen vore att neka någon som redan
|
||||
// är klar.
|
||||
const underlagKlart = hinder.every((h) => h.id.startsWith("slutsats_"));
|
||||
|
||||
if (avslutat) {
|
||||
return (
|
||||
@@ -1350,7 +1345,7 @@ function GuideFlik({
|
||||
<StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}>
|
||||
Avsluta felsökning
|
||||
</StorKnapp>
|
||||
{!kanAvslutas && <Avslutshinder underlagKlart={underlagKlart} brister={slutsatsbrister} />}
|
||||
{!kanAvslutas && <Avslutshinder hinder={hinder} />}
|
||||
</>
|
||||
) : steg.sparr ? (
|
||||
// Säkerhetsspärr. Metodiken går inte vidare — svaret är ett
|
||||
@@ -1423,7 +1418,7 @@ function GuideFlik({
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!kanAvslutas && <Avslutshinder underlagKlart={underlagKlart} brister={slutsatsbrister} />}
|
||||
{!kanAvslutas && <Avslutshinder hinder={hinder} />}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
|
||||
Lämna över arbete
|
||||
@@ -1443,30 +1438,25 @@ function GuideFlik({
|
||||
/**
|
||||
* Varför avslut är spärrat — i klartext, på skärmen, medan arbetet pågår.
|
||||
*
|
||||
* Ett hinder som bara säger "kraven är inte uppfyllda" tvingar teknikern
|
||||
* att leta. Här står vad som fattas, i den ordning det behöver åtgärdas:
|
||||
* först underlaget, sedan varför-frågan.
|
||||
* Listan är grindens egen. Det är hela poängen: teknikern läser exakt de
|
||||
* hinder som servern skulle svara med, vid den tidpunkt då de fortfarande
|
||||
* går att åtgärda. Ett hinder som bara säger "kraven är inte uppfyllda"
|
||||
* tvingar teknikern att leta.
|
||||
*/
|
||||
function Avslutshinder({ underlagKlart, brister }: { underlagKlart: boolean; brister: { falt: string; text: string }[] }) {
|
||||
function Avslutshinder({ hinder }: { hinder: { id: string; rubrik: string; detalj?: string }[] }) {
|
||||
return (
|
||||
<div className="mb-2 border-l-2 border-[#9A6700] bg-[#FFFBF0] px-4 py-4">
|
||||
<p className="mb-2 text-[12px] font-semibold uppercase tracking-[0.08em] text-[#9A6700]">
|
||||
Ärendet kan inte avslutas ännu
|
||||
</p>
|
||||
{!underlagKlart && (
|
||||
<p className="mb-2 text-[13px] leading-[20px] text-[#333333]">
|
||||
Underlaget är ofullständigt: avslut kräver symptomverifiering, felorsaksanalys, dokumenterad åtgärd och —
|
||||
vid utförd åtgärd — kvalitetskontroll av att symptomet är borta samt kundens besked på åtgärdsförslaget.
|
||||
Se panelerna nedan.
|
||||
</p>
|
||||
)}
|
||||
{brister.length > 0 && (
|
||||
<ul className="ml-4 list-disc text-[13px] leading-[20px] text-[#333333]">
|
||||
{brister.map((b) => (
|
||||
<li key={b.falt}>{b.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<ul className="ml-4 list-disc text-[13px] leading-[20px] text-[#333333]">
|
||||
{hinder.map((h) => (
|
||||
<li key={h.id}>
|
||||
{h.rubrik}
|
||||
{h.detalj && <span className="text-[#4A5560]"> — {h.detalj}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user