Merge main: Guidad Felsökning flyttar ur roten till felsokning/

Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.

För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:

  felsokning/app        webbklienten (Vite, egen package.json och
                        eslint-/vitest-konfiguration)
  felsokning/services   plattformstjänsten och AI-orkestern
  felsokning/infra      Terraform och databasschemat
  felsokning/docs       vision, moduler, drift
  felsokning/supabase   edge-funktion och migrationer

Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.

Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.

CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.

Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.

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-04 12:22:49 +00:00
298 changed files with 23491 additions and 6140 deletions
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: none;
margin: 0;
padding: 0;
text-align: inherit;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+78
View File
@@ -0,0 +1,78 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, HashRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
import ResetPassword from "./pages/ResetPassword";
import Profile from "./pages/Profile";
import Account from "./pages/Account";
import AccountConfirmation from "./pages/AccountConfirmation";
import Contact from "./pages/Contact";
import Admin from "./pages/Admin";
import FAQ from "./pages/FAQ";
import OrderConfirmation from "./pages/OrderConfirmation";
import ChangeSubscription from "./pages/ChangeSubscription";
import Arendelista from "./pages/felsokning/Arendelista";
import NyttArende from "./pages/felsokning/NyttArende";
import ArendeSida from "./pages/felsokning/ArendeSida";
import DelatArende from "./pages/felsokning/DelatArende";
import PublikDelning from "./pages/felsokning/PublikDelning";
import Oversikt from "./pages/felsokning/Oversikt";
import Installningar from "./pages/felsokning/Installningar";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient();
// Hash-baserad routing för miljöer utan SPA-rewrites (t.ex. förhandsvisning
// på en statisk sida). Standard är vanlig historik-routing.
//
// Historik-routing kräver att sidan serveras från roten: ligger den under
// en sökväg matchar ingen route och besökaren får 404-sidan. I
// förhandsvisningsläge går routingen därför via hash — då spelar
// sökvägen ingen roll — och startsidan är Guidad Felsökning i stället för
// värdapplikationens startsida.
const forhandsvisning = !!import.meta.env.VITE_HASH_ROUTER;
const Router = forhandsvisning ? HashRouter : BrowserRouter;
function AppContent() {
return (
<Router>
<PaymentTestModeBanner />
<Routes>
<Route path="/" element={forhandsvisning ? <Arendelista /> : <Index />} />
<Route path="/contact" element={<Contact />} />
<Route path="/faq" element={<FAQ />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/profile" element={<Profile />} />
<Route path="/account" element={<Account />} />
<Route path="/account/confirmation" element={<AccountConfirmation />} />
<Route path="/admin" element={<Admin />} />
<Route path="/order-confirmation" element={<OrderConfirmation />} />
<Route path="/account/change-subscription" element={<ChangeSubscription />} />
<Route path="/felsokning" element={<Arendelista />} />
<Route path="/felsokning/nytt" element={<NyttArende />} />
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
<Route path="/felsokning/dela/:id" element={<DelatArende />} />
<Route path="/felsokning/delad/:kod" element={<PublikDelning />} />
<Route path="/felsokning/oversikt" element={<Oversikt />} />
<Route path="/felsokning/installningar" element={<Installningar />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
);
}
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<AppContent />
</TooltipProvider>
</QueryClientProvider>
);
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,84 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import AuthDialog from "./AuthDialog";
const AccountMenu = () => {
const { user, loading } = useAuth();
const [authDialogOpen, setAuthDialogOpen] = useState(false);
const [authMode, setAuthMode] = useState<"login" | "signup">("login");
const navigate = useNavigate();
const handleOpenAuth = (mode: "login" | "signup") => {
setAuthMode(mode);
setAuthDialogOpen(true);
};
if (loading) {
return null;
}
// If user is logged in, navigate directly to account page
if (user) {
return (
<Button
variant="ghost"
onClick={() => navigate("/account")}
className="text-primary hover:text-primary/80 font-serif text-base font-bold border-2 hover:bg-[rgba(220,38,38,0.3)]"
style={{ borderColor: '#dc2626' }}
>
Mitt konto
</Button>
);
}
// If not logged in, show dropdown with login/signup options
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="text-primary hover:text-primary/80 font-serif text-base font-bold border-2 hover:bg-[rgba(220,38,38,0.3)]"
style={{ borderColor: '#dc2626' }}
>
Mitt konto
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-background border border-border shadow-lg z-50 p-0 w-[var(--radix-dropdown-menu-trigger-width)]"
>
<button
onClick={() => handleOpenAuth("signup")}
className="w-full px-4 py-2 text-left text-sm hover:bg-[rgba(220,38,38,0.3)] focus:bg-[rgba(220,38,38,0.3)] transition-colors"
>
Skapa konto
</button>
<button
onClick={() => handleOpenAuth("login")}
className="w-full px-4 py-2 text-left text-sm hover:bg-[rgba(220,38,38,0.3)] focus:bg-[rgba(220,38,38,0.3)] hover:text-black focus:text-black transition-colors"
>
Logga in
</button>
</DropdownMenuContent>
</DropdownMenu>
<AuthDialog
open={authDialogOpen}
onOpenChange={setAuthDialogOpen}
mode={authMode}
onModeChange={setAuthMode}
/>
</>
);
};
export default AccountMenu;
@@ -0,0 +1,262 @@
import { useState } from "react";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
const authSchema = z.object({
email: z.string().trim().email({ message: "Ogiltig e-postadress" }).max(255),
password: z.string().min(6, { message: "Lösenordet måste vara minst 6 tecken" }).max(100),
});
const emailSchema = z.object({
email: z.string().trim().email({ message: "Ogiltig e-postadress" }).max(255),
});
type AuthMode = "login" | "signup" | "forgot";
interface AuthDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "login" | "signup";
onModeChange: (mode: "login" | "signup") => void;
}
const AuthDialog = ({ open, onOpenChange, mode: initialMode, onModeChange }: AuthDialogProps) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [internalMode, setInternalMode] = useState<AuthMode>(initialMode);
const { signIn, signUp, resetPassword } = useAuth();
const { toast } = useToast();
// Sync internal mode with external mode prop
const mode = internalMode === "forgot" ? "forgot" : initialMode;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (internalMode === "forgot") {
const validation = emailSchema.safeParse({ email });
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const { error } = await resetPassword(email);
if (error) {
toast({
title: "Kunde inte skicka återställningslänk",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "E-post skickad!",
description: "Kolla din inkorg för att återställa ditt lösenord.",
});
onOpenChange(false);
resetForm();
}
} finally {
setIsSubmitting(false);
}
return;
}
const validation = authSchema.safeParse({ email, password });
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
if (initialMode === "signup") {
const { error } = await signUp(email, password);
if (error) {
toast({
title: "Kunde inte skapa konto",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Konto skapat!",
description: "Du är nu inloggad.",
});
onOpenChange(false);
resetForm();
}
} else {
const { error } = await signIn(email, password);
if (error) {
toast({
title: "Kunde inte logga in",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Inloggad!",
description: "Välkommen tillbaka.",
});
onOpenChange(false);
resetForm();
}
}
} finally {
setIsSubmitting(false);
}
};
const resetForm = () => {
setEmail("");
setPassword("");
setInternalMode(initialMode);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const getTitle = () => {
if (internalMode === "forgot") return "Glömt lösenord";
return initialMode === "signup" ? "Skapa konto" : "Logga in";
};
const getSubmitText = () => {
if (isSubmitting) return "Laddar...";
if (internalMode === "forgot") return "Skicka återställningslänk";
return initialMode === "signup" ? "Skapa konto" : "Logga in";
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-background border border-border max-w-md">
<DialogHeader>
<DialogTitle className="font-serif text-xl text-foreground">
{getTitle()}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="din@email.se"
required
/>
</div>
{internalMode !== "forgot" && (
<div className="space-y-2">
<Label htmlFor="password">Lösenord</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
)}
<Button
type="submit"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
disabled={isSubmitting}
>
{getSubmitText()}
</Button>
</form>
<div className="text-center mt-4 space-y-2">
{internalMode === "forgot" ? (
<p className="text-sm text-muted-foreground">
<button
type="button"
onClick={() => setInternalMode("login")}
className="text-primary hover:underline"
>
Tillbaka till inloggning
</button>
</p>
) : (
<>
{initialMode === "login" && (
<p className="text-sm text-muted-foreground">
<button
type="button"
onClick={() => setInternalMode("forgot")}
className="text-primary hover:underline"
>
Glömt lösenord?
</button>
</p>
)}
{initialMode === "signup" ? (
<p className="text-sm text-muted-foreground">
Har du redan ett konto?{" "}
<button
type="button"
onClick={() => {
setInternalMode("login");
onModeChange("login");
}}
className="text-primary hover:underline"
>
Logga in
</button>
</p>
) : (
<p className="text-sm text-muted-foreground">
Har du inget konto?{" "}
<button
type="button"
onClick={() => {
setInternalMode("signup");
onModeChange("signup");
}}
className="text-primary hover:underline"
>
Skapa konto
</button>
</p>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default AuthDialog;
@@ -0,0 +1,53 @@
import { Truck, Clock, MapPin } from "lucide-react";
const DeliverySection = () => {
return (
<section className="py-8 md:py-12 px-4 md:px-6 bg-card">
<div className="max-w-4xl mx-auto">
{/* Info cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
{/* Leveransområde */}
<div className="text-center p-4">
<div className="w-10 h-10 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
<MapPin className="w-5 h-5 text-primary" />
</div>
<h3 className="font-display text-base md:text-lg font-semibold mb-1">
Storstockholm
</h3>
<p className="text-muted-foreground text-sm">
Vi levererar till alla företag inom Storstockholmsområdet.
</p>
</div>
{/* Leveransdag */}
<div className="text-center p-4">
<div className="w-10 h-10 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-primary" />
</div>
<h3 className="font-display text-base md:text-lg font-semibold mb-1">
Veckoleverans
</h3>
<p className="text-muted-foreground text-sm">
Kakorna levereras samma dag varje vecka.
</p>
</div>
{/* Leveranstid */}
<div className="text-center p-4">
<div className="w-10 h-10 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
<Truck className="w-5 h-5 text-primary" />
</div>
<h3 className="font-display text-base md:text-lg font-semibold mb-1">
Dagtid
</h3>
<p className="text-muted-foreground text-sm">
Kakorna levereras alltid under dagtid.
</p>
</div>
</div>
</div>
</section>
);
};
export default DeliverySection;
+66
View File
@@ -0,0 +1,66 @@
import logoImage from "@/assets/logo.png";
import { Instagram } from "lucide-react";
const TikTokIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
width="24"
height="24"
>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
);
const Footer = () => {
return (
<footer className="py-8 px-6 bg-card border-t border-border">
<div className="max-w-5xl mx-auto">
{/* Social Media */}
<div className="flex justify-center items-center gap-6 mb-6">
<a
href="https://instagram.com/konditorivaror"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors"
>
<Instagram size={18} />
<span className="text-sm">@konditorivaror</span>
</a>
<a
href="https://tiktok.com/@konditorivaror"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors"
>
<TikTokIcon className="w-[18px] h-[18px]" />
<span className="text-sm">@konditorivaror</span>
</a>
</div>
{/* Links */}
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground mb-6">
<a href="#gallery" className="hover:text-primary transition-colors">
Vårt sortiment
</a>
<a href="/contact" className="hover:text-primary transition-colors">
Kontakta oss
</a>
<a href="/faq" className="hover:text-primary transition-colors">
FAQ
</a>
</div>
{/* Copyright */}
<div className="text-center text-muted-foreground text-xs">
<p>© {new Date().getFullYear()} Lennart Svensson Konditorivaror</p>
<p className="mt-0.5 italic">Alla rättigheter förbehållna</p>
</div>
</div>
</footer>
);
};
export default Footer;
@@ -0,0 +1,50 @@
import bakery1 from "@/assets/bakery-1.jpg";
import bakery2 from "@/assets/bakery-2.jpg";
import bakery3 from "@/assets/bakery-3.jpg";
import bakery4 from "@/assets/bakery-4.jpg";
import bakery5 from "@/assets/bakery-5.jpg";
import bakery6 from "@/assets/bakery-6.jpg";
const images = [
{ src: bakery1, alt: "Kanelbullar" },
{ src: bakery2, alt: "Prinsesstårta" },
{ src: bakery3, alt: "Sorterade kakor" },
{ src: bakery4, alt: "Hantverksbageri" },
{ src: bakery5, alt: "Kardemummabullar" },
{ src: bakery6, alt: "Företagsleverans" },
];
const GallerySection = () => {
return (
<section id="gallery" className="py-24 px-6 bg-card">
<div className="max-w-5xl mx-auto">
{/* Image grid with classic frames */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{images.map((image, index) => (
<div
key={index}
className="group"
>
{/* Classic frame effect */}
<div className="relative p-3 bg-background border border-border card-classic">
<div className="overflow-hidden">
<img
src={image.src}
alt={image.alt}
className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105 sepia-[.15]"
/>
</div>
<p className="text-center mt-3 font-display text-lg text-muted-foreground italic">
{image.alt}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default GallerySection;
+22
View File
@@ -0,0 +1,22 @@
import AccountMenu from "./AccountMenu";
const Header = () => {
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-sm border-b border-border">
<div className="px-6 py-3 flex items-center justify-between">
<a href="/" className="flex items-center">
<img
alt="Lennart Svensson Konditorivaror"
className="h-16 w-auto"
src="/lovable-uploads/bc36fcd7-0c6f-48d2-9921-4323413739c7.png"
/>
</a>
<div className="flex items-center gap-3">
<AccountMenu />
</div>
</div>
</header>
);
};
export default Header;
@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from "react";
import mandelkubbImage from "@/assets/mandelkubb-plate.jpg";
import kolasnittImage from "@/assets/kolasnitt-plate.jpg";
import chokladsnittImage from "@/assets/chokladsnitt-plate.jpg";
const cookies = [
{
name: "Mandelkubben",
image: mandelkubbImage,
description: "Den traditionella mandelkubben nötig och klassisk med mild sötma och tydlig mandelsmak. En riktig svensk fikakaka.",
},
{
name: "Kolasnitten",
image: kolasnittImage,
description: "Den klassiska kolakakan med fyllig, smörig kolasmak som smälter i munnen. Spröd på ytan, seg i mitten.",
},
{
name: "Chokladsnitten",
image: chokladsnittImage,
description: "En chokladälskares favorit perfekt balans mellan sötma och rik chokladsmak. Krispig på kanterna, seg i mitten.",
},
];
const HeritageSection = () => {
const [heroVisible, setHeroVisible] = useState(false);
const [cookiesVisible, setCookiesVisible] = useState(false);
const heroRef = useRef<HTMLDivElement>(null);
const cookiesRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observerOptions = {
threshold: 0.1,
rootMargin: "0px 0px -100px 0px",
};
const heroObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
setHeroVisible(entry.isIntersecting);
});
}, observerOptions);
const cookiesObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
setCookiesVisible(entry.isIntersecting);
});
}, observerOptions);
if (heroRef.current) heroObserver.observe(heroRef.current);
if (cookiesRef.current) cookiesObserver.observe(cookiesRef.current);
return () => {
heroObserver.disconnect();
cookiesObserver.disconnect();
};
}, []);
return (
<section className="bg-background py-24 px-6">
<div className="max-w-6xl mx-auto">
{/* Heritage Hero */}
<div
ref={heroRef}
className={`transition-all duration-[4000ms] ease-out ${
heroVisible
? "opacity-100 translate-y-0 scale-100"
: "opacity-0 translate-y-24 scale-95"
}`}
>
{/* Text Content */}
<div className="text-center max-w-3xl mx-auto">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
<h2 className="font-display text-3xl md:text-4xl lg:text-5xl font-semibold mb-6">
Hantverk & Kvalitet
</h2>
<div className="space-y-4 text-foreground/80 text-lg leading-relaxed">
<p>
Våra recept grundar sig i det klassiska <em className="text-primary">Svenska Konditorilexikon från 1954</em> och
har förädlats genom generationer av bagare. Vi använder bara riktigt smör,
färska mandlar och äkta choklad.
</p>
<p>
Varje kaka formas för hand med samma omsorg som traditionella
konditorer alltid har gjort. Det är det har varit i över 70 år, och det alltid kommer att vara.
</p>
</div>
</div>
</div>
{/* Cookies Grid */}
<div
id="gallery"
ref={cookiesRef}
className="mt-24 grid grid-cols-1 md:grid-cols-3 gap-8 scroll-mt-24"
>
{cookies.map((cookie, index) => (
<div
key={cookie.name}
className={`transition-all duration-700 ease-out ${
cookiesVisible
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-12"
}`}
style={{
transitionDelay: cookiesVisible ? `${index * 200}ms` : "0ms",
}}
>
<div className="text-center">
<div className="aspect-square overflow-hidden rounded-sm mb-6">
<img
src={cookie.image}
alt={cookie.name}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500"
/>
</div>
<h3 className="font-display text-2xl font-semibold text-foreground mb-3">
{cookie.name}
</h3>
<p className="text-foreground/70 leading-relaxed">
{cookie.description}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default HeritageSection;
@@ -0,0 +1,38 @@
import logoImage from "@/assets/logo.png";
import heroBakeryImage from "@/assets/hero-bakery-wide.jpg";
const HeroSection = () => {
return <section className="relative left-1/2 w-screen -translate-x-1/2 overflow-x-clip flex flex-col justify-center items-center text-center min-h-[85vh] pb-0 mb-0">
{/* Background image */}
<div className="pointer-events-none absolute inset-0 bg-cover bg-center" style={{
backgroundImage: `url(${heroBakeryImage})`,
opacity: 0.4,
maskImage: 'linear-gradient(to bottom, black 70%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 70%, transparent 100%)'
}} />
<div className="animate-fade-up max-w-4xl relative z-10 px-6 py-16">
<img alt="Lennart Svensson Konditorivaror" className="h-56 md:h-72 lg:h-80 w-auto mx-auto mb-1 mt-8" src="/lovable-uploads/8658a13b-f1d4-45db-b92b-3e22abb6ee1b.png" />
<p className="text-muted-foreground text-lg mb-4">
Svenskt konditorhantverk sedan 1953
</p>
<div className="w-40 h-px bg-primary/40 mx-auto my-4"></div>
<p className="max-w-xl mx-auto text-foreground text-xl mb-8 leading-relaxed">
Äkta fika levererat till ditt företag varje vecka
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href="#abonnemang" className="btn-classic inline-block px-10 py-4 text-lg rounded-sm">
Starta Prenumeration
</a>
<a href="#gallery" className="inline-block px-10 py-4 text-lg rounded-sm border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground transition-colors">
Sortiment
</a>
</div>
</div>
</section>;
};
export default HeroSection;
+19
View File
@@ -0,0 +1,19 @@
import logoImage from "@/assets/logo.png";
const Logo = ({ className = "", size = "default" }: { className?: string; size?: "small" | "default" | "large" }) => {
const sizeClasses = {
small: "h-16",
default: "h-24",
large: "h-40"
};
return (
<img
src={logoImage}
alt="Lennart Svensson Konditorivaror"
className={`${sizeClasses[size]} w-auto ${className}`}
/>
);
};
export default Logo;
+28
View File
@@ -0,0 +1,28 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };
@@ -0,0 +1,26 @@
import { useLocation } from "react-router-dom";
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
export function PaymentTestModeBanner() {
const { pathname } = useLocation();
// Guidad Felsökning har inga betalningar — bannern gäller bara butiken.
if (pathname.startsWith("/felsokning")) {
return null;
}
if (!clientToken) {
return (
<div className="w-full bg-red-100 border-b border-red-300 px-4 py-2 text-center text-sm text-red-800">
Skarpa betalningar är inte konfigurerade. Slutför go-live i Payments-fliken.
</div>
);
}
if (clientToken.startsWith("pk_test_")) {
return (
<div className="w-full bg-orange-100 border-b border-orange-300 px-4 py-2 text-center text-sm text-orange-800">
Alla betalningar i förhandsvisningen är i testläge. Använd kort <strong>4242 4242 4242 4242</strong>, valfritt framtida datum och CVC.
</div>
);
}
return null;
}
@@ -0,0 +1,707 @@
import { useState, useMemo, useEffect } from "react";
import { Loader2, CheckCircle2, CreditCard, FileText, ChevronsUpDown, Check, Lock, Eye, EyeOff, Sparkles } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { z } from "zod";
import { StripeEmbeddedCheckout } from "@/components/StripeEmbeddedCheckout";
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
import { cn } from "@/lib/utils";
const STORSTOCKHOLM_CITIES = [
"Stockholm", "Solna", "Sundbyberg", "Nacka", "Lidingö", "Danderyd",
"Täby", "Sollentuna", "Järfälla", "Upplands Väsby", "Sigtuna",
"Vallentuna", "Österåker", "Vaxholm", "Värmdö", "Tyresö", "Haninge",
"Nynäshamn", "Botkyrka", "Salem", "Södertälje", "Nykvarn", "Huddinge",
"Bromma", "Ekerö", "Upplands-Bro",
];
// Map första 3 siffrorna i postnummer → kommun/ort inom Storstockholm.
const POSTAL_PREFIX_TO_CITY: Record<string, string> = {
"100": "Stockholm", "101": "Stockholm", "102": "Stockholm", "103": "Stockholm",
"104": "Stockholm", "105": "Stockholm", "106": "Stockholm", "107": "Stockholm",
"108": "Stockholm", "110": "Stockholm", "111": "Stockholm", "112": "Stockholm",
"113": "Stockholm", "114": "Stockholm", "115": "Stockholm", "116": "Stockholm",
"117": "Stockholm", "118": "Stockholm", "119": "Stockholm", "120": "Stockholm",
"121": "Stockholm", "122": "Stockholm", "123": "Stockholm", "124": "Stockholm",
"125": "Stockholm", "126": "Stockholm", "127": "Stockholm", "128": "Stockholm",
"129": "Stockholm",
"131": "Nacka", "132": "Nacka", "133": "Nacka", "138": "Nacka",
"134": "Värmdö", "139": "Värmdö",
"135": "Tyresö",
"136": "Haninge", "137": "Haninge",
"141": "Huddinge", "142": "Huddinge", "143": "Huddinge",
"144": "Salem",
"145": "Botkyrka", "146": "Botkyrka", "147": "Botkyrka",
"148": "Nynäshamn", "149": "Nynäshamn",
"150": "Södertälje", "151": "Södertälje", "152": "Södertälje", "153": "Södertälje",
"155": "Nykvarn",
"168": "Bromma", "169": "Bromma",
"170": "Solna", "171": "Solna", "173": "Solna",
"172": "Sundbyberg", "174": "Sundbyberg",
"175": "Järfälla", "176": "Järfälla", "177": "Järfälla",
"178": "Ekerö", "179": "Ekerö",
"181": "Lidingö",
"182": "Danderyd",
"183": "Täby", "187": "Täby",
"184": "Österåker",
"185": "Vaxholm",
"186": "Vallentuna",
"191": "Sollentuna", "192": "Sollentuna",
"193": "Sigtuna", "195": "Sigtuna",
"194": "Upplands Väsby",
"196": "Upplands-Bro", "197": "Upplands-Bro",
};
const cityFromPostalCode = (postalCode: string): string | null => {
const cleaned = postalCode.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned)) return null;
return POSTAL_PREFIX_TO_CITY[cleaned.slice(0, 3)] ?? null;
};
const PRICE_PER_KG_EXKL = 325; // exkl. moms, används för Stripe/faktura
const VAT_RATE = 0.25;
const PRICE_PER_KG = PRICE_PER_KG_EXKL * (1 + VAT_RATE); // 406,25 kr ink moms visas för kunden
const KG_PER_EMPLOYEE = 0.3;
const WEEKS_PER_MONTH = 4;
const isStockholmPostalCode = (postalCode: string) => {
const cleaned = postalCode.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned)) return false;
const num = parseInt(cleaned, 10);
return num >= 10000 && num <= 19999;
};
const stockholmDeliveryMessage = "Vi levererar endast till företag inom Storstockholm.";
const formSchema = z.object({
företag: z.string().trim().min(1, "Fyll i företagsnamn").max(100),
epost: z.string().trim().email("Ange en giltig e-postadress").max(255),
telefon: z.string().trim().max(30).optional(),
adress: z.string().trim().min(1, "Fyll i leveransadress").max(200),
postnummer: z
.string()
.trim()
.min(1, "Fyll i postnummer")
.refine(isStockholmPostalCode, "Vi levererar endast inom Storstockholm (100 00 199 99)"),
stad: z.string().trim().min(1, "Fyll i stad").max(100),
kommentarer: z.string().trim().max(1000).optional(),
password: z.string().max(100).optional(),
});
type Method = "card" | "invoice";
const PlansSection = () => {
const { user } = useAuth();
const [employees, setEmployees] = useState(10);
const [showForm, setShowForm] = useState(false);
const [method, setMethod] = useState<Method>("card");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState<null | "card" | "invoice">(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [cityOpen, setCityOpen] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [form, setForm] = useState({
företag: "",
epost: "",
telefon: "",
adress: "",
postnummer: "",
stad: "",
kommentarer: "",
password: "",
});
const filteredCities = useMemo(() => {
const query = form.stad.trim().toLocaleLowerCase("sv-SE");
if (!query) return STORSTOCKHOLM_CITIES;
return STORSTOCKHOLM_CITIES.filter((city) =>
city.toLocaleLowerCase("sv-SE").includes(query)
);
}, [form.stad]);
const recommendedKg = useMemo(
() => Math.max(1, Math.round(employees * KG_PER_EMPLOYEE)),
[employees]
);
const weeklyPrice = recommendedKg * PRICE_PER_KG;
const monthlyPrice = weeklyPrice * WEEKS_PER_MONTH;
useEffect(() => {
if (!user || !showForm) return;
(async () => {
const { data } = await supabase
.from("profiles")
.select("company_name, phone, street_address, postal_code, city")
.eq("user_id", user.id)
.maybeSingle();
setForm((prev) => {
const postalCode = prev.postnummer || data?.postal_code || "";
const suggestedCity = cityFromPostalCode(postalCode);
return {
...prev,
företag: prev.företag || data?.company_name || "",
epost: prev.epost || user.email || "",
telefon: prev.telefon || data?.phone || "",
adress: prev.adress || data?.street_address || "",
postnummer: postalCode,
stad: suggestedCity || prev.stad || data?.city || "",
};
});
})();
}, [user, showForm]);
useEffect(() => {
const suggestedCity = cityFromPostalCode(form.postnummer);
if (!suggestedCity || form.stad === suggestedCity) return;
setForm((prev) => ({ ...prev, stad: suggestedCity }));
setErrors((prev) => ({ ...prev, stad: "", postnummer: "" }));
}, [form.postnummer, form.stad]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setForm((p) => {
const next = { ...p, [name]: value };
if (name === "postnummer") {
const suggested = cityFromPostalCode(value);
if (suggested) next.stad = suggested;
}
return next;
});
if (errors[name]) setErrors((p) => ({ ...p, [name]: "" }));
if (name === "postnummer") {
setErrors((p) => ({ ...p, postnummer: "", stad: "" }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = formSchema.safeParse(form);
if (!result.success) {
const fe: Record<string, string> = {};
result.error.errors.forEach((err) => {
if (err.path[0]) fe[err.path[0] as string] = err.message;
});
setErrors(fe);
return;
}
// Password required for guests (auto-create account)
if (!user) {
const pw = form.password || "";
if (pw.length < 8) {
setErrors((prev) => ({ ...prev, password: "Lösenordet måste vara minst 8 tecken" }));
return;
}
}
if (!hasPaymentsConfigured()) {
toast.error("Betalningar är inte konfigurerade ännu.");
return;
}
setSubmitting(true);
try {
// Auto-create account for guests, then sign in
if (!user && form.password) {
const { data: accData, error: accErr } = await supabase.functions.invoke("create-account", {
body: {
email: result.data.epost,
password: form.password,
company: result.data.företag,
phone: result.data.telefon ?? "",
},
});
if (accErr) throw accErr;
if ((accData as any)?.error) throw new Error((accData as any).error);
const { error: signInErr } = await supabase.auth.signInWithPassword({
email: result.data.epost,
password: form.password,
});
if (signInErr) {
if ((accData as any)?.existed) {
throw new Error(
"Ett konto finns redan för denna e-post. Fel lösenord — logga in eller använd 'Glömt lösenord'.",
);
}
throw signInErr;
}
}
const body = {
method,
employees,
kg_per_week: recommendedKg,
company: result.data.företag,
email: result.data.epost,
phone: result.data.telefon ?? "",
address: result.data.adress,
postal_code: result.data.postnummer,
city: result.data.stad,
comments: result.data.kommentarer ?? "",
return_url: `${window.location.origin}/order-confirmation?session_id={CHECKOUT_SESSION_ID}&method=card&kg=${recommendedKg}&email=${encodeURIComponent(result.data.epost)}`,
environment: getStripeEnvironment(),
};
const { data, error } = await supabase.functions.invoke("create-checkout", { body });
if (error) throw error;
if ((data as any)?.error) throw new Error((data as any).error);
if (method === "card") {
if (!(data as any)?.clientSecret) throw new Error("Kunde inte skapa checkout-session");
setClientSecret((data as any).clientSecret);
} else {
window.location.href = `/order-confirmation?method=invoice&kg=${recommendedKg}&email=${encodeURIComponent(result.data.epost)}`;
return;
}
} catch (err: any) {
console.error(err);
toast.error(err?.message || "Kunde inte starta abonnemang. Försök igen.");
} finally {
setSubmitting(false);
}
};
// Show embedded checkout after we get clientSecret
if (clientSecret) {
return (
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-2">
Slutför betalning
</h2>
<p className="text-muted-foreground text-sm">
{recommendedKg} kg/vecka · {monthlyPrice.toLocaleString("sv-SE")} kr/månad (ink moms)
</p>
</div>
<div className="bg-card border border-border rounded-sm p-4 md:p-6">
<StripeEmbeddedCheckout fetchClientSecret={async () => clientSecret} />
</div>
<div className="text-center mt-4">
<button
onClick={() => setClientSecret(null)}
className="text-sm text-muted-foreground underline hover:text-foreground"
>
Avbryt och tillbaka
</button>
</div>
</div>
</section>
);
}
return (
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-10 md:mb-14">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-4">
Hur många anställda har ni?
</h2>
<p className="text-muted-foreground text-base md:text-lg max-w-xl mx-auto">
Vi rekommenderar automatiskt rätt mängd kakor utifrån antalet medarbetare.
</p>
</div>
<div className="bg-card border border-border rounded-sm shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] p-6 md:p-12">
{submitted === "invoice" ? (
<div className="text-center py-8 animate-fade-in">
<CheckCircle2 className="w-16 h-16 text-primary mx-auto mb-4" />
<h3 className="font-display text-3xl md:text-4xl font-semibold mb-3">
Abonnemang startat!
</h3>
<p className="text-muted-foreground max-w-md mx-auto">
Vi har skapat ditt abonnemang {" "}
<strong className="text-foreground">
{recommendedKg} kg kakor per vecka
</strong>
. Första fakturan skickas till <strong>{form.epost}</strong> inom kort.
</p>
</div>
) : !showForm ? (
<>
<div className="text-center mb-8">
<div
key={employees}
className="font-display text-6xl md:text-8xl font-semibold text-primary tabular-nums animate-fade-in"
>
{employees}
</div>
<div className="text-muted-foreground text-sm md:text-base mt-2 tracking-wide uppercase">
{employees === 1 ? "anställd" : "anställda"}
</div>
</div>
<div className="mb-10 md:mb-12">
<input
type="range"
min={1}
max={100}
step={1}
value={employees}
onChange={(e) => setEmployees(parseInt(e.target.value, 10))}
className="calculator-slider w-full"
aria-label="Antal anställda"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-3">
<span>1</span>
<span>100</span>
</div>
</div>
<div className="text-center border-t border-border pt-8 md:pt-10 space-y-6">
<div>
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
Rekommenderad mängd
</div>
<div className="font-display text-4xl md:text-5xl font-semibold text-foreground tabular-nums">
{recommendedKg} kg
</div>
<div className="text-muted-foreground text-sm mt-1">per vecka</div>
</div>
<div>
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
Pris per månad
</div>
<div className="font-display text-3xl md:text-4xl font-semibold text-primary tabular-nums">
{monthlyPrice.toLocaleString("sv-SE")} kr
</div>
<div className="text-muted-foreground text-xs mt-1">
ink moms
</div>
</div>
<button
onClick={() => setShowForm(true)}
className="btn-classic w-full py-4 text-base md:text-lg rounded-sm mt-2"
>
Starta abonnemang
</button>
</div>
</>
) : (
<form onSubmit={handleSubmit} className="space-y-5 animate-fade-in">
<div className="text-center mb-2">
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-1">
Ditt abonnemang
</div>
<div className="font-display text-2xl md:text-3xl font-semibold">
{recommendedKg} kg/vecka {" "}
<span className="text-primary">
{monthlyPrice.toLocaleString("sv-SE")} kr/mån
</span>
</div>
<div className="text-muted-foreground text-xs">
ink moms · {employees} {employees === 1 ? "anställd" : "anställda"}
</div>
</div>
{/* Betalningsmetod */}
<div>
<label className="block mb-2 text-sm font-semibold">Betalningsmetod</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setMethod("card")}
className={`flex items-center gap-3 p-4 rounded-sm border text-left transition-colors ${
method === "card"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<CreditCard className="w-5 h-5 text-primary" />
<div>
<div className="font-semibold text-sm">Kort</div>
<div className="text-xs text-muted-foreground">
Automatiskt varje månad
</div>
</div>
</button>
<button
type="button"
onClick={() => setMethod("invoice")}
className={`flex items-center gap-3 p-4 rounded-sm border text-left transition-colors ${
method === "invoice"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<FileText className="w-5 h-5 text-primary" />
<div>
<div className="font-semibold text-sm">Faktura</div>
<div className="text-xs text-muted-foreground">
Månadsvis via e-post
</div>
</div>
</button>
</div>
</div>
<div className="border border-primary/30 bg-primary/5 px-4 py-3 rounded-sm text-sm text-foreground">
{stockholmDeliveryMessage} Ange postnummer 100 00199 99 för att vidare.
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">
Företagsnamn <span className="text-primary">*</span>
</label>
<input
name="företag"
value={form.företag}
onChange={handleChange}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{errors.företag && (
<p className="text-destructive text-xs mt-1">{errors.företag}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block mb-1.5 text-sm font-semibold">
E-post <span className="text-primary">*</span>
</label>
<input
type="email"
name="epost"
value={form.epost}
onChange={handleChange}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{errors.epost && (
<p className="text-destructive text-xs mt-1">{errors.epost}</p>
)}
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">Telefon</label>
<input
type="tel"
name="telefon"
value={form.telefon}
onChange={handleChange}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
{!user && (
<div className="relative overflow-hidden rounded-sm border border-primary/40 bg-gradient-to-br from-primary/[0.06] via-background to-primary/[0.03] p-5 md:p-6 shadow-[0_2px_20px_-8px_rgba(0,0,0,0.15)]">
<div className="absolute -top-10 -right-10 w-32 h-32 rounded-full bg-primary/10 blur-2xl pointer-events-none" />
<div className="relative flex items-start gap-3 mb-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/15 flex items-center justify-center border border-primary/30">
<Sparkles className="w-5 h-5 text-primary" />
</div>
<div>
<div className="font-display text-lg md:text-xl font-semibold text-foreground leading-tight">
Ditt konto skapas automatiskt
</div>
<p className="text-muted-foreground text-xs md:text-sm mt-1 leading-relaxed">
Välj ett lösenord loggar vi in dig direkt. Hantera ditt abonnemang, ändra leveranser och se fakturor i "Mitt konto" när som helst.
</p>
</div>
</div>
<label className="block mb-1.5 text-sm font-semibold text-foreground">
Välj lösenord <span className="text-primary">*</span>
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
name="password"
type={showPassword ? "text" : "password"}
value={form.password}
onChange={handleChange}
placeholder="Minst 8 tecken"
autoComplete="new-password"
aria-invalid={!!errors.password}
className="w-full pl-10 pr-11 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
aria-label={showPassword ? "Dölj lösenord" : "Visa lösenord"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password ? (
<p className="text-destructive text-xs mt-1.5">{errors.password}</p>
) : (
<p className="text-muted-foreground text-xs mt-1.5">
Använd minst 8 tecken. Vi rekommenderar en blandning av bokstäver och siffror.
</p>
)}
</div>
)}
<div>
<label className="block mb-1.5 text-sm font-semibold">
Leveransadress <span className="text-primary">*</span>
</label>
<input
name="adress"
placeholder="Gatuadress, t.ex. Kungsgatan 12"
value={form.adress}
onChange={handleChange}
aria-invalid={!!errors.adress}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{errors.adress && (
<p className="text-destructive text-xs mt-1">{errors.adress}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block mb-1.5 text-sm font-semibold">
Postnummer <span className="text-primary">*</span>
</label>
<input
name="postnummer"
placeholder="t.ex. 114 32"
value={form.postnummer}
onChange={handleChange}
aria-invalid={!!errors.postnummer}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{errors.postnummer && (
<p className="text-destructive text-xs mt-1">{errors.postnummer}</p>
)}
<p className="text-muted-foreground text-xs mt-1">
Endast postnummer inom Storstockholm.
</p>
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">
Stad <span className="text-primary">*</span>
</label>
<div className="relative">
<div className="relative">
<input
name="stad"
role="combobox"
aria-expanded={cityOpen}
aria-controls="city-options"
aria-autocomplete="list"
aria-invalid={!!errors.stad}
placeholder="Sök eller välj stad..."
value={form.stad}
onFocus={() => setCityOpen(true)}
onBlur={() => window.setTimeout(() => setCityOpen(false), 120)}
onChange={(e) => {
handleChange(e);
setCityOpen(true);
}}
className="w-full px-4 py-3 pr-10 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
type="button"
aria-label="Visa städer"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setCityOpen((open) => !open)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<ChevronsUpDown className="h-4 w-4" />
</button>
</div>
{cityOpen && (
<div
id="city-options"
role="listbox"
className="absolute z-50 mt-1 max-h-56 w-full overflow-auto rounded-sm border border-border bg-popover shadow-md"
>
{filteredCities.length > 0 ? (
filteredCities.map((city) => (
<button
key={city}
type="button"
role="option"
aria-selected={form.stad === city}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setForm((f) => ({ ...f, stad: city }));
setErrors((e) => ({ ...e, stad: "" }));
setCityOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground",
form.stad === city && "bg-accent text-accent-foreground"
)}
>
<Check className={cn("h-4 w-4", form.stad === city ? "opacity-100" : "opacity-0")} />
<span>{city}</span>
</button>
))
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
Ingen stad hittades.
</div>
)}
</div>
)}
</div>
{errors.stad && (
<p className="text-destructive text-xs mt-1">{errors.stad}</p>
)}
</div>
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">Kommentarer</label>
<textarea
name="kommentarer"
value={form.kommentarer}
onChange={handleChange}
rows={3}
className="w-full px-4 py-3 rounded-sm bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<button
type="button"
onClick={() => setShowForm(false)}
disabled={submitting}
className="sm:w-1/3 py-3 rounded-sm border border-border hover:bg-muted text-sm"
>
Tillbaka
</button>
<button
type="submit"
disabled={submitting}
className="btn-classic flex-1 py-3 rounded-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{method === "card" ? "Öppnar betalning..." : "Startar abonnemang..."}
</>
) : method === "card" ? (
"Fortsätt till betalning"
) : (
"Starta abonnemang (faktura)"
)}
</button>
</div>
<p className="text-center text-muted-foreground text-xs">
Alla priser inklusive moms (25%).
</p>
</form>
)}
</div>
</div>
</section>
);
};
export default PlansSection;
@@ -0,0 +1,16 @@
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js";
import { getStripe } from "@/lib/stripe";
interface Props {
fetchClientSecret: () => Promise<string>;
}
export function StripeEmbeddedCheckout({ fetchClientSecret }: Props) {
return (
<div id="checkout">
<EmbeddedCheckoutProvider stripe={getStripe()} options={{ fetchClientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
@@ -0,0 +1,69 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Package, Users, CreditCard, TrendingUp } from "lucide-react";
import { useAdminStats } from "@/hooks/useAdminData";
import { Skeleton } from "@/components/ui/skeleton";
export function AdminStats() {
const { data: stats, isLoading } = useAdminStats();
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
);
}
const statCards = [
{
title: "Totala Ordrar",
value: stats?.totalOrders || 0,
icon: Package,
description: `${stats?.pendingOrders || 0} väntande`,
},
{
title: "Kunder",
value: stats?.totalCustomers || 0,
icon: Users,
description: "Registrerade kunder",
},
{
title: "Betalningar",
value: stats?.totalPayments || 0,
icon: CreditCard,
description: `${stats?.completedPayments || 0} genomförda`,
},
{
title: "Total Omsättning",
value: `${(stats?.totalRevenue || 0).toLocaleString("sv-SE")} kr`,
icon: TrendingUp,
description: "Totalt intjänat",
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{statCards.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className="text-xs text-muted-foreground">{stat.description}</p>
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,95 @@
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { useCustomers } from "@/hooks/useAdminData";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { sv } from "date-fns/locale";
import { Search } from "lucide-react";
export function CustomersTable() {
const { data: customers, isLoading } = useCustomers();
const [search, setSearch] = useState("");
const filteredCustomers = customers?.filter((customer) => {
return (
customer.email.toLowerCase().includes(search.toLowerCase()) ||
customer.name?.toLowerCase().includes(search.toLowerCase()) ||
customer.company?.toLowerCase().includes(search.toLowerCase())
);
});
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök email, namn eller företag..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 max-w-md"
/>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kund</TableHead>
<TableHead>Företag</TableHead>
<TableHead>Telefon</TableHead>
<TableHead>Ordrar</TableHead>
<TableHead className="text-right">Totalt spenderat</TableHead>
<TableHead>Registrerad</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCustomers?.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Inga kunder hittades
</TableCell>
</TableRow>
) : (
filteredCustomers?.map((customer) => (
<TableRow key={customer.id}>
<TableCell>
<div>
<div className="font-medium">{customer.name || "—"}</div>
<div className="text-sm text-muted-foreground">{customer.email}</div>
</div>
</TableCell>
<TableCell>{customer.company || "—"}</TableCell>
<TableCell>{customer.phone || "—"}</TableCell>
<TableCell>{customer.total_orders}</TableCell>
<TableCell className="text-right font-medium">
{Number(customer.total_spent).toLocaleString("sv-SE")} kr
</TableCell>
<TableCell>
{format(new Date(customer.created_at), "d MMM yyyy", { locale: sv })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,399 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Sparkles, RefreshCw, Building2, Users, MapPin, Mail, Search, Copy, Check, Send } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Skeleton } from "@/components/ui/skeleton";
interface Lead {
company_name: string;
industry: string;
employee_count: number;
district: string;
reason: string;
}
interface EmailContent {
subject: string;
body: string;
personalization_points: string[];
}
interface ResearchResult {
company_name: string;
research_summary: string;
email: EmailContent;
}
export function LeadsGenerator() {
const [leads, setLeads] = useState<Lead[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [researchingLead, setResearchingLead] = useState<string | null>(null);
const [emailDialog, setEmailDialog] = useState<ResearchResult | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [recipient, setRecipient] = useState("");
const [sending, setSending] = useState(false);
const { toast } = useToast();
useEffect(() => {
setRecipient("");
}, [emailDialog?.company_name]);
const sendEmail = async () => {
if (!emailDialog || !recipient) return;
setSending(true);
try {
const res = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/send-outreach-email`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
},
body: JSON.stringify({
to_email: recipient,
to_name: emailDialog.company_name,
subject: emailDialog.email.subject,
body: emailDialog.email.body,
}),
},
);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Kunde inte skicka mejl");
toast({
title: "Mejl skickat!",
description: `Skickat från konditorivaror@gmail.com till ${recipient}`,
});
setEmailDialog(null);
} catch (err) {
toast({
title: "Fel vid utskick",
description: err instanceof Error ? err.message : "Okänt fel",
variant: "destructive",
});
} finally {
setSending(false);
}
};
const generateLeads = async () => {
setIsLoading(true);
try {
const response = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/generate-leads`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
},
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Kunde inte generera leads");
}
const data = await response.json();
setLeads(data.leads || []);
toast({
title: "Leads genererade!",
description: `${data.leads?.length || 0} potentiella kunder hittades.`,
});
} catch (error) {
console.error("Error generating leads:", error);
toast({
title: "Fel",
description: error instanceof Error ? error.message : "Kunde inte generera leads",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const researchCompany = async (lead: Lead) => {
setResearchingLead(lead.company_name);
try {
const response = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/research-company`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
},
body: JSON.stringify({
company_name: lead.company_name,
district: lead.district,
industry: lead.industry,
}),
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Kunde inte researcha företaget");
}
const data: ResearchResult = await response.json();
setEmailDialog(data);
toast({
title: "Research klar!",
description: `Personligt mejl genererat för ${lead.company_name}`,
});
} catch (error) {
console.error("Error researching company:", error);
toast({
title: "Fel",
description: error instanceof Error ? error.message : "Kunde inte researcha företaget",
variant: "destructive",
});
} finally {
setResearchingLead(null);
}
};
const copyToClipboard = async (text: string, field: string) => {
await navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
toast({
title: "Kopierat!",
description: `${field} kopierat till urklipp`,
});
};
const getEmployeeCountBadge = (count: number) => {
if (count >= 100) return "bg-green-100 text-green-800";
if (count >= 50) return "bg-blue-100 text-blue-800";
if (count >= 25) return "bg-yellow-100 text-yellow-800";
return "bg-gray-100 text-gray-800";
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Kundprospektering
</CardTitle>
<CardDescription>
Generera leads och skapa personliga mejl med AI-driven research
</CardDescription>
</div>
<Button onClick={generateLeads} disabled={isLoading}>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Genererar...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
Generera leads
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : leads.length > 0 ? (
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<div className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
Företag
</div>
</TableHead>
<TableHead>Bransch</TableHead>
<TableHead>
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
Anställda
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
Stadsdel
</div>
</TableHead>
<TableHead>Varför bra kund</TableHead>
<TableHead className="text-right">Åtgärd</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leads.map((lead, index) => (
<TableRow key={index}>
<TableCell className="font-medium">{lead.company_name}</TableCell>
<TableCell>
<Badge variant="outline">{lead.industry}</Badge>
</TableCell>
<TableCell>
<Badge className={getEmployeeCountBadge(lead.employee_count)}>
{lead.employee_count} st
</Badge>
</TableCell>
<TableCell>{lead.district}</TableCell>
<TableCell className="max-w-xs text-sm text-muted-foreground">
{lead.reason}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
onClick={() => researchCompany(lead)}
disabled={researchingLead === lead.company_name}
>
{researchingLead === lead.company_name ? (
<>
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Researchar...
</>
) : (
<>
<Search className="h-3 w-3 mr-1" />
Research & Mejl
</>
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Klicka "Generera leads" för att hitta potentiella kunder</p>
</div>
)}
</CardContent>
</Card>
<Dialog open={!!emailDialog} onOpenChange={() => setEmailDialog(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Personligt mejl för {emailDialog?.company_name}
</DialogTitle>
<DialogDescription>
AI-genererat mejl baserat företagsresearch
</DialogDescription>
</DialogHeader>
{emailDialog && (
<div className="space-y-4">
{emailDialog.research_summary && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-xs font-medium text-muted-foreground mb-1">Research-sammanfattning:</p>
<p className="text-sm">{emailDialog.research_summary.substring(0, 300)}...</p>
</div>
)}
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium">Ämnesrad:</label>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(emailDialog.email.subject, "Ämnesrad")}
>
{copiedField === "Ämnesrad" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<div className="p-3 bg-background border rounded-md">
<p className="font-medium">{emailDialog.email.subject}</p>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium">Mejltext:</label>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(emailDialog.email.body, "Mejltext")}
>
{copiedField === "Mejltext" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<div className="p-3 bg-background border rounded-md whitespace-pre-wrap">
{emailDialog.email.body}
</div>
</div>
{emailDialog.email.personalization_points?.length > 0 && (
<div>
<label className="text-sm font-medium mb-2 block">Personaliseringspunkter:</label>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{emailDialog.email.personalization_points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
<div className="pt-4 border-t space-y-2">
<label className="text-sm font-medium">Skicka till (mottagarens mejl):</label>
<div className="flex gap-2">
<Input
type="email"
placeholder="kontakt@företag.se"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
disabled={sending}
/>
<Button
onClick={sendEmail}
disabled={sending || !recipient || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient)}
>
{sending ? (
<><RefreshCw className="h-4 w-4 mr-2 animate-spin" /> Skickar...</>
) : (
<><Send className="h-4 w-4 mr-2" /> Skicka mejl</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Skickas från <strong>konditorivaror@gmail.com</strong> som Tiffany.
</p>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,162 @@
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useOrders, Order } from "@/hooks/useAdminData";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { sv } from "date-fns/locale";
import { Search } from "lucide-react";
const statusColors: Record<string, string> = {
pending: "bg-yellow-500/20 text-yellow-700 border-yellow-500/30",
processing: "bg-blue-500/20 text-blue-700 border-blue-500/30",
completed: "bg-green-500/20 text-green-700 border-green-500/30",
cancelled: "bg-red-500/20 text-red-700 border-red-500/30",
};
const statusLabels: Record<string, string> = {
pending: "Väntande",
processing: "Behandlas",
completed: "Slutförd",
cancelled: "Avbruten",
};
export function OrdersTable() {
const { data: orders, isLoading } = useOrders();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredOrders = orders?.filter((order) => {
const matchesSearch =
order.order_number.toLowerCase().includes(search.toLowerCase()) ||
order.customer_email.toLowerCase().includes(search.toLowerCase()) ||
order.customer_name?.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
return matchesSearch && matchesStatus;
});
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök ordernummer, email eller namn..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Filtrera status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla statusar</SelectItem>
<SelectItem value="pending">Väntande</SelectItem>
<SelectItem value="processing">Behandlas</SelectItem>
<SelectItem value="completed">Slutförd</SelectItem>
<SelectItem value="cancelled">Avbruten</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ordernummer</TableHead>
<TableHead>Kund</TableHead>
<TableHead>Leveransadress</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders?.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Inga ordrar hittades
</TableCell>
</TableRow>
) : (
filteredOrders?.map((order) => {
const item = Array.isArray(order.items) ? order.items[0] : null;
const lev = item?.leverans;
const phone = item?.telefon;
const comments = item?.kommentarer;
return (
<TableRow key={order.id}>
<TableCell className="font-medium align-top">{order.order_number}</TableCell>
<TableCell className="align-top">
<div>
<div className="font-medium">{order.customer_name || "—"}</div>
<div className="text-sm text-muted-foreground">{order.customer_email}</div>
{phone && <div className="text-sm text-muted-foreground">{phone}</div>}
</div>
</TableCell>
<TableCell className="align-top">
{lev ? (
<div className="text-sm">
<div>{lev.adress || "—"}</div>
<div className="text-muted-foreground">
{[lev.postnummer, lev.stad].filter(Boolean).join(" ")}
</div>
{comments && (
<div className="text-xs text-muted-foreground mt-1 italic">
"{comments}"
</div>
)}
</div>
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell className="align-top">
{format(new Date(order.created_at), "d MMM yyyy, HH:mm", { locale: sv })}
</TableCell>
<TableCell className="align-top">
<Badge variant="outline" className={statusColors[order.status] || ""}>
{statusLabels[order.status] || order.status}
</Badge>
</TableCell>
<TableCell className="text-right font-medium align-top">
{Number(order.total_amount).toLocaleString("sv-SE")} {order.currency}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { usePayments } from "@/hooks/useAdminData";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { sv } from "date-fns/locale";
const statusColors: Record<string, string> = {
pending: "bg-yellow-500/20 text-yellow-700 border-yellow-500/30",
completed: "bg-green-500/20 text-green-700 border-green-500/30",
failed: "bg-red-500/20 text-red-700 border-red-500/30",
refunded: "bg-purple-500/20 text-purple-700 border-purple-500/30",
};
const statusLabels: Record<string, string> = {
pending: "Väntande",
completed: "Genomförd",
failed: "Misslyckad",
refunded: "Återbetald",
};
export function PaymentsTable() {
const { data: payments, isLoading } = usePayments();
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredPayments = payments?.filter((payment) => {
return statusFilter === "all" || payment.status === statusFilter;
});
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filtrera status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla statusar</SelectItem>
<SelectItem value="pending">Väntande</SelectItem>
<SelectItem value="completed">Genomförd</SelectItem>
<SelectItem value="failed">Misslyckad</SelectItem>
<SelectItem value="refunded">Återbetald</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Betalnings-ID</TableHead>
<TableHead>Betalmetod</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPayments?.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Inga betalningar hittades
</TableCell>
</TableRow>
) : (
filteredPayments?.map((payment) => (
<TableRow key={payment.id}>
<TableCell className="font-mono text-sm">
{payment.id.slice(0, 8)}...
</TableCell>
<TableCell>{payment.payment_method || "—"}</TableCell>
<TableCell>
{format(new Date(payment.created_at), "d MMM yyyy, HH:mm", { locale: sv })}
</TableCell>
<TableCell>
<Badge variant="outline" className={statusColors[payment.status] || ""}>
{statusLabels[payment.status] || payment.status}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{Number(payment.amount).toLocaleString("sv-SE")} {payment.currency}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,104 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
@@ -0,0 +1,43 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
@@ -0,0 +1,38 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
@@ -0,0 +1,29 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
@@ -0,0 +1,90 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -0,0 +1,54 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
@@ -0,0 +1,224 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
+303
View File
@@ -0,0 +1,303 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
@@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
@@ -0,0 +1,132 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -0,0 +1,178 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
@@ -0,0 +1,95 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,87 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} />
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -0,0 +1,179 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+129
View File
@@ -0,0 +1,129 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
@@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
@@ -0,0 +1,61 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<React.ElementRef<typeof OTPInput>, React.ComponentPropsWithoutRef<typeof OTPInput>>(
({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
),
);
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center", className)} {...props} />,
);
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
),
);
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,17 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,207 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn("flex h-10 items-center space-x-1 rounded-md border bg-background p-1", className)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};
@@ -0,0 +1,120 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};
@@ -0,0 +1,81 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};
@@ -0,0 +1,29 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
@@ -0,0 +1,23 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
@@ -0,0 +1,36 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
@@ -0,0 +1,37 @@
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,38 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+143
View File
@@ -0,0 +1,143 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
@@ -0,0 +1,20 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+107
View File
@@ -0,0 +1,107 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};
@@ -0,0 +1,637 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
},
);
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };
@@ -0,0 +1,23 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
@@ -0,0 +1,27 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster, toast };
@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
@@ -0,0 +1,72 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
);
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
),
);
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
),
);
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", className)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
),
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
),
);
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
),
);
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };
+111
View File
@@ -0,0 +1,111 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors group-[.destructive]:border-muted/40 hover:bg-secondary group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-[.destructive]:focus:ring-destructive disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-foreground group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:outline-none focus:ring-2 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
@@ -0,0 +1,24 @@
import { useToast } from "@/hooks/use-toast";
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}
@@ -0,0 +1,49 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root ref={ref} className={cn("flex items-center justify-center gap-1", className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
@@ -0,0 +1,37 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root ref={ref} className={cn(toggleVariants({ variant, size, className }))} {...props} />
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };
@@ -0,0 +1,28 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,3 @@
import { useToast, toast } from "@/hooks/use-toast";
export { useToast, toast };
@@ -0,0 +1,69 @@
// Visar en bilaga oavsett om innehållet ligger inbäddat i händelsen
// (äldre ärenden, lokalt läge) eller hämtas som referens.
import { useEffect, useState } from "react";
import type { Bilaga } from "./domain";
import { hamtaBilaga } from "./bilagor";
function useKalla(bilaga: Bilaga, delningskod?: string): string | null | "laddar" {
const [kalla, setKalla] = useState<string | null | "laddar">("laddar");
const id = bilaga.bilagaId ?? bilaga.dataUrl?.slice(0, 64) ?? "";
useEffect(() => {
let aktuell = true;
setKalla("laddar");
hamtaBilaga(bilaga, delningskod)
.then((url) => aktuell && setKalla(url))
.catch(() => aktuell && setKalla(null));
return () => {
aktuell = false;
};
// Bilagan identifieras av sitt id — objektet självt byts vid varje
// omrendering utan att innehållet ändrats.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, delningskod]);
return kalla;
}
function Platshallare({ text }: { text: string }) {
return (
<p className="rounded border border-dashed border-[#C6C6C6] bg-[#F7F7F7] p-3 text-[12px] text-[#707070]">
{text}
</p>
);
}
export function Bild({
bilaga,
alt,
className,
delningskod,
}: {
bilaga: Bilaga;
alt: string;
className?: string;
delningskod?: string;
}) {
const kalla = useKalla(bilaga, delningskod);
if (kalla === "laddar") return <Platshallare text="Hämtar bild …" />;
// Ärligt om att bilden inte gick att hämta, i stället för en trasig
// bildikon som lämnar teknikern i tvivel om vad som dokumenterats.
if (!kalla) return <Platshallare text="Bilden kunde inte hämtas." />;
return <img src={kalla} alt={alt} className={className} />;
}
export function Klipp({
bilaga,
className,
delningskod,
}: {
bilaga: Bilaga;
className?: string;
delningskod?: string;
}) {
const kalla = useKalla(bilaga, delningskod);
if (kalla === "laddar") return <Platshallare text="Hämtar videoklipp …" />;
if (!kalla) return <Platshallare text="Videoklippet kunde inte hämtas." />;
return <video src={kalla} controls className={className} />;
}
@@ -0,0 +1,283 @@
// Gemensam Live Share-vy: används både av den interna förhandsvisningen
// (läser ur lokala storen) och den publika delningssidan (läser via
// delningskod genom backend). Skrivskyddad; interna poster (kategoribyten,
// hypoteser) ingår aldrig i underlaget som når hit externt, och filtreras
// bort även lokalt.
import { useState } from "react";
import type { Arende } from "./domain";
import { KUNDBESLUT_LABEL, handelseRubrik } from "./domain";
import { arAvslutat, arendeidentitet, brief, foton, tidsfordelningsRader, videor } from "./projektioner";
import { metodikForArende } from "./store";
import { Bild, Klipp } from "./Bilagevisning";
import { tidDatum, tidKlockslag } from "./format";
import { FelsokningSkal, Panel, StorKnapp } from "./ui";
import { IkonCheck, IkonKlocka, IkonUppdatera } from "./ikoner";
// Kundens svar på åtgärdsförslaget — den enda skrivande åtgärden i hela
// delningsvyn. Beskedet kan lämnas en gång och kan inte ändras här.
function BeslutsKnappar({
vidBeslut,
}: {
vidBeslut: (beslut: "godkant" | "avbojt", kommentar: string) => Promise<string | null>;
}) {
const [val, setVal] = useState<"" | "godkant" | "avbojt">("");
const [kommentar, setKommentar] = useState("");
const [skickar, setSkickar] = useState(false);
const [fel, setFel] = useState("");
return (
<div className="mt-2 border-t border-[#DDDDDD] pt-2 print:hidden">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Ditt besked till verkstaden
</p>
<div className="mb-2 grid grid-cols-2 gap-2">
<StorKnapp variant={val === "godkant" ? "primar" : "sekundar"} onClick={() => setVal("godkant")}>
Godkänn åtgärden
</StorKnapp>
<StorKnapp variant={val === "avbojt" ? "fara" : "sekundar"} onClick={() => setVal("avbojt")}>
Avböj
</StorKnapp>
</div>
{val && (
<>
<label className="mb-2 block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
{val === "avbojt" ? "Kort motivering (valfritt)" : "Kommentar (valfritt)"}
</span>
<input
value={kommentar}
maxLength={500}
onChange={(e) => setKommentar(e.target.value)}
className="w-full rounded border border-[#ADADAD] bg-white px-2.5 py-1.5 text-[13px] focus:border-[#00437A] focus:outline-none"
/>
</label>
{fel && <p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">{fel}</p>}
<StorKnapp
disabled={skickar}
onClick={async () => {
setSkickar(true);
setFel("");
const felmeddelande = await vidBeslut(val, kommentar);
if (felmeddelande) setFel(felmeddelande);
setSkickar(false);
}}
>
{skickar ? "Skickar …" : "Skicka besked"}
</StorKnapp>
<p className="mt-1 text-[11px] text-[#707070]">
Beskedet registreras i ärendet och kan inte ändras här kontakta verkstaden om du ändrar dig.
</p>
</>
)}
</div>
);
}
function IdentitetsPanel({ arende, avslutat }: { arende: Arende; avslutat: boolean }) {
const idn = arendeidentitet(arende);
const falt: [string, string | undefined][] = [
["Arbetsorder", idn.arbetsorder],
["Claim", idn.claim],
["Skadenr", idn.skadenummer],
["Regnr", idn.identifierare],
["VIN", idn.vin],
["Miltal", idn.miltal],
["Ansvarig tekniker", idn.ansvarig],
["Status", avslutat ? "Avslutat" : "Felsökning pågår"],
];
return (
<div className="sticky top-11 z-10 mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] px-3 py-1.5 shadow-[0_1px_3px_rgba(0,0,0,0.1)] print:static">
{idn.beskrivning && <p className="text-[13px] font-semibold">{idn.beskrivning}</p>}
<p className="flex flex-wrap gap-x-4 gap-y-0.5 text-[12px]">
{falt
.filter(([, varde]) => varde)
.map(([etikett, varde]) => (
<span key={etikett} className="whitespace-nowrap">
<span className="text-[#707070]">{etikett}:</span> <span className="font-medium">{varde}</span>
</span>
))}
</p>
</div>
);
}
export function DelatArendeVy({
arende,
nu,
notis,
redanFiltrerad = false,
vidBeslut,
delningskod,
}: {
arende: Arende;
nu: string;
notis: string;
// Satt i den publika vyn: bilagor hämtas då via delningens egen väg,
// som filtrerar på samma behörighetsnivå som händelserna.
delningskod?: string;
// Satt endast på den publika kundlänken: kunden kan svara på ett
// åtgärdsförslag. Utan den är vyn helt skrivskyddad.
vidBeslut?: (beslut: "godkant" | "avbojt", kommentar: string) => Promise<string | null>;
// Sant när servern redan filtrerat per behörighetsnivå (publik delning) —
// då renderas händelserna som de kom, inklusive t.ex. hypoteser på
// partner-/internnivå.
redanFiltrerad?: boolean;
}) {
const metodik = metodikForArende(arende);
const b = brief(arende, metodik, nu);
const avslutat = arAvslutat(arende);
const bilder = foton(arende);
const klipp = videor(arende);
// Åtgärdsförslaget är avsett för kunden — det visas alltid överst i
// delningsvyn, tillsammans med det besked som registrerats.
const forslag = arende.handelser.filter((p) => p.handelse.typ === "atgardsforslag");
const beslut = [...arende.handelser].reverse().find((p) => p.handelse.typ === "kundbeslut");
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
const kundposter = redanFiltrerad
? arende.handelser
: arende.handelser.filter((p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"].includes(p.handelse.typ));
const pagaende = b.rekommenderatNastaSteg[0];
return (
<FelsokningSkal
rubrik={b.objekt?.beskrivning ?? "Ärende"}
hoger={
<span
className={`rounded px-3 py-1 text-[12px] font-semibold uppercase ${
avslutat ? "bg-[#3E5A78] text-white" : "bg-[#1E6B34] text-white"
}`}
>
{avslutat ? "Avslutat" : "Felsökning pågår"}
</span>
}
>
{/* Ärendeidentiteten: låst överst — mottagaren (kund, försäkrings-
handläggare, kollega) ska aldrig tveka om vilket fordon och
ärende vyn avser. Härledd ur det nivåfiltrerade underlaget. */}
<IdentitetsPanel arende={arende} avslutat={avslutat} />
<p className="mb-4 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-3 text-[12px] text-[#4A5560] print:hidden">
{notis}
</p>
{b.felbeskrivning && (
<Panel rubrik="Kundens felbeskrivning">
<p className="text-[14px]">{b.felbeskrivning}</p>
</Panel>
)}
{forslag.length > 0 && (
<Panel rubrik="Åtgärdsförslag">
{forslag.map((p) => {
const h = p.handelse;
if (h.typ !== "atgardsforslag") return null;
return (
<div key={p.id} className="mb-2 last:mb-0">
<p className="text-[14px]">{h.beskrivning}</p>
{h.uppskattadKostnad && (
<p className="text-[13px] text-[#4A5560]">Uppskattad kostnad: {h.uppskattadKostnad}</p>
)}
</div>
);
})}
{beslut?.handelse.typ === "kundbeslut" ? (
<p className="mt-2 border-t border-[#DDDDDD] pt-2 text-[13px] font-semibold">
Ditt besked: {KUNDBESLUT_LABEL[beslut.handelse.beslut]}{" "}
<span className="font-normal text-[#4A5560]">(registrerat via {beslut.handelse.kanal})</span>
</p>
) : vidBeslut ? (
<BeslutsKnappar vidBeslut={vidBeslut} />
) : (
<p className="mt-2 border-t border-[#DDDDDD] pt-2 text-[12px] text-[#707070]">
Inväntar ditt besked kontakta verkstaden för att godkänna eller avböja.
</p>
)}
</Panel>
)}
<Panel rubrik="Aktuell status">
{b.utfordaKontroller.map((k, i) => (
<p key={`u${i}`} className="py-0.5 text-[14px]"><span className="text-[#1E6B34]"><IkonCheck /></span> {k.text}</p>
))}
{!avslutat && pagaende && <p className="py-0.5 text-[14px] text-[#00437A]"><IkonUppdatera /> {pagaende}</p>}
{b.ejKontrollerat.slice(avslutat ? 0 : 1).map((e, i) => (
<p key={`e${i}`} className="py-0.5 text-[14px] text-[#4A5560]"><IkonKlocka /> {e}</p>
))}
</Panel>
{klipp.length > 0 && (
<Panel rubrik="Video">
{klipp.map((v, i) => (
<figure key={i} className="mb-2">
<Klipp bilaga={v.bilaga} delningskod={delningskod} className="w-full rounded border border-[#C6C6C6]" />
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{v.beskrivning}</figcaption>
</figure>
))}
</Panel>
)}
{bilder.length > 0 && (
<Panel rubrik="Bilder">
<div className="grid grid-cols-2 gap-2">
{bilder.map((bild, i) => (
<figure key={i}>
<Bild bilaga={bild.bilaga} alt={bild.beskrivning} delningskod={delningskod} className="rounded border border-[#C6C6C6]" />
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{bild.beskrivning}</figcaption>
</figure>
))}
</div>
</Panel>
)}
{matvarden.length > 0 && (
<Panel rubrik="Mätvärden">
<table className="w-full text-[14px]">
<tbody>
{matvarden.map((p) => {
const h = p.handelse;
if (h.typ !== "matvarde") return null;
return (
<tr key={p.id} className="border-b border-[#DDDDDD] last:border-0">
<td className="py-1 pr-3 text-[#333333]">{h.beskrivning}</td>
<td className="py-1 font-semibold">
{h.varde}
{h.enhet ? ` ${h.enhet}` : ""}
</td>
</tr>
);
})}
</tbody>
</table>
</Panel>
)}
<Panel rubrik="Tidslinje">
{kundposter.map((post) => (
<p key={post.id} className="py-0.5 text-[13px]">
<span className="font-mono font-semibold text-[#00437A]">{tidKlockslag(post.tidpunkt)}</span>{" "}
{handelseRubrik(post)}
</p>
))}
</Panel>
{!avslutat && pagaende && (
<Panel rubrik="Rekommenderat nästa steg">
<p className="text-[14px]">{pagaende}</p>
</Panel>
)}
<Panel rubrik="Arbetstid">
<p className="text-[17px] font-semibold">{b.totalArbetstid}</p>
{tidsfordelningsRader(arende, nu).map((r) => (
<p key={r.label} className="text-[#333333]">
{r.label}: {r.tid}
</p>
))}
</Panel>
<p className="text-center text-[11px] text-[#8A8A8A]">
Ärende #{arende.nummer} · startat {tidDatum(arende.skapad)} · genererad ur ärendets händelselogg
</p>
</FelsokningSkal>
);
}
@@ -0,0 +1,66 @@
// Push-to-Talk-mikrofon: lyssnar ENDAST efter aktivt tryck, visar tydligt
// när inspelning pågår och skriver in transkriberingen i ett redigerbart
// fält. Skicka sker aldrig automatiskt.
import { useEffect, useRef, useState } from "react";
import { startaIgenkanning, stodRost } from "./rost";
import { IkonMik } from "./ikoner";
export function MikrofonKnapp({ paText }: { paText: (text: string) => void }) {
const [lyssnar, setLyssnar] = useState(false);
const [interim, setInterim] = useState("");
const igenkanningRef = useRef<ReturnType<typeof startaIgenkanning>>(undefined);
const paTextRef = useRef(paText);
paTextRef.current = paText;
useEffect(() => () => igenkanningRef.current?.abort(), []);
if (!stodRost()) return null;
const stoppa = () => {
igenkanningRef.current?.stop();
igenkanningRef.current = undefined;
setLyssnar(false);
setInterim("");
};
const starta = () => {
setLyssnar(true);
igenkanningRef.current = startaIgenkanning(
({ transkript, slutgiltigt }) => {
if (slutgiltigt) {
setInterim("");
paTextRef.current(transkript.trim());
} else {
setInterim(transkript);
}
},
() => {
setLyssnar(false);
setInterim("");
},
);
};
return (
<span className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => (lyssnar ? stoppa() : starta())}
aria-label={lyssnar ? "Stoppa inspelning" : "Starta röstinmatning"}
className={`min-h-8 min-w-11 rounded border px-3 text-[14px] font-semibold transition-colors ${
lyssnar
? "animate-pulse border-[#6E1414] bg-[#8B1A1A] text-white"
: "border-[#ADADAD] bg-white text-[#1A1A1A] hover:border-[#00437A]"
}`}
>
{lyssnar ? "■" : <IkonMik />}
</button>
{lyssnar && (
<span className="text-[12px] font-semibold text-[#8B1A1A]">
Inspelning pågår{interim ? ` — ”${interim}` : "…"}
</span>
)}
</span>
);
}
@@ -0,0 +1,203 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { byggAnvandarPrompt, byggGranskningsPrompt, normaliseraArbetsorder, tolkaAiSvar, ARBETSORDER_FALT } from "../ai";
import { VIBRATION_METODIK } from "../metodik";
import { brief } from "../projektioner";
import { byggDemoArende } from "../demo";
const ENDPOINT = readFileSync("../supabase/functions/felsokning-ai/index.ts", "utf8");
describe("AI-orkestern", () => {
it("plattformens endpoint routar uppgifter till olika modeller", () => {
// Servern äger orkestern: modellval, effort, systemprompt och schema.
for (const uppgift of ["handledning:", "granskning:", "sammanfattning:", "metodikval:", "dokumenttolkning:"]) {
expect(ENDPOINT).toContain(uppgift);
}
expect(ENDPOINT).toContain('"claude-sonnet-5"');
expect(ENDPOINT).toContain('"claude-opus-5"');
expect(ENDPOINT).toContain('"claude-haiku-4-5"');
expect(ENDPOINT).toContain("ANTHROPIC_API_KEY");
});
it("K8s-tjänsten (services/ai-orkester) kör samma orkester som edge-funktionen", () => {
const tjanst = readFileSync("../services/ai-orkester/server.mjs", "utf8");
for (const bit of [
'"claude-sonnet-5"',
'"claude-opus-5"',
'"claude-haiku-4-5"',
"handledning:",
"granskning:",
"sammanfattning:",
"metodikval:",
"dokumenttolkning:",
"Hitta aldrig på fakta",
"SUPABASE_JWT_SECRET",
]) {
expect(tjanst).toContain(bit);
}
});
it("självhostade stacken: plattformstjänst + append-only i databasen", () => {
const plattform = readFileSync("../services/plattform/server.mjs", "utf8");
for (const bit of [
"/api/auth/logga-in",
"/api/auth/registrera",
"/api/arenden",
String.raw`\/api\/delad\/`,
"gen_salt('bf')",
"JWT_SECRET",
]) {
expect(plattform).toContain(bit);
}
// API:t exponerar medvetet inga update/delete-operationer.
expect(plattform).not.toMatch(/\b(update|delete)\s+felsokning_handelser/i);
// Multi-tenant: all ärendedata är organisationsknuten.
expect(plattform).toContain("organisation_id");
expect(plattform).toContain("arendeIOrg");
// Kontospärr och återkallelse: en giltig signatur räcker inte.
expect(plattform).toContain("kontoGiltigt");
expect(plattform).toContain("token_version");
expect(plattform).toContain("inloggningSparrad");
const schema = readFileSync("../infra/postgres-init.sql", "utf8");
expect(schema).toContain("create table if not exists inloggningsforsok");
expect(schema).toContain("create table if not exists bilagor");
expect(schema).toContain("create table if not exists bilage_innehall");
expect(schema).toContain("token_version integer not null default 0");
expect(schema).toContain("before update or delete on felsokning_handelser");
expect(schema).toContain("before update or delete on felsokning_arenden");
expect(schema).toContain("create table if not exists organisationer");
expect(schema).toContain("check (roll in ('tekniker', 'arbetsledare', 'admin'))");
});
it("OpenAPI-specen och plattformsservern täcker samma endpoints", () => {
const spec = readFileSync("../services/plattform/openapi.yaml", "utf8");
const server = readFileSync("../services/plattform/server.mjs", "utf8");
// Varje dokumenterad väg finns i servern …
const vagar: [string, string][] = [
["/halsa", "/halsa"],
["/api/openapi.yaml", "/api/openapi.yaml"],
["/api/auth/registrera", "/api/auth/registrera"],
["/api/auth/logga-in", "/api/auth/logga-in"],
["/api/anvandare", "/api/anvandare"],
["/api/ecm/regler", "/api/ecm/regler"],
["/api/organisation", "/api/organisation"],
["/api/organisation/installningar", "/api/organisation/installningar"],
["/api/arenden", "/api/arenden"],
["/api/arenden/{arendeId}/handelser", "handelser"],
["/api/fordon/{identifierare}/historik", "historik"],
["/api/statistik/felorsaker", "/api/statistik/felorsaker"],
["/api/oversikt", "/api/oversikt"],
["/api/delad/{delningskod}", "delad"],
["/api/delad/{delningskod}/beslut", "beslut"],
["/api/integrationer", "/api/integrationer"],
["/api/integrationer/leverantorer", "/api/integrationer/leverantorer"],
["/api/integrationer/{leverantor}", "integrationVag"],
["/api/integrationer/{leverantor}/uppslag", "uppslagVag"],
["/api/anvandare/{anvandarId}/avaktivera", "kontoVag"],
["/api/anvandare/{anvandarId}/aktivera", "aktivera"],
["/api/auth/logga-ut-alla", "/api/auth/logga-ut-alla"],
["/api/arenden/{arendeId}/bilagor", "laddaUppVag"],
["/api/bilagor/{bilagaId}", "bilagaVag"],
["/api/delad/{delningskod}/bilagor/{bilagaId}", "delatBilaga"],
];
for (const [iSpec, iServer] of vagar) {
expect(spec).toContain(`${iSpec}:`);
expect(server).toContain(iServer);
}
// … och AI-endpointen + händelsetyperna är dokumenterade.
expect(spec).toContain("/api/ai:");
expect(spec).toContain("append-only");
for (const typ of ["objekt_identifierat", "kontroll_utford", "ai_svar", "ansvarig_satt", "arbetsorder_skannad", "arende_avslutat"]) {
expect(spec).toContain(typ);
}
});
it("endpointen kodar AI-reglerna i grundprompten", () => {
expect(ENDPOINT).toContain("Hitta aldrig på fakta");
expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel");
expect(ENDPOINT).toContain("KRÄVER verifiering");
expect(ENDPOINT).toContain('"json_schema"');
});
it("användarprompten byggs ur ärendebriefen och den nya inmatningen", () => {
const arende = byggDemoArende(1);
const prompt = byggAnvandarPrompt(
brief(arende, VIBRATION_METODIK),
VIBRATION_METODIK.namn,
"Reläet klickar inte.",
);
expect(prompt).toContain("Volvo XC60 D4 2019");
expect(prompt).toContain("Felbeskrivning:");
expect(prompt).toContain("Kontrollera lufttryck");
expect(prompt).toContain("Ej kontrollerat enligt metodiken:");
expect(prompt).toContain("Teknikerns nya inmatning: Reläet klickar inte.");
});
it("granskningsprompten innehåller hela arbetsloggen men ingen ny inmatning", () => {
const arende = byggDemoArende(1);
const prompt = byggGranskningsPrompt(arende, VIBRATION_METODIK);
expect(prompt).toContain("Fullständig arbetslogg:");
expect(prompt).toContain("Arbete överlämnat från Anna till Johan");
expect(prompt).not.toContain("Teknikerns nya inmatning");
});
it("tolkar giltiga svar och kastar på ogiltiga", () => {
const svar = tolkaAiSvar({
rader: [
{ typ: "verifierat", text: "Matningsspänning finns på stift 14." },
{ typ: "okand_typ", text: "ska filtreras bort" },
],
nastaSteg: "Kontrollera jordanslutningen på stift 7.",
});
expect(svar.rader).toHaveLength(1);
expect(svar.rader[0].typ).toBe("verifierat");
expect(svar.nastaSteg).toContain("stift 7");
expect(() => tolkaAiSvar({ rader: "inte en lista", nastaSteg: "x" })).toThrow();
expect(() => tolkaAiSvar(null)).toThrow();
});
});
describe("arbetsorderskanning", () => {
it("normaliserar tolkningen: okända fält bort, konfidens klipps, tomma värden bort", () => {
const falt = normaliseraArbetsorder({
falt: [
{ id: "fordon_regnr", varde: " abc123 ", konfidens: 1.7 },
{ id: "pahittat_falt", varde: "x", konfidens: 0.9 },
{ id: "kund_namn", varde: " ", konfidens: 0.9 },
{ id: "fordon_vin", varde: "YV1DZ8256F2123456", konfidens: 0.74, omrade: { x: 0.5, y: 0.2, bredd: 0.4, hojd: 0.05 } },
{ id: "fordon_vin", varde: "DUBBLETT", konfidens: 0.5 },
],
});
expect(falt.map((f) => f.id)).toEqual(["fordon_regnr", "fordon_vin"]);
expect(falt[0].konfidens).toBe(1);
expect(falt[0].varde).toBe("abc123");
expect(falt[0].grupp).toBe("Personbil");
expect(falt[1].omrade?.x).toBe(0.5);
expect(() => normaliseraArbetsorder({})).toThrow();
});
it("demo-tolkningen använder bara kända fält och alla konfidensnivåer", async () => {
const { byggDemoTolkning } = await import("../demo");
const falt = byggDemoTolkning();
for (const f of falt) {
expect(ARBETSORDER_FALT.some((def) => def.id === f.id), f.id).toBe(true);
}
// Flödet ska kunna demonstrera alla tre nivåerna: auto-godkänd,
// granska och kräver bekräftelse.
expect(falt.some((f) => f.konfidens >= 0.95)).toBe(true);
expect(falt.some((f) => f.konfidens >= 0.8 && f.konfidens < 0.95)).toBe(true);
expect(falt.some((f) => f.konfidens < 0.8)).toBe(true);
});
it("händelsetypen arbetsorder_skannad är organisationsintern i alla delningsvägar", () => {
const server = readFileSync("../services/plattform/server.mjs", "utf8");
const migration = readFileSync("../supabase/migrations/20260802230000_guidad_felsokning.sql", "utf8");
const delatVy = readFileSync("src/felsokning/DelatArendeVy.tsx", "utf8");
for (const innehall of [server, migration, delatVy]) {
expect(innehall).toContain("arbetsorder_skannad");
}
});
});
@@ -0,0 +1,146 @@
// @vitest-environment node
// Bilagornas två egenskaper som måste hålla: signeringen mot
// objektlagringen är korrekt (annars fungerar inget alls), och
// innehållsadresseringen är verkligen innehållsadresserad — hashen är
// det som binder bilden till händelseloggen.
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import {
innehallsHash,
mediatypGiltig,
signera,
signeringsnyckel,
skapaS3Lager,
valjLager,
} from "../../../../services/plattform/bilagor.mjs";
// Referenssignaturer framtagna med botocore (AWS egen implementation) —
// se scratchpad/sigv4-referens.py. Att vår signering ger exakt samma
// resultat är den enda meningsfulla kontrollen: en signatur är antingen
// bitidentisk eller värdelös.
const REFERENS = JSON.parse(readFileSync("src/felsokning/__tests__/sigv4-referens.json", "utf8")) as {
namn: string;
metod: string;
url: string;
kropp_base64: string;
region: string;
tid: string;
authorization: string;
}[];
const UPPGIFTER = {
nyckelId: "AKIDEXAMPLE",
hemlighet: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
};
describe("signering mot objektlagringen", () => {
it("ger bitidentiska signaturer med botocore", () => {
expect(REFERENS.length).toBeGreaterThanOrEqual(3);
for (const fall of REFERENS) {
const huvuden = signera(
fall.metod,
fall.url,
Buffer.from(fall.kropp_base64, "base64"),
{ ...UPPGIFTER, region: fall.region },
fall.tid,
);
expect(huvuden.Authorization, fall.namn).toBe(fall.authorization);
}
});
it("härleder signeringsnyckeln enligt AWS testvektor", () => {
// Publicerad vektor ur AWS dokumentation för nyckelhärledning.
const nyckel = signeringsnyckel("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "20150830", "us-east-1", "iam");
expect(nyckel.toString("hex")).toBe(
"c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9",
);
});
it("en ändrad byte i kroppen ger en annan signatur", () => {
const bas = { ...UPPGIFTER, region: "eu-north-1" };
const a = signera("PUT", "https://s3.exempel.se/hink/x", Buffer.from("hej"), bas, "20260803T120000Z");
const b = signera("PUT", "https://s3.exempel.se/hink/x", Buffer.from("hej!"), bas, "20260803T120000Z");
expect(a.Authorization).not.toBe(b.Authorization);
expect(a["x-amz-content-sha256"]).not.toBe(b["x-amz-content-sha256"]);
});
});
describe("innehållsadressering", () => {
it("samma innehåll ger samma nyckel, olika innehåll olika", () => {
expect(innehallsHash(Buffer.from("samma"))).toBe(innehallsHash(Buffer.from("samma")));
expect(innehallsHash(Buffer.from("a"))).not.toBe(innehallsHash(Buffer.from("b")));
expect(innehallsHash(Buffer.from(""))).toHaveLength(64);
});
it("objektet läggs under sin egen hash — identiska filer lagras en gång", async () => {
const skrivna: string[] = [];
const lager = skapaS3Lager(
{ endpoint: "https://s3.exempel.se", hink: "hink", region: "eu-north-1", prefix: "bilagor", ...UPPGIFTER },
async (url: string, init: { method: string }) => {
if (init.method === "PUT") skrivna.push(url);
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
},
);
const data = Buffer.from("ett foto");
const hash = innehallsHash(data);
await lager.spara(hash, data);
await lager.spara(hash, data);
// Båda skrivningarna pekar på samma nyckel: lagringen skriver över
// sig själv i stället för att lägga till en dubblett.
expect(new Set(skrivna).size).toBe(1);
expect(skrivna[0]).toBe(`https://s3.exempel.se/hink/bilagor/${hash.slice(0, 2)}/${hash}`);
});
it("saknat objekt ger null, fel från lagringen kastas vidare", async () => {
const svar = { status: 404, ok: false };
const lager = skapaS3Lager(
{ endpoint: "https://s3.exempel.se", hink: "h", region: "eu-north-1", ...UPPGIFTER },
async () => svar,
);
expect(await lager.hamta("a".repeat(64))).toBeNull();
svar.status = 500;
await expect(lager.hamta("a".repeat(64))).rejects.toThrow("500");
});
});
describe("val av lager", () => {
it("standard är databasen — fungerar utan konfiguration", () => {
expect(valjLager({}, null).namn).toBe("databas");
expect(valjLager({ BILAGE_LAGE: "databas" }, null).namn).toBe("databas");
});
it("s3 utan fullständig konfiguration failar direkt, inte vid första uppladdningen", () => {
expect(() => valjLager({ BILAGE_LAGE: "s3" }, null)).toThrow(/S3_ENDPOINT/);
expect(() =>
valjLager({ BILAGE_LAGE: "s3", S3_ENDPOINT: "https://s3.exempel.se", S3_HINK: "h" }, null),
).toThrow(/S3_REGION/);
expect(
valjLager(
{
BILAGE_LAGE: "s3",
S3_ENDPOINT: "https://s3.exempel.se",
S3_HINK: "h",
S3_REGION: "eu-north-1",
S3_NYCKEL_ID: "a",
S3_NYCKEL: "b",
},
null,
).namn,
).toBe("s3");
});
});
describe("vad som får laddas upp", () => {
it("bilder och video, inget annat", () => {
for (const typ of ["image/jpeg", "image/png", "image/webp", "video/mp4", "video/webm", "IMAGE/JPEG"]) {
expect(mediatypGiltig(typ), typ).toBe(true);
}
expect(mediatypGiltig("image/jpeg; charset=binary")).toBe(true);
for (const typ of ["application/pdf", "text/html", "application/octet-stream", "", undefined]) {
expect(mediatypGiltig(typ as string), String(typ)).toBe(false);
}
});
});
@@ -0,0 +1,116 @@
// @vitest-environment node
// Delningsgränsen är en integritetsgräns. Testerna här låser två saker:
// att den är en tillåtelselista (nya händelsetyper är interna tills
// någon aktivt släpper fram dem) och att uppslag mot kundkonfigurerade
// URL:er inte kan användas för att nå klustrets insida.
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import {
DELBART_KUND,
DELBART_PARTNER,
ENDAST_INTERNT,
arPrivatAdress,
gorUppslag,
pekarInat,
synligaTyper,
} from "../../../../services/plattform/server.mjs";
// Alla händelsetyper i domänmodellen — sanningen om vad som kan hamna
// i loggen.
const HANDELSETYPER = [
...new Set(
[...readFileSync("src/felsokning/domain.ts", "utf8").matchAll(/\btyp:\s*"([a-z_]+)"/g)].map((m) => m[1]),
),
];
describe("delningsfiltret är en tillåtelselista", () => {
it("varje händelsetyp i domänmodellen är klassificerad", () => {
// Fångar den verkliga risken: någon lägger till en händelsetyp och
// glömmer bestämma om kunden ska se den. Då faller testet i stället
// för att typen tyst dyker upp i kundens delningslänk.
const klassificerade = new Set([...DELBART_KUND, ...ENDAST_INTERNT]);
const oklassificerade = HANDELSETYPER.filter((t) => !klassificerade.has(t));
expect(oklassificerade, `oklassificerade händelsetyper: ${oklassificerade.join(", ")}`).toEqual([]);
});
it("hittar faktiskt domänens händelsetyper", () => {
// Skyddar testet ovan mot att bli meningslöst om regexen slutar träffa.
expect(HANDELSETYPER.length).toBeGreaterThan(20);
expect(HANDELSETYPER).toContain("felorsak");
expect(HANDELSETYPER).toContain("ai_svar");
});
it("arbetsmaterial och arbetsledning lämnar aldrig organisationen", () => {
for (const intern of ENDAST_INTERNT) {
expect(DELBART_KUND, intern).not.toContain(intern);
}
// Hypoteser är det enda partnern ser utöver kundnivån.
expect(DELBART_PARTNER).toContain("hypotes");
expect(DELBART_KUND).not.toContain("hypotes");
for (const bara of ["kategori_byte", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"]) {
expect(DELBART_PARTNER, bara).not.toContain(bara);
}
});
it("internnivån filtrerar inte alls", () => {
expect(synligaTyper("intern")).toBeNull();
expect(synligaTyper("kund")).toEqual(DELBART_KUND);
expect(synligaTyper("partner")).toEqual(DELBART_PARTNER);
});
});
describe("uppslag kan inte riktas mot klustrets insida", () => {
it("känner igen privata, loopback- och link-local-adresser", () => {
for (const intern of [
"127.0.0.1",
"10.4.2.9",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"169.254.169.254", // molnens metadatatjänst
"100.64.0.1", // CGNAT
"0.0.0.0",
"::1",
"fd00::1",
"fe80::1",
"::ffff:127.0.0.1",
]) {
expect(arPrivatAdress(intern), intern).toBe(true);
}
for (const extern of ["8.8.8.8", "51.12.3.4", "172.32.0.1", "192.169.0.1", "2606:4700::1111"]) {
expect(arPrivatAdress(extern), extern).toBe(false);
}
});
it("stoppar både IP-literaler och namn som resolvar inåt", async () => {
expect(await pekarInat("http://169.254.169.254/latest/meta-data/")).toBe("169.254.169.254");
expect(await pekarInat("http://localhost:8080/x")).toBe("localhost");
// Klustrets egna tjänstenamn slutar på .local och stoppas på namnet,
// utan att ens behöva slås upp.
expect(await pekarInat("http://postgres.guidad-felsokning.svc.cluster.local/")).toBe(
"postgres.guidad-felsokning.svc.cluster.local",
);
// Namn som slår upp till en privat adress fångas via uppslaget.
const falskUppslagare = async () => [{ address: "10.0.0.5", family: 4 }];
expect(await pekarInat("https://kund.exempel.se/", falskUppslagare)).toBe("10.0.0.5");
const publikUppslagare = async () => [{ address: "93.184.216.34", family: 4 }];
expect(await pekarInat("https://kund.exempel.se/", publikUppslagare)).toBeNull();
});
it("uppslaget avvisar en intern bas-URL innan något anrop görs", async () => {
let anropades = false;
const def = { uppslag: { urlFalt: "bas_url", auth: "bearer", authFalt: "n", svarsfalt: { marke: "make" } } };
const svar = await gorUppslag(
def,
{ bas_url: "http://169.254.169.254/{vin}", n: "x" },
"YV1DZ8256F2123456",
async () => {
anropades = true;
return { ok: true, json: async () => ({ make: "Volvo" }) };
},
);
expect(svar.ok).toBe(false);
expect(svar.fel).toContain("intern adress");
expect(anropades, "anropet får inte göras alls").toBe(false);
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { byggDemoArende } from "../demo";
import { VIBRATION_METODIK, valjMetodik } from "../metodik";
import { brief, felbeskrivning } from "../projektioner";
describe("demoärendet", () => {
const arende = byggDemoArende(7);
it("väljer vibrationsmetodiken och har ett rikt underlag", () => {
expect(valjMetodik(felbeskrivning(arende)!).id).toBe("vibration");
const b = brief(arende, VIBRATION_METODIK);
expect(b.objekt?.identifierare).toBe("ABC123");
expect(b.utfordaKontroller.length).toBeGreaterThanOrEqual(10);
expect(b.observationer.length).toBeGreaterThanOrEqual(4);
expect(b.hypoteser).toHaveLength(1);
expect(b.avslutat).toBe(false);
});
it("lämnar kontroller kvar så att guiden kan fortsätta i demon", () => {
const b = brief(arende, VIBRATION_METODIK);
expect(b.ejKontrollerat).toContain("Kontrollera radial- och sidokast");
expect(b.ejKontrollerat).toContain("Förändring vid kurvtagning");
expect(b.rekommenderatNastaSteg.length).toBeGreaterThan(0);
});
it("har cirka 1,5 timmars loggad arbetstid och en överlämning", () => {
const b = brief(arende, VIBRATION_METODIK);
expect(b.totalArbetstid).toBe("1 tim 35 min");
expect(arende.handelser.some((p) => p.handelse.typ === "overlamning")).toBe(true);
expect(new Set(arende.handelser.map((p) => p.anvandare)).size).toBeGreaterThanOrEqual(2);
});
it("händelserna ligger i tidsordning med demoärendets id-format", () => {
const tider = arende.handelser.map((p) => p.tidpunkt);
expect([...tider].sort()).toEqual(tider);
expect(arende.id).toMatch(/^arende-demo-/);
expect(arende.delningskod).toBeTruthy();
});
});
@@ -0,0 +1,503 @@
import { describe, expect, it } from "vitest";
import type { Arende, Handelse } from "../domain";
import { nyLoggPost } from "../domain";
import { VIBRATION_METODIK } from "../metodik";
import {
arendetyp,
evidensNiva,
evidensposter,
grindGodkand,
kvalitetsgrind,
preDiagnostik,
sparbarhetspaket,
} from "../ecm";
import { arendeidentitet } from "../projektioner";
import { byggDemoArende } from "../demo";
function byggArende(handelser: Handelse[]): Arende {
return {
id: "ecm-test",
nummer: 1,
skapad: "2026-08-03T08:00:00Z",
handelser: handelser.map((h) => nyLoggPost("Anna", h, "2026-08-03T08:00:00Z")),
};
}
const OBJEKT: Handelse = {
typ: "objekt_identifierat",
objekt: {
typ: "Personbil",
identifierare: "ABC123",
identifieringsmetod: "Regnr",
beskrivning: "Volvo XC60",
vin: "YV1DZ8256F2123456",
miltal: "8 432 mil",
arbetsorder: "AO-2496",
claim: "G-2026-00481",
skadenummer: "SK-77812",
},
};
// Grundkontroller som gör pre-diagnostiken klar.
const PREDIAG: Handelse[] = [
{ typ: "historik_kontrollerad", kontrollerad: true },
{ typ: "matarstallning", lage: "ingaende", varde: "84 320 km" },
{ typ: "kommentar", text: "Kundens felbeskrivning verifierad vid mottagandet." },
{ typ: "kommentar", text: "Inga ytterligare observationer vid mottagandet." },
];
describe("Evidence Engine", () => {
it("härleder nivån ur loggens faktiska innehåll", () => {
expect(evidensNiva(byggArende([]))).toBe("E0");
expect(evidensNiva(byggArende([{ typ: "observation", text: "x" }]))).toBe("E1");
expect(evidensNiva(byggArende([{ typ: "foto", beskrivning: "x", dataUrl: "data:" }]))).toBe("E2");
expect(evidensNiva(byggArende([{ typ: "matvarde", beskrivning: "U", varde: "12" }]))).toBe("E4");
expect(evidensNiva(byggArende([{ typ: "arbetsorder_skannad", falt: [] }]))).toBe("E5");
expect(
evidensNiva(
byggArende([
{ typ: "foto", beskrivning: "x", dataUrl: "data:" },
{ typ: "matvarde", beskrivning: "U", varde: "12" },
]),
),
).toBe("E6");
});
it("katalogiserar evidensposter med tekniker, nivå och innehållshash", () => {
const arende = byggArende([
{ typ: "foto", beskrivning: "Höger framhjul", dataUrl: "data:x" },
{ typ: "matvarde", beskrivning: "Obalans", varde: "38", enhet: "g" },
]);
const poster = evidensposter(arende);
expect(poster).toHaveLength(2);
expect(poster[0].niva).toBe("E2");
expect(poster[1].sammanfattning).toBe("Obalans = 38 g");
expect(poster[0].tekniker).toBe("Anna");
expect(poster[0].hash).toMatch(/^[0-9a-f]{8}$/);
// Samma innehåll → samma hash (deterministisk spårbarhet).
expect(evidensposter(arende)[0].hash).toBe(poster[0].hash);
});
});
describe("Pre-Diagnostic Validation", () => {
it("ingen felsökning utan grundkontroller — allt härleds ur loggen", () => {
const tomt = byggArende([OBJEKT]);
expect(preDiagnostik(tomt).every((r) => r.klar)).toBe(false);
const klart = byggArende([OBJEKT, ...PREDIAG]);
expect(preDiagnostik(klart).every((r) => r.klar)).toBe(true);
});
it("ej kontrollerad historik godtas med orsak men flaggas som kvalitetsvarning", () => {
const arende = byggArende([
OBJEKT,
{ typ: "historik_kontrollerad", kontrollerad: false, kommentar: "Historiksystemet otillgängligt" },
]);
const rad = preDiagnostik(arende).find((r) => r.id === "historik")!;
expect(rad.klar).toBe(true);
expect(rad.varning).toContain("Historiksystemet otillgängligt");
});
});
describe("Completion Engine (kvalitetsgrind)", () => {
it("utan evidens är grinden stängd — inga påståenden utan underlag", () => {
const tomt = byggArende([]);
expect(grindGodkand(tomt, VIBRATION_METODIK)).toBe(false);
const rader = kvalitetsgrind(tomt, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "objekt")?.ok).toBe(false);
expect(rader.find((r) => r.id === "kontroller")?.detalj).toContain("Evidens saknas");
});
it("dokumenterade undantag öppnar grinden men redovisas med orsak", () => {
const handelser: Handelse[] = [
OBJEKT,
...PREDIAG,
{ typ: "foto", beskrivning: "Översikt", dataUrl: "data:" },
{ typ: "matvarde", beskrivning: "Lufttryck", varde: "2,4", enhet: "bar" },
];
for (const steg of VIBRATION_METODIK.steg) {
for (const kontroll of steg.kontroller ?? []) {
handelser.push({
typ: "kontroll_utford",
stegId: steg.id,
kontrollId: kontroll.id,
text: kontroll.text,
undantag: "Kunden avböjde demontering",
});
}
}
const arende = byggArende(handelser);
expect(grindGodkand(arende, VIBRATION_METODIK)).toBe(true);
const rad = kvalitetsgrind(arende, VIBRATION_METODIK).find((r) => r.id === "kontroller");
expect(rad?.detalj).toContain("Undantag med orsak");
});
it("utgående mätarställning blir obligatorisk när ärendet avslutas", () => {
const bas: Handelse[] = [OBJEKT, ...PREDIAG];
const oppen = byggArende(bas);
expect(kvalitetsgrind(oppen, VIBRATION_METODIK).find((r) => r.id === "matarstallning_ut")?.kravs).toBe(false);
const stangd = byggArende([...bas, { typ: "arende_avslutat" }]);
expect(kvalitetsgrind(stangd, VIBRATION_METODIK).find((r) => r.id === "matarstallning_ut")?.kravs).toBe(true);
});
});
describe("Compliance Engine", () => {
it("ärendetypen styr dokumentationskraven", () => {
const privat = byggArende([OBJEKT]);
expect(arendetyp(privat)).toBe("Privat kund");
expect(kvalitetsgrind(privat, VIBRATION_METODIK).some((r) => r.id.startsWith("garanti_"))).toBe(false);
const garanti = byggArende([OBJEKT, { typ: "arendetyp_satt", arendetyp: "Garanti" }]);
const rader = kvalitetsgrind(garanti, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "garanti_claim")?.ok).toBe(true); // claim finns i objektet
expect(rader.find((r) => r.id === "garanti_historik")?.ok).toBe(false); // historik ej kontrollerad
});
it("försäkringsärenden kräver skadenummer och bildbevis", () => {
const utanBild = byggArende([OBJEKT, { typ: "arendetyp_satt", arendetyp: "Försäkring" }]);
const rader = kvalitetsgrind(utanBild, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "forsakring_skadenummer")?.ok).toBe(true);
expect(rader.find((r) => r.id === "forsakring_bildbevis")?.ok).toBe(false);
});
});
describe("Traceability Engine + ärendeidentitet", () => {
it("spårbarhetspaketet innehåller version, grindstatus och evidensposter", () => {
const arende = byggArende([OBJEKT, { typ: "foto", beskrivning: "x", dataUrl: "data:" }]);
const paket = sparbarhetspaket(arende, VIBRATION_METODIK);
expect(paket.ecmVersion).toBe("2.0");
expect(paket.arendetyp).toBe("Privat kund");
expect(paket.evidensposter).toHaveLength(1);
expect(paket.kvalitetsgrind.some((r) => r.id === "objekt" && r.ok)).toBe(true);
});
it("ärendeidentiteten registreras en gång och återanvänds överallt", () => {
const arende = byggArende([OBJEKT, { typ: "matarstallning", lage: "ingaende", varde: "84 320 km" }]);
const idn = arendeidentitet(arende);
expect(idn.arbetsorder).toBe("AO-2496");
expect(idn.claim).toBe("G-2026-00481");
expect(idn.skadenummer).toBe("SK-77812");
expect(idn.vin).toBe("YV1DZ8256F2123456");
// Den fotograferade mätarställningen ersätter arbetsorderns uppgift.
expect(idn.miltal).toBe("84 320 km");
});
it("demoärendet passerar pre-diagnostiken men blockeras av okontrollerade metodiksteg", () => {
const demo = byggDemoArende(1);
expect(preDiagnostik(demo).every((r) => r.klar)).toBe(true);
expect(grindGodkand(demo, VIBRATION_METODIK)).toBe(false);
expect(evidensNiva(demo)).toBe("E6");
});
});
describe("Felorsaksanalys (Root Cause Analysis)", () => {
it("kvalitetsregeln avvisar generella formuleringar utan förklaring", async () => {
const { granskaAvvikelse } = await import("../ecm");
for (const daligt of ["Trasig.", "defekt", "Sliten", "Behöver bytas", "kort"]) {
expect(granskaAvvikelse(daligt), daligt).not.toBeNull();
}
expect(
granskaAvvikelse("Startmotorn aktiverar inte trots korrekt matningsspänning och god jordförbindelse."),
).toBeNull();
});
it("valda evidenskällor måste finnas i loggen", async () => {
const { underlagFinns } = await import("../ecm");
const utanFoto = byggArende([OBJEKT, { typ: "matvarde", beskrivning: "U", varde: "12" }]);
expect(underlagFinns(utanFoto, "Foto")).toBe(false);
expect(underlagFinns(utanFoto, "Mätresultat")).toBe(true);
expect(underlagFinns(utanFoto, "Servicehistorik")).toBe(false);
const medHistorik = byggArende([{ typ: "historik_kontrollerad", kontrollerad: true }]);
expect(underlagFinns(medHistorik, "Servicehistorik")).toBe(true);
});
it("avslut kräver reproducering och felorsak — grinden blir obligatorisk vid stängning", () => {
const stangdUtan = byggArende([OBJEKT, ...PREDIAG, { typ: "arende_avslutat" }]);
const rader = kvalitetsgrind(stangdUtan, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "svp")?.kravs).toBe(true);
expect(rader.find((r) => r.id === "svp")?.ok).toBe(false);
expect(rader.find((r) => r.id === "felorsak")?.ok).toBe(false);
const stangdMed = byggArende([
OBJEKT,
...PREDIAG,
{ typ: "reproducering", status: "nej", beskrivning: "Felet uppträdde inte vid provkörning; fordonet var varmt." },
{
typ: "felorsak",
avvikelse: "Ingen avvikelse kunde konstateras vid undersökningen trots systematisk genomgång.",
orsaker: ["Okänd orsak"],
underlag: ["Direkt observation"],
sakerhet: "lag",
atgard: "Återkom vid kall motor så att symptomet kan reproduceras.",
motivering: "Symptomet kunde inte reproduceras under rådande förhållanden.",
},
{ typ: "observation", text: "Systematisk genomgång utan anmärkning" },
{ typ: "arende_avslutat" },
]);
const rader2 = kvalitetsgrind(stangdMed, VIBRATION_METODIK);
expect(rader2.find((r) => r.id === "svp")?.ok).toBe(true);
expect(rader2.find((r) => r.id === "svp")?.detalj).toContain("kunde inte reproduceras");
expect(rader2.find((r) => r.id === "felorsak")?.ok).toBe(true);
});
it("rapportformuleringen skiljer reproducerat från ej reproducerat", async () => {
const { reproduceringsText } = await import("../ecm");
expect(reproduceringsText("ja")).toContain("reproducerades");
expect(reproduceringsText("nej")).toContain("kunde inte reproduceras under de förhållanden");
});
it("demoärendet bär hela beviskedjan: reproducering + felorsak med underlag", () => {
const demo = byggDemoArende(1);
const repro = demo.handelser.find((p) => p.handelse.typ === "reproducering");
expect(repro).toBeDefined();
const orsak = demo.handelser.find((p) => p.handelse.typ === "felorsak");
expect(orsak?.handelse.typ === "felorsak" && orsak.handelse.underlag.length > 0).toBe(true);
});
});
describe("ECM Knowledge Library (regelpaket)", () => {
it("serverpaketet och det inbyggda standardpaketet är samma regler", async () => {
const { readFileSync } = await import("node:fs");
const { STANDARD_REGELPAKET, normaliseraRegelpaket } = await import("../ecm");
const server = normaliseraRegelpaket(JSON.parse(readFileSync("../services/plattform/ecm-regler.json", "utf8")));
expect(server.version).toBe(STANDARD_REGELPAKET.version);
expect(server.arendetypRegler).toEqual(STANDARD_REGELPAKET.arendetypRegler);
expect(server.orsakskategorier).toEqual(STANDARD_REGELPAKET.orsakskategorier);
});
it("trasiga paket faller tillbaka till standard, okända krav-typer filtreras", async () => {
const { STANDARD_REGELPAKET, normaliseraRegelpaket } = await import("../ecm");
expect(normaliseraRegelpaket(null)).toEqual(STANDARD_REGELPAKET);
const paket = normaliseraRegelpaket({
version: "2.1",
arendetypRegler: {
Garanti: [
{ id: "x", rubrik: "Ny regel", krav: "foto", detaljVidBrist: "Kräver foto." },
{ id: "y", rubrik: "Trasig", krav: "pahittad_kravtyp", detaljVidBrist: "…" },
],
},
});
expect(paket.version).toBe("2.1");
expect(paket.arendetypRegler.Garanti).toHaveLength(1);
expect(paket.undantagsorsaker).toEqual(STANDARD_REGELPAKET.undantagsorsaker);
});
it("ett uppdaterat regelpaket ändrar kvalitetsgrinden utan appändring", async () => {
const { normaliseraRegelpaket } = await import("../ecm");
localStorage.setItem(
"gf-ecm-regelpaket",
JSON.stringify(
normaliseraRegelpaket({
version: "2.1-test",
arendetypRegler: {
"Privat kund": [
{ id: "test_foto", rubrik: "Foto krävs för alla privatkunder", krav: "foto", detaljVidBrist: "Testregel." },
],
},
}),
),
);
try {
const utanFoto = byggArende([OBJEKT]);
const rad = kvalitetsgrind(utanFoto, VIBRATION_METODIK).find((r) => r.id === "test_foto");
expect(rad?.ok).toBe(false);
expect(rad?.rubrik).toContain("Privat kund");
// Spårbarheten bär paketversionen.
expect(sparbarhetspaket(utanFoto, VIBRATION_METODIK).regelpaketVersion).toBe("2.1-test");
} finally {
localStorage.removeItem("gf-ecm-regelpaket");
}
});
});
describe("Fordonshistorik (lokal projektion)", () => {
it("hittar tidigare ärenden på samma identifierare med felorsaker", async () => {
const { lokalFordonshistorik } = await import("../projektioner");
const tidigare = byggArende([
OBJEKT,
{
typ: "felorsak",
avvikelse: "Vattenpumpen läcker vid axeltätningen.",
orsaker: ["Normalt slitage"],
underlag: ["Foto"],
sakerhet: "hog",
atgard: "Byt vattenpump.",
},
{ typ: "arende_avslutat" },
]);
const aktuellt = byggArende([OBJEKT]);
aktuellt.id = "nytt-arende";
const historik = lokalFordonshistorik({ [tidigare.id]: tidigare, [aktuellt.id]: aktuellt }, "abc123", aktuellt.id);
expect(historik).toHaveLength(1);
expect(historik[0].avslutat).toBe(true);
expect(historik[0].felorsaker[0].orsaker).toContain("Normalt slitage");
// Annat fordon → ingen träff.
expect(lokalFordonshistorik({ [tidigare.id]: tidigare }, "ZZZ111")).toHaveLength(0);
});
});
describe("Videoevidens (E3) och signerat avslut", () => {
it("video höjer evidensnivån till E3 och räknas som oberoende källa", () => {
const medVideo = byggArende([{ typ: "video", beskrivning: "Motorljud vid tomgång", dataUrl: "data:video/webm;base64,x" }]);
expect(evidensNiva(medVideo)).toBe("E3");
expect(evidensposter(medVideo)[0].kategori).toBe("video");
const tvaKallor = byggArende([
{ typ: "video", beskrivning: "Motorljud", dataUrl: "data:video/webm;base64,x" },
{ typ: "matvarde", beskrivning: "Oljetryck", varde: "3,1", enhet: "bar" },
]);
expect(evidensNiva(tvaKallor)).toBe("E6");
});
it("underlagskällan Video valideras mot loggen i felorsaksanalysen", async () => {
const { underlagFinns } = await import("../ecm");
expect(underlagFinns(byggArende([OBJEKT]), "Video")).toBe(false);
expect(underlagFinns(byggArende([{ typ: "video", beskrivning: "x", dataUrl: "data:" }]), "Video")).toBe(true);
});
it("avslutet signeras och grinden redovisar signaturen", async () => {
const { handelseRubrik, nyLoggPost: ny } = await import("../domain");
expect(handelseRubrik(ny("Erik", { typ: "arende_avslutat", signatur: "Erik" }))).toContain("signerad av Erik");
const stangd = byggArende([OBJEKT, ...PREDIAG, { typ: "arende_avslutat", signatur: "Erik" }]);
const rad = kvalitetsgrind(stangd, VIBRATION_METODIK).find((r) => r.id === "signering");
expect(rad?.ok).toBe(true);
expect(rad?.detalj).toContain("Erik");
});
});
describe("Åtgärdsfasen och kvalitetskontroll", () => {
it("avslut kräver dokumenterad åtgärd — och kvalitetskontroll när åtgärd utförts", async () => {
const { atgarder, kvalitetskontroll } = await import("../ecm");
const bas: Handelse[] = [
OBJEKT,
...PREDIAG,
{ typ: "reproducering", status: "ja", beskrivning: "Reproducerad vid provkörning." },
{
typ: "felorsak",
avvikelse: "Framhjulen uppvisar obalans om 38 g på höger sida.",
orsaker: ["Yttre påverkan"],
underlag: ["Direkt observation"],
sakerhet: "hog",
atgard: "Balansera om hjulet.",
},
{ typ: "observation", text: "Obalans uppmätt" },
];
// Utan åtgärd: grinden kräver den vid avslut.
const utanAtgard = byggArende([...bas, { typ: "arende_avslutat", signatur: "Erik" }]);
const rad = kvalitetsgrind(utanAtgard, VIBRATION_METODIK).find((r) => r.id === "atgard");
expect(rad?.kravs).toBe(true);
expect(rad?.ok).toBe(false);
// Utförd åtgärd utan kvalitetskontroll: kontrollen blir obligatorisk.
const utanKk = byggArende([
...bas,
{ typ: "atgard_utford", beskrivning: "Balanserade om höger framhjul.", utford: true },
{ typ: "arende_avslutat", signatur: "Erik" },
]);
expect(atgarder(utanKk)).toHaveLength(1);
const kkRad = kvalitetsgrind(utanKk, VIBRATION_METODIK).find((r) => r.id === "kvalitetskontroll");
expect(kkRad?.kravs).toBe(true);
expect(kkRad?.ok).toBe(false);
// Komplett kedja: åtgärd + verifierat borta.
const komplett = byggArende([
...bas,
{ typ: "atgard_utford", beskrivning: "Balanserade om höger framhjul.", utford: true },
{ typ: "kvalitetskontroll", resultat: "symptomet_borta", beskrivning: "Ny provkörning utan vibration." },
{ typ: "arende_avslutat", signatur: "Erik" },
]);
expect(kvalitetskontroll(komplett)?.resultat).toBe("symptomet_borta");
const rader = kvalitetsgrind(komplett, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "atgard")?.ok).toBe(true);
expect(rader.find((r) => r.id === "kvalitetskontroll")?.detalj).toContain("verifierat borta");
});
it("motiverat uteblivande godtas utan kvalitetskontroll", () => {
const ingenAtgard = byggArende([
OBJEKT,
...PREDIAG,
{ typ: "atgard_utford", beskrivning: "Ingen åtgärd utförd", utford: false, motivering: "Kunden avböjde åtgärd" },
{ typ: "arende_avslutat", signatur: "Erik" },
]);
const rader = kvalitetsgrind(ingenAtgard, VIBRATION_METODIK);
expect(rader.find((r) => r.id === "atgard")?.ok).toBe(true);
expect(rader.find((r) => r.id === "atgard")?.detalj).toContain("orsaken är dokumenterad");
// Inget att verifiera → kvalitetskontrollen är inte obligatorisk.
expect(rader.find((r) => r.id === "kvalitetskontroll")?.kravs).toBe(false);
});
it("kvarstående symptom flaggas i grinden i stället för att döljas", () => {
const kvarstar = byggArende([
OBJEKT,
...PREDIAG,
{ typ: "atgard_utford", beskrivning: "Bytte hjullager höger fram.", utford: true },
{ typ: "kvalitetskontroll", resultat: "kvarstar", beskrivning: "Missljudet finns kvar vid 60 km/h." },
]);
const rad = kvalitetsgrind(kvarstar, VIBRATION_METODIK).find((r) => r.id === "kvalitetskontroll");
expect(rad?.detalj).toContain("bör inte avslutas som åtgärdat");
});
it("demoärendet bär hela kedjan symptom → orsak → åtgärd → verifiering", () => {
const demo = byggDemoArende(1);
const typer = demo.handelser.map((p) => p.handelse.typ);
for (const typ of ["reproducering", "felorsak", "atgard_utford", "kvalitetskontroll"]) {
expect(typer, typ).toContain(typ);
}
});
});
describe("Kundgodkännande före arbete", () => {
const FORSLAG: Handelse = {
typ: "atgardsforslag",
beskrivning: "Balansera om höger framhjul och genomför ny provkörning.",
uppskattadKostnad: "890 kr",
};
const ATGARD: Handelse = { typ: "atgard_utford", beskrivning: "Balanserade om höger framhjul.", utford: true };
it("lämnat förslag kräver registrerat besked innan arbetet utförs", async () => {
const { atgardsforslag, kundbeslut } = await import("../ecm");
const utanBesked = byggArende([OBJEKT, ...PREDIAG, FORSLAG, ATGARD]);
expect(atgardsforslag(utanBesked)).toHaveLength(1);
expect(kundbeslut(utanBesked)).toBeUndefined();
const rad = kvalitetsgrind(utanBesked, VIBRATION_METODIK).find((r) => r.id === "kundgodkannande");
expect(rad?.kravs).toBe(true);
expect(rad?.ok).toBe(false);
expect(rad?.detalj).toContain("registrera kundens besked");
const medBesked = byggArende([
OBJEKT,
...PREDIAG,
FORSLAG,
{ typ: "kundbeslut", beslut: "godkant", kanal: "Telefon" },
ATGARD,
]);
const rad2 = kvalitetsgrind(medBesked, VIBRATION_METODIK).find((r) => r.id === "kundgodkannande");
expect(rad2?.ok).toBe(true);
expect(rad2?.detalj).toContain("Telefon");
});
it("utfört arbete trots avböjt förslag flaggas som konflikt", () => {
const konflikt = byggArende([
OBJEKT,
...PREDIAG,
FORSLAG,
{ typ: "kundbeslut", beslut: "avbojt", kanal: "Telefon", kommentar: "Kunden vill vänta." },
ATGARD,
]);
const rad = kvalitetsgrind(konflikt, VIBRATION_METODIK).find((r) => r.id === "godkannande_konflikt");
expect(rad?.ok).toBe(false);
expect(rad?.kravs).toBe(true);
});
it("utan lämnat förslag ställs inget godkännandekrav", () => {
const utanForslag = byggArende([OBJEKT, ...PREDIAG, ATGARD]);
expect(kvalitetsgrind(utanForslag, VIBRATION_METODIK).some((r) => r.id === "kundgodkannande")).toBe(false);
});
it("demoärendet visar hela kundkedjan: förslag → godkännande → åtgärd", () => {
const demo = byggDemoArende(1);
const typer = demo.handelser.map((p) => p.handelse.typ);
expect(typer.indexOf("atgardsforslag")).toBeLessThan(typer.indexOf("kundbeslut"));
expect(typer.indexOf("kundbeslut")).toBeLessThan(typer.indexOf("atgard_utford"));
});
});
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
ALLA_IDENTIFIERINGSMETODER,
ALLA_OBJEKTTYPER,
lastaInstallningar,
normalisera,
} from "../installningar";
describe("organisationsinställningar", () => {
beforeEach(() => localStorage.clear());
it("utan sparade val visas allt", () => {
const inst = lastaInstallningar();
expect(inst.objekttyper).toEqual(ALLA_OBJEKTTYPER);
expect(inst.identifieringsmetoder).toEqual(ALLA_IDENTIFIERINGSMETODER);
});
it("okända värden filtreras bort och ordningen följer standardlistan", () => {
const inst = normalisera({
objekttyper: ["Hydraulik", "Påhittad typ", "Personbil"],
identifieringsmetoder: ["VIN"],
});
expect(inst.objekttyper).toEqual(["Personbil", "Hydraulik"]);
expect(inst.identifieringsmetoder).toEqual(["VIN"]);
});
it("en tom eller trasig lista faller tillbaka till hela standardlistan", () => {
expect(normalisera({ objekttyper: [] }).objekttyper).toEqual(ALLA_OBJEKTTYPER);
expect(normalisera({ objekttyper: "fel typ" }).objekttyper).toEqual(ALLA_OBJEKTTYPER);
expect(normalisera(null).identifieringsmetoder).toEqual(ALLA_IDENTIFIERINGSMETODER);
});
it("sparade val på enheten läses tillbaka", () => {
localStorage.setItem(
"gf-installningar",
JSON.stringify({ objekttyper: ["Elsystem"], identifieringsmetoder: ["Serienummer", "Manuell inmatning"] }),
);
const inst = lastaInstallningar();
expect(inst.objekttyper).toEqual(["Elsystem"]);
expect(inst.identifieringsmetoder).toEqual(["Serienummer", "Manuell inmatning"]);
});
});
@@ -0,0 +1,130 @@
// @vitest-environment node
// Servermodulen körs bara i Node — jsdom saknar bland annat
// AbortSignal.timeout, som uppslaget använder.
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { dekryptera, gorUppslag, kryptera, maskera } from "../../../../services/plattform/server.mjs";
const REGISTER = JSON.parse(readFileSync("../services/plattform/integrationer.json", "utf8"));
describe("integrationsuppgifter i vila", () => {
it("krypterar och dekrypterar med AES-256-GCM", () => {
const nyckel = randomBytes(32);
const hemlighet = JSON.stringify({ api_nyckel: "sk-verkstad-123", bas_url: "https://api.exempel.se/{vin}" });
const paket = kryptera(hemlighet, nyckel);
// Chiffertexten avslöjar ingenting.
expect(paket).not.toContain("sk-verkstad-123");
expect(paket.split(".")).toHaveLength(3);
expect(dekryptera(paket, nyckel)).toBe(hemlighet);
});
it("manipulerad chiffertext avvisas av autentiseringstaggen", () => {
const nyckel = randomBytes(32);
const paket = kryptera("hemligt", nyckel);
const [iv, tagg, data] = paket.split(".");
const trasig = `${iv}.${tagg}.${Buffer.from("annat innehåll").toString("base64")}`;
expect(() => dekryptera(trasig, nyckel)).toThrow();
// Fel nyckel går inte heller.
expect(() => dekryptera(paket, randomBytes(32))).toThrow();
});
it("maskerar hemligheter så bara de sista tecknen syns", () => {
expect(maskera("sk-verkstad-9821")).toBe("••••9821");
expect(maskera("abc")).toBe("••••");
expect(maskera("")).toBe("");
expect(maskera(undefined)).toBe("");
});
});
describe("leverantörsregistret är data, inte kod", () => {
it("varje leverantör har fält och en komplett uppslagsdefinition", () => {
expect(REGISTER.leverantorer.length).toBeGreaterThan(0);
for (const lev of REGISTER.leverantorer) {
expect(lev.id, lev.namn).toMatch(/^[a-z0-9_]+$/);
expect(lev.falt.length, lev.namn).toBeGreaterThan(0);
for (const falt of lev.falt) {
expect(typeof falt.hemlig, `${lev.id}/${falt.nyckel}`).toBe("boolean");
}
// Varje leverantör måste ha minst ett hemligt fält (annars behövs
// ingen kryptering) och en URL-mall att slå upp mot.
expect(lev.falt.some((f: { hemlig: boolean }) => f.hemlig), lev.id).toBe(true);
expect(Object.keys(lev.uppslag.svarsfalt).length, lev.id).toBeGreaterThan(0);
}
});
});
describe("uppslag mot leverantör", () => {
const def = REGISTER.leverantorer.find((l: { id: string }) => l.id === "generisk_vin");
it("bygger URL ur mallen och mappar svaret till våra fält", async () => {
let anropadUrl = "";
let anropadeHeaders: Record<string, string> = {};
const resultat = await gorUppslag(
def,
{ bas_url: "https://api.exempel.se/vin/{vin}", api_nyckel: "hemlig-nyckel" },
"YV1DZ8256F2123456",
async (url: string, init: { headers: Record<string, string> }) => {
anropadUrl = url;
anropadeHeaders = init.headers;
return { ok: true, json: async () => ({ make: "Volvo", model: "XC60", year: 2019, okant: "x" }) };
},
);
expect(anropadUrl).toBe("https://api.exempel.se/vin/YV1DZ8256F2123456");
expect(anropadeHeaders.Authorization).toBe("Bearer hemlig-nyckel");
expect(resultat.ok).toBe(true);
expect(resultat.fordon).toEqual({ marke: "Volvo", modell: "XC60", arsmodell: "2019" });
});
it("avvisar ogiltig bas-URL och rapporterar leverantörens fel ärligt", async () => {
const utanUrl = await gorUppslag(def, { bas_url: "inte-en-url", api_nyckel: "x" }, "ABC123");
expect(utanUrl.ok).toBe(false);
expect(utanUrl.fel).toContain("Bas-URL");
const felsvar = await gorUppslag(
def,
{ bas_url: "https://api.exempel.se/{vin}", api_nyckel: "x" },
"ABC123",
async () => ({ ok: false, status: 401, json: async () => ({}) }),
);
expect(felsvar.ok).toBe(false);
expect(felsvar.fel).toContain("401");
const tomtSvar = await gorUppslag(
def,
{ bas_url: "https://api.exempel.se/{vin}", api_nyckel: "x" },
"ABC123",
async () => ({ ok: true, json: async () => ({ helt: "andra falt" }) }),
);
expect(tomtSvar.ok).toBe(false);
expect(tomtSvar.fel).toContain("inga kända fält");
});
it("stödjer basic och query-autentisering utan leverantörsspecifik kod", async () => {
const basicDef = REGISTER.leverantorer.find((l: { id: string }) => l.id === "vag_erwin");
let headers: Record<string, string> = {};
await gorUppslag(
basicDef,
{ bas_url: "https://erwin.exempel.se/{vin}", anvandarnamn: "verkstad", losenord: "hemligt" },
"YV1DZ8256F2123456",
async (_url: string, init: { headers: Record<string, string> }) => {
headers = init.headers;
return { ok: true, json: async () => ({ brand: "VW" }) };
},
);
expect(headers.Authorization).toBe(`Basic ${Buffer.from("verkstad:hemligt").toString("base64")}`);
const queryDef = REGISTER.leverantorer.find((l: { id: string }) => l.id === "fordonsregister");
let url = "";
await gorUppslag(
queryDef,
{ bas_url: "https://fordon.exempel.se/{regnr}", api_nyckel: "n1" },
"ABC123",
async (u: string) => {
url = u;
return { ok: true, json: async () => ({ make: "Volvo" }) };
},
);
expect(url).toBe("https://fordon.exempel.se/ABC123?key=n1");
});
});
@@ -0,0 +1,201 @@
import { describe, expect, it } from "vitest";
import type { Arende, Handelse } from "../domain";
import { nyLoggPost } from "../domain";
import { VIBRATION_METODIK, nastaSteg, valjMetodik } from "../metodik";
import { brief, ejKontrollerat, tidsfordelning } from "../projektioner";
// Hjälpare: bygger ett ärende ur en lista (tidpunkt, händelse) —
// precis som produktionskoden är alla vyer rena funktioner av loggen.
function byggArende(poster: [string, Handelse][]): Arende {
return {
id: "test",
nummer: 1,
skapad: poster[0]?.[0] ?? "2026-08-02T08:00:00Z",
handelser: poster.map(([tidpunkt, handelse]) => nyLoggPost("Anna", handelse, tidpunkt)),
};
}
const OBJEKT: Handelse = {
typ: "objekt_identifierat",
objekt: {
typ: "Personbil",
identifierare: "ABC123",
identifieringsmetod: "Registreringsnummer",
beskrivning: "Volvo XC60 D4 2019",
kund: "Anders Svensson",
},
};
describe("verifierade checklistor", () => {
it("varje kontroll i metodikerna har ett definierat minimikrav", async () => {
const { METODIKER } = await import("../metodik");
for (const metodik of METODIKER) {
for (const steg of metodik.steg) {
for (const kontroll of steg.kontroller ?? []) {
expect(kontroll.krav, `${metodik.id}/${steg.id}/${kontroll.id}`).toBeDefined();
}
}
}
});
});
describe("metodikval", () => {
it("väljer vibrationsmetodiken när felbeskrivningen nämner vibration", () => {
expect(valjMetodik("Bilen vibrerar runt 88 km/h").id).toBe("vibration");
expect(valjMetodik("Motorn startar inte").id).toBe("generisk");
});
it("väljer elsystemmetodiken vid elrelaterade symptom", () => {
expect(valjMetodik("Reläet klickar inte").id).toBe("elsystem");
expect(valjMetodik("Ingen spänning till bränslepumpen").id).toBe("elsystem");
expect(valjMetodik("Belysningen fungerar inte").id).toBe("elsystem");
});
});
describe("nastaSteg", () => {
it("börjar med första symptomfrågan och går vidare i metodikens ordning", () => {
const arende = byggArende([
["2026-08-02T08:03:00Z", OBJEKT],
["2026-08-02T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h" }],
]);
const steg = nastaSteg(arende, VIBRATION_METODIK);
expect(steg.klart).toBe(false);
expect(steg.steg.id).toBe("symptom");
expect(steg.fraga?.id).toBe("hastighetsberoende");
});
it("hoppar till nästa obesvarade fråga när en besvarats", () => {
const arende = byggArende([
["2026-08-02T08:03:00Z", OBJEKT],
[
"2026-08-02T08:06:00Z",
{ typ: "fraga_besvarad", stegId: "symptom", frageId: "hastighetsberoende", fraga: "…", svar: "Ja" },
],
]);
expect(nastaSteg(arende, VIBRATION_METODIK).fraga?.id).toBe("var");
});
it("är klart när alla frågor och kontroller är dokumenterade", () => {
const poster: [string, Handelse][] = [["2026-08-02T08:00:00Z", OBJEKT]];
for (const steg of VIBRATION_METODIK.steg) {
for (const fraga of steg.fragor ?? []) {
poster.push([
"2026-08-02T08:10:00Z",
{ typ: "fraga_besvarad", stegId: steg.id, frageId: fraga.id, fraga: fraga.text, svar: "Ja" },
]);
}
for (const kontroll of steg.kontroller ?? []) {
poster.push([
"2026-08-02T08:20:00Z",
{ typ: "kontroll_utford", stegId: steg.id, kontrollId: kontroll.id, text: kontroll.text },
]);
}
}
expect(nastaSteg(byggArende(poster), VIBRATION_METODIK).klart).toBe(true);
});
});
describe("ejKontrollerat", () => {
it("krymper när kontroller utförs och härleds helt ur loggen", () => {
const utan = byggArende([["2026-08-02T08:00:00Z", OBJEKT]]);
const alla = ejKontrollerat(utan, VIBRATION_METODIK);
expect(alla).toContain("Kontrollera lufttryck");
const med = byggArende([
["2026-08-02T08:00:00Z", OBJEKT],
[
"2026-08-02T08:15:00Z",
{ typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck" },
],
]);
const kvar = ejKontrollerat(med, VIBRATION_METODIK);
expect(kvar).not.toContain("Kontrollera lufttryck");
expect(kvar.length).toBe(alla.length - 1);
});
});
describe("tidsfordelning", () => {
it("attribuerar intervall till aktiv kategori och räknar inte paus i totalen", () => {
const arende = byggArende([
["2026-08-02T08:00:00Z", OBJEKT],
// 30 min aktiv felsökning (standardkategori)
["2026-08-02T08:30:00Z", { typ: "kategori_byte", kategori: "paus" }],
// 15 min paus
["2026-08-02T08:45:00Z", { typ: "kategori_byte", kategori: "provkorning" }],
// 20 min provkörning
["2026-08-02T09:05:00Z", { typ: "arende_avslutat" }],
]);
const { totalMs, perKategori } = tidsfordelning(arende);
expect(perKategori.aktiv_felsokning).toBe(30 * 60000);
expect(perKategori.paus).toBe(15 * 60000);
expect(perKategori.provkorning).toBe(20 * 60000);
expect(totalMs).toBe(50 * 60000);
});
it("räknar pågående tid fram till 'nu' för öppna ärenden", () => {
const arende = byggArende([["2026-08-02T08:00:00Z", OBJEKT]]);
const { totalMs } = tidsfordelning(arende, "2026-08-02T08:10:00Z");
expect(totalMs).toBe(10 * 60000);
});
});
describe("ansvarig", () => {
it("skaparen är ansvarig tills överlämning eller omfördelning pekar ut någon annan", async () => {
const { ansvarig } = await import("../projektioner");
const bas: [string, Handelse][] = [["2026-08-03T08:00:00Z", OBJEKT]];
expect(ansvarig(byggArende(bas))).toBe("Anna");
const efterOverlamning = byggArende([
...bas,
["2026-08-03T09:00:00Z", { typ: "overlamning", fran: "Anna", till: "Johan" }],
]);
expect(ansvarig(efterOverlamning)).toBe("Johan");
const efterOmfordelning = byggArende([
...bas,
["2026-08-03T09:00:00Z", { typ: "overlamning", fran: "Anna", till: "Johan" }],
["2026-08-03T10:00:00Z", { typ: "ansvarig_satt", ansvarig: "Lisa" }],
]);
expect(ansvarig(efterOmfordelning)).toBe("Lisa");
});
});
describe("brief", () => {
const arende = byggArende([
["2026-08-02T08:03:00Z", OBJEKT],
["2026-08-02T08:05:00Z", { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h" }],
[
"2026-08-02T08:11:00Z",
{ typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck", resultat: "2,4 bar" },
],
["2026-08-02T08:18:00Z", { typ: "matvarde", beskrivning: "Matningsspänning", varde: "13,9", enhet: "V" }],
["2026-08-02T08:20:00Z", { typ: "observation", text: "Höger framdäck visar ojämnt slitage" }],
["2026-08-02T08:22:00Z", { typ: "hypotes", text: "Obalans i höger framhjul", niva: "lag" }],
]);
it("sammanställer utfört, observerat, ej kontrollerat och nästa steg ur loggen", () => {
const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
expect(b.objekt?.identifierare).toBe("ABC123");
expect(b.felbeskrivning).toBe("Bilen vibrerar runt 88 km/h");
expect(b.utfordaKontroller.map((k) => k.text)).toContain("Kontrollera lufttryck");
expect(b.observationer).toContain("Höger framdäck visar ojämnt slitage");
expect(b.observationer).toContain("Matningsspänning: 13,9 V");
expect(b.ejKontrollerat).toContain("Kontrollera hjulbalansering");
expect(b.ejKontrollerat).not.toContain("Kontrollera lufttryck");
expect(b.rekommenderatNastaSteg[0]).toContain("hastighetsberoende");
expect(b.totalArbetstid).toBe("27 min");
});
it("skiljer hypoteser från fakta och markerar felorsak som ej verifierad", () => {
const b = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
expect(b.hypoteser).toEqual([{ text: "Obalans i höger framhjul", niva: "lag" }]);
expect(b.observationer).not.toContain("Obalans i höger framhjul");
expect(b.tillforlitlighet.some((r) => r.niva === "medel" && /inte verifierad/.test(r.text))).toBe(true);
});
it("är regenererbar: samma logg ger alltid samma brief", () => {
const b1 = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
const b2 = brief(arende, VIBRATION_METODIK, "2026-08-02T08:30:00Z");
expect(b2).toEqual(b1);
});
});
@@ -0,0 +1,29 @@
[
{
"namn": "put-liten",
"metod": "PUT",
"url": "https://s3.eu-north-1.amazonaws.com/verkstad-bilagor/bilagor/ab/abcdef",
"kropp_base64": "aGVq",
"region": "eu-north-1",
"tid": "20260804T111952Z",
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/eu-north-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=20ee842bb592f8af0cfe2dbf1e697693b12de1aa677812a1a12628687f4ab102"
},
{
"namn": "get-tom",
"metod": "GET",
"url": "https://s3.eu-north-1.amazonaws.com/verkstad-bilagor/bilagor/ff/ffff",
"kropp_base64": "",
"region": "eu-north-1",
"tid": "20260804T111952Z",
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/eu-north-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=7002e3616b3ab2a3a2a55927827afbe9e197eb47e4ebfe6def73b56de5c2f266"
},
{
"namn": "annan-endpoint",
"metod": "PUT",
"url": "https://minio.exempel.se:9000/hink/verkstad-1/bilagor/0f/0f9a2b",
"kropp_base64": "AAECYmluYXJ0",
"region": "us-east-1",
"tid": "20260804T111952Z",
"authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260804/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=09e038fa4e0ba6b967bfd8e88dde00285f3090f7609c935eabe04c4eae68758e"
}
]
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { tolkaKod } from "../streckkod";
describe("avläst kod klassificeras innan den används", () => {
it("känner igen VIN (17 tecken, utan I/O/Q)", () => {
expect(tolkaKod("YV1DZ8256F2123456")).toEqual({ varde: "YV1DZ8256F2123456", typ: "VIN" });
// Mellanslag och bindestreck i streckkoder normaliseras bort.
expect(tolkaKod(" yv1dz8256f2123456 ")?.typ).toBe("VIN");
// 16 tecken är inget VIN — men kan vara ett serienummer.
expect(tolkaKod("YV1DZ8256F212345")?.typ).toBe("Serienummer");
// I/O/Q förekommer aldrig i VIN → klassas som serienummer.
expect(tolkaKod("YV1DZ8256F212345O")?.typ).toBe("Serienummer");
});
it("känner igen svenska registreringsnummer i båda serierna", () => {
expect(tolkaKod("ABC123")).toEqual({ varde: "ABC123", typ: "Regnr" });
expect(tolkaKod("abc12a")).toEqual({ varde: "ABC12A", typ: "Regnr" });
expect(tolkaKod("ABC 123")?.typ).toBe("Regnr");
});
it("plockar ut identifieraren ur QR-innehåll med fält eller URL", () => {
expect(tolkaKod("https://verkstad.se/fordon?vin=YV1DZ8256F2123456")).toEqual({
varde: "YV1DZ8256F2123456",
typ: "VIN",
});
expect(tolkaKod("REGNR: ABC123")).toEqual({ varde: "ABC123", typ: "Regnr" });
expect(tolkaKod("vin YV1DZ8256F2123456")?.typ).toBe("VIN");
});
it("tar maskin-/serienummer som de är men avvisar fritext och nakna URL:er", () => {
expect(tolkaKod("SN-4471/A")).toEqual({ varde: "SN4471/A", typ: "Serienummer" });
expect(tolkaKod("https://exempel.se/nagot-helt-annat")).toBeNull();
expect(tolkaKod("Kontrollera oljenivån innan start")).toBeNull();
expect(tolkaKod(" ")).toBeNull();
expect(tolkaKod("AB")).toBeNull();
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { LoggPost } from "../domain";
import { flataIhop } from "../synk";
function post(id: string, tidpunkt: string, anvandare = "Anna"): LoggPost {
return { id, tidpunkt, anvandare, handelse: { typ: "kommentar", text: id } };
}
describe("flataIhop", () => {
const lokala = [post("a", "2026-08-02T08:00:00.000Z"), post("c", "2026-08-02T08:10:00.000Z")];
const fjarr = [post("a", "2026-08-02T08:00:00.000Z"), post("b", "2026-08-02T08:05:00.000Z", "Johan")];
it("flätar ihop två enheters loggar utan dubbletter, i tidsordning", () => {
const resultat = flataIhop(lokala, fjarr);
expect(resultat.map((p) => p.id)).toEqual(["a", "b", "c"]);
});
it("skriver aldrig över: alla poster från båda källorna finns kvar", () => {
const resultat = flataIhop(lokala, fjarr);
expect(resultat).toHaveLength(3);
expect(resultat.find((p) => p.id === "b")?.anvandare).toBe("Johan");
});
it("är idempotent och deterministisk", () => {
const enGang = flataIhop(lokala, fjarr);
const tvaGanger = flataIhop(enGang, fjarr);
expect(tvaGanger).toEqual(enGang);
expect(flataIhop(fjarr, lokala).map((p) => p.id)).toEqual(enGang.map((p) => p.id));
});
it("använder id som stabil ordning vid exakt samma tidpunkt", () => {
const samtidiga = flataIhop(
[post("x2", "2026-08-02T08:00:00.000Z")],
[post("x1", "2026-08-02T08:00:00.000Z")],
);
expect(samtidiga.map((p) => p.id)).toEqual(["x1", "x2"]);
});
});
+302
View File
@@ -0,0 +1,302 @@
// AI-handledning: Claude-orkestern, driven av plattformen.
//
// Vi kör flera modeller i plattformens infrastruktur och routar per
// uppgift. Klienten skickar bara uppgiftstyp + underlag till backend
// (edge-funktionen felsokning-ai); servern äger modellval, effort,
// systemprompt och svarsschema. Inga AI-nycklar hanteras eller lagras
// någonsin i klienten, och svaret kommer alltid tillbaka strikt
// klassificerat enligt AI-reglerna.
//
// Orkestern (serverns routing):
// - handledning → Claude Sonnet 5 (realtidssvar på varje dokumentation)
// - granskning → Claude Opus 5 (motsägelser/luckor i hela underlaget)
// - sammanfattning → Claude Sonnet 5 (överlämning: risker & osäkerheter)
// - metodikval → Claude Haiku 4.5 (klassificering av felbeskrivning)
// - dokumenttolkning → Claude Sonnet 5 (vision: skannad arbetsorder → fält)
import type { Arende } from "./domain";
import { handelseRubrik } from "./domain";
import type { Metodik } from "./metodik";
import { METODIKER } from "./metodik";
import type { Brief } from "./projektioner";
import { brief } from "./projektioner";
export type AiUppgift =
| "handledning"
| "granskning"
| "sammanfattning"
| "metodikval"
| "dokumenttolkning"
| "instrumentavlasning";
export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation";
export const AI_RADTYP_LABEL: Record<AiRadTyp, string> = {
observation: "Observation",
verifierat: "Verifierat",
hypotes: "Hypotes (ej verifierad)",
rekommendation: "Rekommendation",
};
export interface AiRad {
typ: AiRadTyp;
text: string;
}
export interface AiSvar {
rader: AiRad[];
nastaSteg: string;
}
export interface AiSvarMedModell extends AiSvar {
modell: string;
}
export function byggAnvandarPrompt(brief: Brief, metodikNamn: string, inmatning?: string): string {
const rader: string[] = [];
rader.push(`Metodik: ${metodikNamn}`);
if (brief.objekt) rader.push(`Objekt: ${brief.objekt.beskrivning} (${brief.objekt.identifierare})`);
if (brief.felbeskrivning) rader.push(`Felbeskrivning: ${brief.felbeskrivning}`);
if (brief.utfordaKontroller.length > 0) {
rader.push("Utförda kontroller:");
for (const k of brief.utfordaKontroller) rader.push(`- ${k.text}${k.resultat ? `: ${k.resultat}` : ""}`);
}
if (brief.observationer.length > 0) {
rader.push("Observationer och mätvärden:");
for (const o of brief.observationer) rader.push(`- ${o}`);
}
if (brief.ejKontrollerat.length > 0) {
rader.push(`Ej kontrollerat enligt metodiken: ${brief.ejKontrollerat.join("; ")}`);
}
if (inmatning) {
rader.push("");
rader.push(`Teknikerns nya inmatning: ${inmatning}`);
}
return rader.join("\n");
}
// Granskningen får hela arbetsloggen, inte bara briefen.
export function byggGranskningsPrompt(arende: Arende, metodik: Metodik): string {
const kontext = byggAnvandarPrompt(brief(arende, metodik), metodik.namn);
const logg = arende.handelser
.map((p) => `${p.tidpunkt.slice(11, 16)} ${p.anvandare}: ${handelseRubrik(p)}`)
.join("\n");
return `${kontext}\n\nFullständig arbetslogg:\n${logg}`;
}
export function tolkaAiSvar(data: unknown): AiSvar {
const svar = data as AiSvar;
if (!svar || !Array.isArray(svar.rader) || typeof svar.nastaSteg !== "string") {
throw new Error("Oväntat AI-svarsformat");
}
const giltiga: AiRadTyp[] = ["observation", "verifierat", "hypotes", "rekommendation"];
const rader = svar.rader.filter(
(rad): rad is AiRad => !!rad && giltiga.includes(rad.typ) && typeof rad.text === "string",
);
return { rader, nastaSteg: svar.nastaSteg };
}
// ---- Arbetsorderskanning (dokumenttolkning) ---------------------------
// Teknikern fotograferar arbetsorderns framsida; AI:n läser dokumentet
// (OCR + layoutförståelse) och returnerar strukturerade fält med
// konfidens per värde. Bara osäkra fält behöver granskas.
export type ArbetsorderGrupp = "Kund" | "Personbil" | "Verkstad" | "Ärende";
export interface TolkatFalt {
id: string;
etikett: string;
grupp: ArbetsorderGrupp;
varde: string;
konfidens: number; // 01
// Ungefärlig position i den skannade bilden, normaliserad 01.
omrade?: { x: number; y: number; bredd: number; hojd: number };
}
export const ARBETSORDER_FALT: { id: string; grupp: ArbetsorderGrupp; etikett: string }[] = [
{ id: "kund_namn", grupp: "Kund", etikett: "Namn" },
{ id: "kund_foretag", grupp: "Kund", etikett: "Företag" },
{ id: "kund_telefon", grupp: "Kund", etikett: "Telefon" },
{ id: "kund_epost", grupp: "Kund", etikett: "E-post" },
{ id: "fordon_regnr", grupp: "Personbil", etikett: "Regnr" },
{ id: "fordon_vin", grupp: "Personbil", etikett: "VIN" },
{ id: "fordon_marke", grupp: "Personbil", etikett: "Märke" },
{ id: "fordon_modell", grupp: "Personbil", etikett: "Modell" },
{ id: "fordon_motor", grupp: "Personbil", etikett: "Motor" },
{ id: "fordon_arsmodell", grupp: "Personbil", etikett: "Årsmodell" },
{ id: "fordon_matarstallning", grupp: "Personbil", etikett: "Mätarställning" },
{ id: "fordon_motorkod", grupp: "Personbil", etikett: "Motorkod" },
{ id: "fordon_vaxellada", grupp: "Personbil", etikett: "Växellåda" },
{ id: "ao_nummer", grupp: "Verkstad", etikett: "Arbetsordernr" },
{ id: "ao_claim", grupp: "Verkstad", etikett: "Claim-/garantinr" },
{ id: "ao_skadenummer", grupp: "Verkstad", etikett: "Skadenummer" },
{ id: "ao_referens", grupp: "Verkstad", etikett: "Intern referens" },
{ id: "ao_serviceradgivare", grupp: "Verkstad", etikett: "Servicerådgivare" },
{ id: "ao_bokningsdatum", grupp: "Verkstad", etikett: "Bokningsdatum" },
{ id: "felbeskrivning", grupp: "Ärende", etikett: "Felbeskrivning" },
];
const ARBETSORDER_PROMPT =
"Tolka den bifogade arbetsordern från en fordonsverkstad och extrahera fälten.";
// Rensar AI-svaret: okända fält-id filtreras, konfidens klipps till 01,
// tomma värden tas bort och etikett/grupp slås upp ur katalogen.
export function normaliseraArbetsorder(data: unknown): TolkatFalt[] {
const rader = (data as { falt?: unknown }).falt;
if (!Array.isArray(rader)) throw new Error("Oväntat tolkningsformat");
const resultat: TolkatFalt[] = [];
for (const rad of rader as Partial<TolkatFalt>[]) {
const def = ARBETSORDER_FALT.find((f) => f.id === rad?.id);
if (!def || typeof rad.varde !== "string" || !rad.varde.trim()) continue;
if (resultat.some((f) => f.id === def.id)) continue;
const konfidens = typeof rad.konfidens === "number" ? Math.min(1, Math.max(0, rad.konfidens)) : 0;
const o = rad.omrade;
const omrade =
o && [o.x, o.y, o.bredd, o.hojd].every((v) => typeof v === "number" && v >= 0 && v <= 1)
? o
: undefined;
resultat.push({ id: def.id, etikett: def.etikett, grupp: def.grupp, varde: rad.varde.trim(), konfidens, omrade });
}
return resultat;
}
export async function tolkaArbetsorder(
dataUrl: string,
): Promise<{ falt: TolkatFalt[]; modell: string } | null> {
const resultat = await anropa("dokumenttolkning", ARBETSORDER_PROMPT, { bild: dataUrl });
return resultat ? { falt: normaliseraArbetsorder(resultat.svar), modell: resultat.modell } : null;
}
// ---- Instrumentavläsning (visual-first) -------------------------------
// Kameran är det universella gränssnittet: när ett instrument, en
// diagnosdator eller ett dokument visar information fotograferas det —
// systemet extraherar värden, enheter och felkoder med konfidens per
// värde. Originalbilden bevaras alltid som evidens.
export interface InstrumentVarde {
beskrivning: string;
varde: string;
enhet?: string;
konfidens: number; // 01
}
export interface InstrumentTolkning {
instrumenttyp: string;
varden: InstrumentVarde[];
}
export function normaliseraInstrument(data: unknown): InstrumentTolkning {
const d = data as Partial<InstrumentTolkning>;
if (!d || typeof d.instrumenttyp !== "string" || !Array.isArray(d.varden)) {
throw new Error("Oväntat avläsningsformat");
}
const varden: InstrumentVarde[] = [];
for (const rad of d.varden as Partial<InstrumentVarde>[]) {
if (typeof rad?.beskrivning !== "string" || typeof rad.varde !== "string" || !rad.varde.trim()) continue;
varden.push({
beskrivning: rad.beskrivning.trim(),
varde: rad.varde.trim(),
enhet: typeof rad.enhet === "string" && rad.enhet.trim() ? rad.enhet.trim() : undefined,
konfidens: typeof rad.konfidens === "number" ? Math.min(1, Math.max(0, rad.konfidens)) : 0,
});
}
return { instrumenttyp: d.instrumenttyp.trim() || "instrument", varden };
}
export async function lasAvInstrument(
dataUrl: string,
): Promise<{ tolkning: InstrumentTolkning; modell: string } | null> {
const resultat = await anropa(
"instrumentavlasning",
"Läs av instrumentet/skärmen i den bifogade bilden.",
{ bild: dataUrl },
);
return resultat ? { tolkning: normaliseraInstrument(resultat.svar), modell: resultat.modell } : null;
}
// I Kubernetes-driften pekar VITE_AI_ORKESTER_URL på orkestertjänsten
// (services/ai-orkester); utan den används Supabase-edge-funktionen.
const ORKESTER_URL = (import.meta.env.VITE_AI_ORKESTER_URL as string | undefined)?.replace(/\/$/, "");
// Returnerar null i lokalt läge (ej inloggad) — orkestern nås via
// plattformens autentiserade backend.
async function anropa(
uppgift: AiUppgift,
prompt: string,
extra?: Record<string, unknown>,
): Promise<{ modell: string; svar: unknown } | null> {
const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform");
let token: string | null = null;
if (plattformAktiv()) {
// Självhostat: plattformens egen JWT — samma hemlighet verifieras
// av AI-orkestern i klustret.
token = plattformToken();
if (!token) return null;
} else {
const { supabase } = await import("@/integrations/supabase/client");
const { data } = await supabase.auth.getSession();
if (!data.session) return null;
token = data.session.access_token;
}
let resultat: unknown;
const bas = ORKESTER_URL ?? (plattformAktiv() ? PLATTFORM_URL : undefined);
if (bas) {
const res = await fetch(`${bas}/api/ai`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ uppgift, prompt, ...extra }),
});
if (!res.ok) throw new Error(`AI-orkestern svarade ${res.status}`);
resultat = await res.json();
} else {
const { supabase } = await import("@/integrations/supabase/client");
const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", {
body: { uppgift, prompt, ...extra },
});
if (error) throw error;
resultat = svar;
}
const { modell, svar } = resultat as { modell?: string; svar?: unknown };
if (typeof modell !== "string") throw new Error("Oväntat svar från AI-endpointen");
return { modell, svar };
}
export async function fragaAi(
briefData: Brief,
metodikNamn: string,
inmatning: string,
): Promise<AiSvarMedModell | null> {
const resultat = await anropa("handledning", byggAnvandarPrompt(briefData, metodikNamn, inmatning));
return resultat ? { ...tolkaAiSvar(resultat.svar), modell: resultat.modell } : null;
}
export async function granskaUnderlag(arende: Arende, metodik: Metodik): Promise<AiSvarMedModell | null> {
const resultat = await anropa("granskning", byggGranskningsPrompt(arende, metodik));
return resultat ? { ...tolkaAiSvar(resultat.svar), modell: resultat.modell } : null;
}
export async function sammanfattaOverlamning(
arende: Arende,
metodik: Metodik,
): Promise<AiSvarMedModell | null> {
const resultat = await anropa("sammanfattning", byggGranskningsPrompt(arende, metodik));
return resultat ? { ...tolkaAiSvar(resultat.svar), modell: resultat.modell } : null;
}
// Klassificerar felbeskrivningen till en metodik; null om lokalt läge,
// anropet misslyckas eller svaret inte är en känd metodik.
export async function valjMetodikMedAi(felbeskrivning: string): Promise<string | null> {
try {
const resultat = await anropa("metodikval", felbeskrivning);
if (!resultat) return null;
const { metodikId } = resultat.svar as { metodikId?: string };
return METODIKER.some((m) => m.id === metodikId) ? (metodikId as string) : null;
} catch {
return null;
}
}
+96
View File
@@ -0,0 +1,96 @@
// Bilagor på klientsidan.
//
// Innehållet ligger inte längre i händelsen utan hämtas separat. Det gör
// synken lätt igen: en ärendesynk var femtonde sekund drar inte med sig
// hela bildmassan, och kundvyn laddar bara de bilder som faktiskt visas.
//
// I lokalt läge (ingen inloggning) finns ingen server att ladda upp till.
// Då används data-URL:en precis som förut — appen ska fungera utan
// backend, och en tekniker som dokumenterar offline får inte tappa något.
import type { Bilaga } from "./domain";
// Objekt-URL:er per bilage-id. Innehållsadresserat på servern, så en
// hämtad bilaga kan cachas obegränsat under sidans livstid.
const cache = new Map<string, Promise<string | null>>();
export function harBilaga(b: Bilaga | undefined): boolean {
return !!b && (!!b.bilagaId || !!b.dataUrl);
}
/**
* Laddar upp innehållet och returnerar referensen som ska sparas i
* händelsen. Returnerar null i lokalt läge — då sparas data-URL:en i
* stället.
*/
export async function laddaUppBilaga(
arendeId: string,
dataUrl: string,
): Promise<{ bilagaId: string; bilagaHash: string } | null> {
const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform");
if (!plattformAktiv() || !plattformToken()) return null;
const svar = await fetch(dataUrl);
const blob = await svar.blob();
const res = await fetch(`${PLATTFORM_URL}/api/arenden/${arendeId}/bilagor`, {
method: "POST",
headers: {
"Content-Type": blob.type || "application/octet-stream",
Authorization: `Bearer ${plattformToken()}`,
},
body: blob,
});
if (!res.ok) throw new Error(`Bilagan kunde inte sparas (${res.status}).`);
const { id, hash } = (await res.json()) as { id: string; hash: string };
return { bilagaId: id, bilagaHash: hash };
}
/**
* Bygger det som ska in i händelsen. Lyckas uppladdningen bär händelsen
* en referens; annars data-URL:en. Det senare är inte ett fel utan
* lokalt läge — dokumentationen får aldrig gå förlorad för att nätet
* ligger nere.
*/
export async function bilageFalt(arendeId: string, dataUrl: string): Promise<Bilaga> {
try {
const referens = await laddaUppBilaga(arendeId, dataUrl);
if (referens) return referens;
} catch {
// Faller igenom till inbäddning nedan.
}
return { dataUrl };
}
/**
* Var innehållet finns att hämta. `delningskod` sätts i den publika
* vyn — där går hämtningen via delningens egen väg, som filtrerar på
* samma nivå som händelserna.
*/
export async function hamtaBilaga(b: Bilaga, delningskod?: string): Promise<string | null> {
if (b.dataUrl) return b.dataUrl;
if (!b.bilagaId) return null;
const nyckel = `${delningskod ?? ""}:${b.bilagaId}`;
const befintlig = cache.get(nyckel);
if (befintlig) return befintlig;
const hamtning = (async () => {
const { PLATTFORM_URL, plattformToken } = await import("./plattform");
if (!PLATTFORM_URL) return null;
const url = delningskod
? `${PLATTFORM_URL}/api/delad/${delningskod}/bilagor/${b.bilagaId}`
: `${PLATTFORM_URL}/api/bilagor/${b.bilagaId}`;
const token = plattformToken();
const res = await fetch(url, {
headers: !delningskod && token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return null;
return URL.createObjectURL(await res.blob());
})();
cache.set(nyckel, hamtning);
return hamtning;
}
+200
View File
@@ -0,0 +1,200 @@
// Demoärende: vibrationsexemplet ur visionen, komplett med besvarade
// symptomfrågor, verifierade kontroller, mätvärden, foton, kategoribyten
// och en hypotes — så att brief, tidrapport, kundrapport och Live Share
// har verkligt innehåll direkt.
import type { Arende, Handelse, LoggPost } from "./domain";
import type { InstrumentTolkning, TolkatFalt } from "./ai";
import { nyLoggPost } from "./domain";
// Enkla platshållarbilder ritade i canvas — ersätts av riktiga foton så
// fort en kamera används. Fallback (1×1) när canvas saknas (t.ex. tester).
function demoFoto(text: string): string {
try {
const canvas = document.createElement("canvas");
canvas.width = 480;
canvas.height = 360;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas saknas");
ctx.fillStyle = "#27272a";
ctx.fillRect(0, 0, 480, 360);
ctx.strokeStyle = "#52525b";
ctx.lineWidth = 22;
ctx.beginPath();
ctx.arc(240, 170, 110, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "#a1a1aa";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(240, 170, 58, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < 5; i++) {
const v = (i * 2 * Math.PI) / 5 - Math.PI / 2;
ctx.beginPath();
ctx.moveTo(240, 170);
ctx.lineTo(240 + Math.cos(v) * 58, 170 + Math.sin(v) * 58);
ctx.stroke();
}
ctx.fillStyle = "#fbbf24";
ctx.font = "bold 22px sans-serif";
ctx.textAlign = "center";
ctx.fillText(text, 240, 330);
ctx.fillStyle = "#71717a";
ctx.font = "14px sans-serif";
ctx.fillText("Demobild", 240, 26);
return canvas.toDataURL("image/jpeg", 0.7);
} catch {
return "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
}
}
export function byggDemoArende(nummer: number, anvandare = "Anna"): Arende {
const start = Date.now() - 97 * 60000; // 1 tim 37 min sedan
const vid = (minuter: number) => new Date(start + minuter * 60000).toISOString();
const post = (minuter: number, handelse: Handelse, vem = anvandare): LoggPost =>
nyLoggPost(vem, handelse, vid(minuter));
const handelser: LoggPost[] = [
post(0, {
typ: "objekt_identifierat",
objekt: {
typ: "Personbil",
identifierare: "ABC123",
identifieringsmetod: "Regnr",
beskrivning: "Volvo XC60 D4 2019",
kund: "Anders Svensson",
vin: "YV1DZ8256F2123456",
miltal: "8 432 mil",
arbetsorder: "AO-2496",
},
}),
post(2, { typ: "felbeskrivning", text: "Bilen vibrerar runt 88 km/h. Symptomet uppträder endast under körning." }),
// Pre-diagnostiken: historik, ingående mätarställning, verifierad
// felbeskrivning och tidiga observationer — innan metodiken börjar.
post(3, { typ: "historik_kontrollerad", kontrollerad: true, kommentar: "Däck roterade vid service för 2 månader sedan" }),
post(3, { typ: "matarstallning", lage: "ingaende", varde: "84 320 km", dataUrl: demoFoto("Instrumentpanel 84 320 km") }),
post(4, { typ: "kommentar", text: "Kundens felbeskrivning verifierad vid mottagandet." }),
post(4, { typ: "kommentar", text: "Inga ytterligare observationer vid mottagandet." }),
post(4, { typ: "fraga_besvarad", stegId: "symptom", frageId: "hastighetsberoende", fraga: "Är vibrationen hastighetsberoende?", svar: "Ja" }),
post(5, { typ: "fraga_besvarad", stegId: "symptom", frageId: "var", fraga: "Var känns vibrationen?", svar: "I ratten" }),
post(6, { typ: "fraga_besvarad", stegId: "symptom", frageId: "nar", fraga: "När uppstår vibrationen?", svar: "Vid jämn fart" }),
post(8, { typ: "fraga_besvarad", stegId: "symptom", frageId: "intervall", fraga: "Försvinner den över eller under ett visst hastighetsintervall?", svar: "Märks tydligast 8595 km/h, försvinner under 80" }),
post(12, { typ: "foto", beskrivning: "Fotografera vänster framhjul", dataUrl: demoFoto("Vänster framhjul") }),
post(12, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "foto_vf", text: "Fotografera vänster framhjul" }),
post(14, { typ: "foto", beskrivning: "Fotografera höger framhjul", dataUrl: demoFoto("Höger framhjul") }),
post(14, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "foto_hf", text: "Fotografera höger framhjul" }),
post(16, { typ: "foto", beskrivning: "Fotografera vänster bakhjul", dataUrl: demoFoto("Vänster bakhjul") }),
post(16, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "foto_vb", text: "Fotografera vänster bakhjul" }),
post(18, { typ: "foto", beskrivning: "Fotografera höger bakhjul", dataUrl: demoFoto("Höger bakhjul") }),
post(18, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "foto_hb", text: "Fotografera höger bakhjul" }),
post(22, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "dot", text: "Dokumentera DOT-/tillverkningsdatum", resultat: "Fram: DOT 2318, bak: DOT 2318 — samtliga från vecka 23 2018" }),
post(26, { typ: "observation", text: "Höger framdäck visar ojämnt slitage på innerkanten" }),
post(26, { typ: "kontroll_utford", stegId: "visuell", kontrollId: "slitage", text: "Kontrollera däckslitage", resultat: "Ojämnt slitage höger fram, övriga ok" }),
post(31, { typ: "matvarde", beskrivning: "Lufttryck vänster fram", varde: "2,4", enhet: "bar" }),
post(32, { typ: "matvarde", beskrivning: "Lufttryck höger fram", varde: "2,1", enhet: "bar" }),
post(33, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "lufttryck", text: "Kontrollera lufttryck", resultat: "VF 2,4 / HF 2,1 / VB 2,4 / HB 2,4 bar — justerat till 2,4 runtom" }),
post(38, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "hjulmoment", text: "Kontrollera hjulmoment", resultat: "140 Nm samtliga hjul, ok" }),
post(40, { typ: "hypotes", text: "Obalans eller kast i höger framhjul", niva: "lag" }),
post(41, { typ: "kategori_byte", kategori: "provkorning" }),
post(55, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_hastighet", text: "Hastighet där vibration uppstår", resultat: "Tydlig vibration 8595 km/h, max vid 88" }),
post(56, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_acc", text: "Förändring vid acceleration", resultat: "Ingen förändring vid acceleration" }),
post(57, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_motorbroms", text: "Förändring vid motorbroms", resultat: "Ingen förändring vid motorbroms" }),
post(58, { typ: "kontroll_utford", stegId: "provkorning", kontrollId: "pk_var", text: "Om vibration känns i ratt eller kaross", resultat: "Endast i ratten" }),
post(60, { typ: "kategori_byte", kategori: "aktiv_felsokning" }),
post(75, { typ: "inaktivitet_forklarad", text: "Lyfte bilen och demonterade höger framhjul för kontroll i balanseringsmaskin.", minuter: 15 }),
post(82, { typ: "matvarde", beskrivning: "Obalans höger framhjul", varde: "38", enhet: "g" }),
post(83, { typ: "kontroll_utford", stegId: "kontroller", kontrollId: "balansering", text: "Kontrollera hjulbalansering", resultat: "38 g obalans höger fram — en balansvikt saknas. Övriga hjul inom 5 g." }),
post(85, { typ: "observation", text: "Spår efter lossnad klistervikt på höger framfälgs insida" }),
post(85, {
typ: "reproducering",
status: "ja",
beskrivning: "Reproducerad vid provkörning på plan väg: vibrationen återskapades tre gånger mellan 92 och 108 km/h, tydligast i ratten.",
}),
post(86, {
typ: "felorsak",
avvikelse: "Framhjulen uppvisar obalans (38 g höger fram). Ingen deformation av fälgar eller däck kunde konstateras.",
orsaker: ["Yttre påverkan"],
underlag: ["Mätresultat", "Foto", "Direkt observation"],
sakerhet: "medel",
atgard: "Balansera framhjulen och genomför ny provkörning i 80100 km/h.",
ytterligareKontroller: "Ny provkörning efter balansering bekräftar att symptomet är åtgärdat.",
}),
// Exempel på AI-handledning, märkt "demo" — riktiga svar kräver API-nyckel.
post(86, {
typ: "ai_svar",
rader: [
{ typ: "verifierat", text: "Obalans uppmätt: 38 g höger framhjul. Övriga hjul inom tolerans." },
{ typ: "observation", text: "Spår efter lossnad klistervikt förklarar var vikten suttit — orsaken till obalansen är dock inte verifierad." },
{ typ: "rekommendation", text: "Balansera om hjulet och verifiera att symptomet försvinner innan ärendet avslutas." },
],
nastaSteg: "Balansera om höger framhjul och genomför ny provkörning i 80100 km/h.",
modell: "demo",
}),
post(86, {
typ: "atgardsforslag",
beskrivning: "Balansera om höger framhjul och genomför ny provkörning i 80100 km/h.",
uppskattadKostnad: "890 kr inkl. moms",
}),
post(87, { typ: "kategori_byte", kategori: "kundkontakt" }),
post(87, {
typ: "kundbeslut",
beslut: "godkant",
kanal: "Telefon",
kommentar: "Kunden godkände ombalansering.",
}),
post(87, { typ: "kategori_byte", kategori: "aktiv_felsokning" }),
post(87, {
typ: "atgard_utford",
beskrivning: "Balanserade om höger framhjul och monterade ny balansvikt på fälgens insida.",
delar: "Balansvikter 40 g",
utford: true,
}),
post(88, {
typ: "kvalitetskontroll",
resultat: "symptomet_borta",
beskrivning: "Ny provkörning 80110 km/h på samma vägsträcka — ingen vibration i ratten.",
}),
post(90, { typ: "overlamning", fran: anvandare, till: "Johan" }),
post(92, { typ: "kategori_byte", kategori: "administration" }, "Johan"),
post(95, { typ: "kommentar", text: "Balanserat om höger framhjul, ny provkörning planerad." }, "Johan"),
];
return {
id: `arende-demo-${start.toString(36)}`,
nummer,
skapad: vid(0),
delningskod: `demo-${start.toString(36)}`,
handelser,
};
}
// Demo-tolkning av en arbetsorder — används i lokalt läge där plattformens
// dokumenttolkning inte kan nås, så att flödet (skanna → granska osäkra
// fält → starta diagnos) kan provas ändå. Märks alltid som demo i UI:t.
export function byggDemoTolkning(): TolkatFalt[] {
return [
{ id: "kund_namn", etikett: "Namn", grupp: "Kund", varde: "Anders Svensson", konfidens: 0.98 },
{ id: "kund_telefon", etikett: "Telefon", grupp: "Kund", varde: "070-123 45 67", konfidens: 0.91, omrade: { x: 0.08, y: 0.22, bredd: 0.3, hojd: 0.04 } },
{ id: "fordon_regnr", etikett: "Regnr", grupp: "Personbil", varde: "ABC123", konfidens: 0.99, omrade: { x: 0.62, y: 0.12, bredd: 0.2, hojd: 0.05 } },
{ id: "fordon_vin", etikett: "VIN", grupp: "Personbil", varde: "YV1DZ8256F2123456", konfidens: 0.74, omrade: { x: 0.55, y: 0.19, bredd: 0.38, hojd: 0.04 } },
{ id: "fordon_marke", etikett: "Märke", grupp: "Personbil", varde: "Volvo", konfidens: 0.99 },
{ id: "fordon_modell", etikett: "Modell", grupp: "Personbil", varde: "XC60 D4", konfidens: 0.97 },
{ id: "fordon_arsmodell", etikett: "Årsmodell", grupp: "Personbil", varde: "2019", konfidens: 0.96 },
{ id: "fordon_matarstallning", etikett: "Mätarställning", grupp: "Personbil", varde: "8 432 mil", konfidens: 0.87, omrade: { x: 0.62, y: 0.27, bredd: 0.18, hojd: 0.04 } },
{ id: "ao_nummer", etikett: "Arbetsordernr", grupp: "Verkstad", varde: "AO-2496", konfidens: 0.99 },
{ id: "ao_serviceradgivare", etikett: "Servicerådgivare", grupp: "Verkstad", varde: "M. Lindqvist", konfidens: 0.82 },
{ id: "felbeskrivning", etikett: "Felbeskrivning", grupp: "Ärende", varde: "Kunden upplever vibrationer i ratten vid ca 90 km/h", konfidens: 0.97, omrade: { x: 0.08, y: 0.45, bredd: 0.84, hojd: 0.07 } },
];
}
// Demo-avläsning av ett instrument — används i lokalt läge där
// bildtolkningen inte kan nås. Märks alltid som demo i UI:t.
export function byggDemoInstrument(): InstrumentTolkning {
return {
instrumenttyp: "multimeter",
varden: [
{ beskrivning: "Batterispänning (vila)", varde: "12,4", enhet: "V", konfidens: 0.97 },
{ beskrivning: "Spänning vid start", varde: "9,8", enhet: "V", konfidens: 0.84 },
],
};
}
+281
View File
@@ -0,0 +1,281 @@
// Domänmodell för Guidad Felsökning.
// Händelseloggen är append-only och är ärendets enda sanningskälla.
// Allt annat (brief, tidrapport, kundrapport) är projektioner av loggen.
export type Tillforlitlighet = "hog" | "medel" | "lag";
export const TILLFORLITLIGHET_LABEL: Record<Tillforlitlighet, string> = {
hog: "Hög",
medel: "Medel",
lag: "Låg",
};
export type TidKategori =
| "aktiv_felsokning"
| "vantetid"
| "administration"
| "reservdelssokning"
| "provkorning"
| "kundkontakt"
| "paus";
export const TIDKATEGORI_LABEL: Record<TidKategori, string> = {
aktiv_felsokning: "Aktiv felsökning",
vantetid: "Väntetid",
administration: "Administration",
reservdelssokning: "Reservdelssökning",
provkorning: "Provkörning",
kundkontakt: "Kundkontakt",
paus: "Paus",
};
// Kundens besked på ett lämnat åtgärdsförslag.
export const KUNDBESLUT_LABEL: Record<"godkant" | "avbojt" | "delvis", string> = {
godkant: "Godkänt",
avbojt: "Avböjt",
delvis: "Delvis godkänt",
};
// Kvalitetskontrollens utfall efter utförd åtgärd.
export const KVALITETSKONTROLL_LABEL: Record<
"symptomet_borta" | "kvarstar" | "delvis" | "ej_verifierbar",
string
> = {
symptomet_borta: "Symptomet är borta",
kvarstar: "Symptomet kvarstår",
delvis: "Symptomet delvis kvar",
ej_verifierbar: "Kunde inte verifieras",
};
// Fordonsobjektet är den röda tråden genom hela ärendet: identiteten
// registreras en gång och återanvänds i felsökning, Live Share, rapport
// och export. Ärendereferenserna (AO/claim/skadenummer) hör till objektet
// så att de följer med i alla vyer utan att kunduppgifter läcker.
export interface Objekt {
typ: string; // t.ex. "Personbil", "Industrimaskin"
identifierare: string; // reg.nr, VIN, serienummer …
identifieringsmetod: string; // "Regnr", "VIN", "Serienummer", "Manuell inmatning"
beskrivning: string; // t.ex. "Volvo XC60 D4 2019"
kund?: string;
vin?: string;
miltal?: string;
arbetsorder?: string;
claim?: string;
skadenummer?: string;
}
// Ett fält tolkat ur en skannad arbetsorder, med AI:ns läs-säkerhet.
export interface ArbetsorderFalt {
id: string;
etikett: string;
varde: string;
konfidens: number; // 01
}
// Varje händelse är en av dessa typer. Ingen händelse ändras eller tas bort.
// Bilagans innehåll ligger utanför händelsen: loggen bär referensen och
// innehållets SHA-256. Hashen står därmed i den append-only-skyddade
// loggen och en utbytt bild går att upptäcka.
//
// dataUrl finns kvar och kommer alltid att finnas kvar. Loggen är
// append-only, så händelser som skrevs när bilden låg inbäddad måste
// gå att läsa för alltid. Lokalt läge (utan inloggning) använder den
// också — där finns ingen server att ladda upp till.
export interface Bilaga {
bilagaId?: string;
bilagaHash?: string;
dataUrl?: string;
}
export type Handelse =
| { typ: "objekt_identifierat"; objekt: Objekt }
| ({ typ: "arbetsorder_skannad"; falt: ArbetsorderFalt[] } & Bilaga)
| { typ: "felbeskrivning"; text: string }
| { typ: "fraga_besvarad"; stegId: string; frageId: string; fraga: string; svar: string }
// undantag: underlaget kunde inte tas fram — obligatorisk orsak i stället
// för evidens. Kontrollen räknas som hanterad men flaggas i brief/rapport.
| { typ: "kontroll_utford"; stegId: string; kontrollId: string; text: string; resultat?: string; undantag?: string }
| { typ: "observation"; text: string }
| { typ: "matvarde"; beskrivning: string; varde: string; enhet?: string }
| { typ: "hypotes"; text: string; niva: Exclude<Tillforlitlighet, "hog"> }
| ({ typ: "foto"; beskrivning: string } & Bilaga)
// Video med ljud (E3-evidens): för det som låter eller rör sig —
// motorljud, hjullager, fjädringsrörelse. Kort och komprimerad;
// originalfilen bevaras som evidens precis som foton.
| ({ typ: "video"; beskrivning: string } & Bilaga)
| { typ: "kommentar"; text: string }
| { typ: "kategori_byte"; kategori: TidKategori }
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
| { typ: "overlamning"; fran: string; till?: string }
| { typ: "ansvarig_satt"; ansvarig: string }
// Ärendetypen styr vilka dokumentationskrav ECM ställer (garanti,
// försäkring, reklamation …).
| { typ: "arendetyp_satt"; arendetyp: string }
// Pre-diagnostik: fordonshistoriken kontrollerad — eller motiverat
// varför inte (kvalitetsvarning).
| { typ: "historik_kontrollerad"; kontrollerad: boolean; kommentar?: string }
// Officiell mätarställning in/ut, normalt med foto av instrumentpanelen.
| ({ typ: "matarstallning"; lage: "ingaende" | "utgaende"; varde: string; undantag?: string } & Bilaga)
// Symptomverifiering (SVP): kundens beskrivning är inte ett konstaterat
// fel förrän den reproducerats — eller dokumenterats som ej
// reproducerbar med motivering.
| { typ: "reproducering"; status: "ja" | "delvis" | "nej"; beskrivning: string }
// Felorsaksanalys: varje konstaterat fel kräver avvikelse, bedömd
// grundorsak, underlag och säkerhetsnivå — eller motivering till
// varför orsaken inte kunnat fastställas.
| {
typ: "felorsak";
avvikelse: string;
orsaker: string[];
underlag: string[];
sakerhet: Tillforlitlighet;
atgard: string;
motivering?: string;
ytterligareKontroller?: string;
}
// Kundkommunikation: åtgärdsförslag lämnat till kund för godkännande
// innan arbetet påbörjas. Kunddelbart — visas i Live Share.
| {
typ: "atgardsforslag";
beskrivning: string;
uppskattadKostnad?: string;
uppskattadTid?: string;
}
// Kundens besked på förslaget, med kanal och tidpunkt (loggposten bär
// vem i verkstaden som tog emot beskedet).
| {
typ: "kundbeslut";
beslut: "godkant" | "avbojt" | "delvis";
kanal: string;
kommentar?: string;
}
// Åtgärdsfasen: vad som faktiskt gjordes — eller varför ingen åtgärd
// utfördes (kunden avböjde, väntar på delar …). Kopplas till den
// felorsak åtgärden svarar mot.
| {
typ: "atgard_utford";
beskrivning: string;
delar?: string;
utford: boolean;
motivering?: string;
}
// Kvalitetskontroll efter åtgärd: är symptomet borta? Slutar loopen
// som symptomverifieringen (SVP) öppnade.
| {
typ: "kvalitetskontroll";
resultat: "symptomet_borta" | "kvarstar" | "delvis" | "ej_verifierbar";
beskrivning: string;
}
| { typ: "export_skapad"; format: string; version: number }
| {
typ: "ai_svar";
rader: { typ: "observation" | "verifierat" | "hypotes" | "rekommendation"; text: string }[];
nastaSteg: string;
modell: string;
}
// Avslutet signeras av teknikern — slutsatsen får en ansvarig avsändare.
| { typ: "arende_avslutat"; signatur?: string };
export interface LoggPost {
id: string;
tidpunkt: string; // ISO 8601
anvandare: string;
handelse: Handelse;
}
export interface Arende {
id: string;
nummer: number;
skapad: string; // ISO 8601
// Slumpad kod för Live Share-länken; sätts vid skapande och kan aldrig
// ändras (ärenderaden är append-only även i databasen).
delningskod?: string;
// Explicit vald metodik (t.ex. via AI-klassificering vid skapandet).
// Saknas den härleds metodiken ur felbeskrivningen.
metodikId?: string;
handelser: LoggPost[];
}
let raknare = 0;
export function nyLoggPost(anvandare: string, handelse: Handelse, tidpunkt?: string): LoggPost {
raknare += 1;
return {
id: `${Date.now().toString(36)}-${raknare.toString(36)}`,
tidpunkt: tidpunkt ?? new Date().toISOString(),
anvandare,
handelse,
};
}
export function handelseRubrik(post: LoggPost): string {
const h = post.handelse;
switch (h.typ) {
case "objekt_identifierat":
return `Objekt identifierat: ${h.objekt.beskrivning} (${h.objekt.identifierare})`;
case "arbetsorder_skannad":
return `Arbetsorder skannad — ${h.falt.length} fält tolkade`;
case "felbeskrivning":
return `Felbeskrivning registrerad`;
case "fraga_besvarad":
return `${h.fraga}${h.svar}`;
case "kontroll_utford":
if (h.undantag) return `${h.text} — underlag kunde inte tas fram: ${h.undantag}`;
return h.resultat ? `${h.text}${h.resultat}` : `${h.text} — utförd`;
case "observation":
return `Observation: ${h.text}`;
case "matvarde":
return `Mätvärde: ${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`;
case "hypotes":
return `Hypotes (${TILLFORLITLIGHET_LABEL[h.niva]}): ${h.text}`;
case "foto":
return `Foto: ${h.beskrivning}`;
case "video":
return `Video: ${h.beskrivning}`;
case "kommentar":
return h.text;
case "kategori_byte":
return `Arbetskategori: ${TIDKATEGORI_LABEL[h.kategori]}`;
case "inaktivitet_forklarad":
return `Komplettering (${h.minuter} min utan aktivitet): ${h.text}`;
case "overlamning":
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
case "ansvarig_satt":
return `Ansvarig tekniker: ${h.ansvarig}`;
case "arendetyp_satt":
return `Ärendetyp: ${h.arendetyp}`;
case "historik_kontrollerad":
return h.kontrollerad
? `Fordonshistorik kontrollerad${h.kommentar ? `: ${h.kommentar}` : ""}`
: `Fordonshistorik EJ kontrollerad — orsak: ${h.kommentar ?? "saknas"}`;
case "matarstallning":
if (h.undantag) return `Mätarställning (${h.lage === "ingaende" ? "in" : "ut"}) kunde inte dokumenteras: ${h.undantag}`;
return `Mätarställning ${h.lage === "ingaende" ? "in" : "ut"}: ${h.varde}`;
case "reproducering":
return h.status === "ja"
? `Symptomet reproducerat: ${h.beskrivning}`
: h.status === "delvis"
? `Symptomet delvis reproducerat: ${h.beskrivning}`
: `Symptomet kunde inte reproduceras — ${h.beskrivning}`;
case "felorsak":
return `Felorsak (${TILLFORLITLIGHET_LABEL[h.sakerhet]}): ${h.avvikelse}${h.orsaker.join(", ")}`;
case "atgardsforslag":
return `Åtgärdsförslag till kund: ${h.beskrivning}${h.uppskattadKostnad ? ` — uppskattad kostnad ${h.uppskattadKostnad}` : ""}`;
case "kundbeslut":
return `Kundens besked (${h.kanal}): ${KUNDBESLUT_LABEL[h.beslut]}${h.kommentar ? `${h.kommentar}` : ""}`;
case "atgard_utford":
return h.utford
? `Åtgärd utförd: ${h.beskrivning}${h.delar ? ` (delar: ${h.delar})` : ""}`
: `Ingen åtgärd utförd — ${h.motivering ?? "orsak saknas"}`;
case "kvalitetskontroll":
return `Kvalitetskontroll: ${KVALITETSKONTROLL_LABEL[h.resultat]}${h.beskrivning}`;
case "export_skapad":
return `Export skapad: ${h.format}, version ${h.version}`;
case "ai_svar": {
const forsta = h.rader[0];
return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`;
}
case "arende_avslutat":
return h.signatur ? `Felsökning avslutad — signerad av ${h.signatur}` : "Felsökning avslutad";
}
}
+749
View File
@@ -0,0 +1,749 @@
// Evidence & Compliance Matrix (ECM) — plattformens regelmotor.
//
// ECM är ett eget subsystem, inte en tabell i databasen: den avgör vilken
// dokumentation som krävs, när dokumentation saknas, vilken bevisnivå som
// uppnåtts, vilka regler som gäller (per ärendetyp) och om ett ärende kan
// avslutas. Systemet kan aldrig skriva en slutsats som ECM inte godkänt.
//
// Sex motorer:
// 1. Evidence Engine — katalogiserar all bevisning ur loggen
// 2. Rule Engine — dokumentationskraven (metodik + automatiska regler)
// 3. Compliance Engine — vilka regler som gäller per ärendetyp
// 4. Validation Engine — inga påståenden utan underlag ("Evidens saknas")
// 5. Completion Engine — kvalitetsgrind: får ärendet avslutas/rapporteras?
// 6. Traceability Engine — varje evidenspost hash:as och versionsmärks
//
// Regelbiblioteket är versionshanterat och skilt från applikationslogiken;
// vyerna anropar bara de rena funktionerna nedan. Nästa steg är server-
// distribuerade regelpaket (garantivillkor per tillverkare, försäkrings-
// krav, reklamationsregler) som laddas dynamiskt utan appändring.
import type { Arende, LoggPost } from "./domain";
import type { Metodik } from "./metodik";
export const ECM_VERSION = "2.0";
// ---- 1. Evidence Engine -----------------------------------------------
// Evidensnivåer: bevisvärdet för ett påstående, härlett ur loggen.
export type EvidensNiva = "E0" | "E1" | "E2" | "E3" | "E4" | "E5" | "E6";
export const EVIDENS_LABEL: Record<EvidensNiva, string> = {
E0: "E0 · Inget underlag",
E1: "E1 · Teknikerns observation",
E2: "E2 · Foto",
E3: "E3 · Video",
E4: "E4 · Mätvärde",
E5: "E5 · Diagnosdata/dokument",
E6: "E6 · Flera oberoende källor",
};
export interface Evidenspost {
id: string;
tidpunkt: string;
tekniker: string;
kategori: string;
niva: Exclude<EvidensNiva, "E0" | "E6">;
sammanfattning: string;
// Innehållshash för spårbarhet: samma post ger alltid samma hash,
// varje ändringsförsök syns (loggen är dessutom append-only i DB:n).
hash: string;
}
// FNV-1a — snabb, deterministisk innehållshash (spårbarhet, inte krypto;
// manipulationsskyddet ligger i databasens append-only-triggers).
export function innehallsHash(innehall: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < innehall.length; i++) {
h ^= innehall.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
// Katalogiserar varje evidenspost i loggen med nivå, kategori och hash.
export function evidensposter(arende: Arende): Evidenspost[] {
const poster: Evidenspost[] = [];
const lagg = (post: LoggPost, kategori: string, niva: Evidenspost["niva"], sammanfattning: string) =>
poster.push({
id: post.id,
tidpunkt: post.tidpunkt,
tekniker: post.anvandare,
kategori,
niva,
sammanfattning,
hash: innehallsHash(JSON.stringify(post.handelse)),
});
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "foto") lagg(post, "foto", "E2", h.beskrivning);
if (h.typ === "video") lagg(post, "video", "E3", h.beskrivning);
if (h.typ === "matvarde") lagg(post, "mätvärde", "E4", `${h.beskrivning} = ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
if (h.typ === "matarstallning" && !h.undantag) lagg(post, "mätarställning", "E2", `${h.lage === "ingaende" ? "In" : "Ut"}: ${h.varde}`);
if (h.typ === "arbetsorder_skannad") lagg(post, "dokument", "E5", `Arbetsorder, ${h.falt.length} fält`);
if (h.typ === "observation") lagg(post, "observation", "E1", h.text);
if (h.typ === "kontroll_utford" && !h.undantag) lagg(post, "kontroll", "E1", h.text);
}
return poster;
}
// Ärendets samlade evidensnivå: starkaste kombination loggen innehåller.
export function evidensNiva(arende: Arende): EvidensNiva {
const poster = evidensposter(arende);
const har = (n: string) => poster.some((p) => p.niva === n);
const kallor = [har("E2"), har("E3"), har("E4"), har("E5")].filter(Boolean).length;
if (kallor >= 2) return "E6";
if (har("E5")) return "E5";
if (har("E4")) return "E4";
if (har("E3")) return "E3";
if (har("E2")) return "E2";
if (har("E1")) return "E1";
return "E0";
}
// ---- 2. Rule Engine ---------------------------------------------------
// Automatiska regler (kodade i orkesterns grundprompt och i metodikens
// krav-fält): kan det fotograferas → begär foto; låter det → video med
// ljud; rör det sig → video; mäts det → mätvärde; visar en display
// informationen → fota displayen; finns ett dokument → fota dokumentet.
// Godkända orsaker när underlag inte kan tas fram (fri text tillåts
// också — men en orsak är alltid obligatorisk).
export const UNDANTAGSORSAKER = [
"Komponenten är oåtkomlig",
"Fordonet kan inte lyftas säkert",
"Kunden avböjde demontering",
"Dålig sikt/åtkomst",
"Utrustning saknas",
];
// Markörtexter för pre-diagnostikens kvitteringar (loggas som kommentar).
export const MARKOR_FELBESKRIVNING_VERIFIERAD = "Kundens felbeskrivning verifierad vid mottagandet";
export const MARKOR_INGA_TIDIGA_OBSERVATIONER = "Inga ytterligare observationer vid mottagandet";
export const MARKOR_TIDIGA_OBSERVATIONER_KLARA = "Tidiga observationer vid mottagandet dokumenterade";
// -- Felorsaksanalys (Root Cause Analysis) --
// Ett ärende avslutas aldrig med enbart "komponent trasig, byt komponent":
// varje konstaterat fel kräver avvikelse, bedömd grundorsak, underlag och
// säkerhetsnivå — eller en motivering till varför orsaken inte fastställts.
export const ORSAKSKATEGORIER = [
"Normalt slitage",
"Ålder",
"Körsträcka",
"Materialutmattning",
"Tillverkningsfel",
"Bristande underhåll",
"Felaktig tidigare reparation",
"Yttre påverkan",
"Korrosion",
"Överhettning",
"Förorening",
"Felaktig användning",
"Modifiering",
"Olycka eller skada",
"Okänd orsak",
];
export const UNDERLAGSKALLOR = [
"Foto",
"Video",
"Mätresultat",
"Diagnosutläsning",
"Tidigare historik",
"Servicehistorik",
"Teknisk dokumentation",
"Direkt observation",
];
// Kvalitetsregeln: generella formuleringar utan förklaring avvisas.
const GENERISKA_FRASER = /^(trasig|defekt|sliten|utsliten|kass|död|behöver bytas|byt(es)?( ut)?|fungerar inte|går sönder|sönder)[.!]?$/i;
export function granskaAvvikelse(text: string): string | null {
const ren = text.trim();
if (ren.length < 20 || GENERISKA_FRASER.test(ren)) {
return "Felorsak saknas. Beskriv den konstaterade avvikelsen — inte bara komponenten. Exempel: ”Startmotorn aktiverar inte trots korrekt matningsspänning och god jordförbindelse.”";
}
return null;
}
// Underlaget måste finnas: en vald evidenskälla godtas bara om loggen
// faktiskt innehåller den sortens evidens.
export function underlagFinns(arende: Arende, kalla: string): boolean {
const h = arende.handelser.map((p) => p.handelse);
switch (kalla) {
case "Foto":
return h.some((x) => x.typ === "foto");
case "Video":
return h.some((x) => x.typ === "video");
case "Mätresultat":
return h.some((x) => x.typ === "matvarde");
case "Diagnosutläsning":
return h.some((x) => x.typ === "foto" && /instrument|diagnos/i.test(x.beskrivning));
case "Tidigare historik":
case "Servicehistorik":
return h.some((x) => x.typ === "historik_kontrollerad" && x.kontrollerad);
case "Direkt observation":
return h.some((x) => x.typ === "observation" || x.typ === "kontroll_utford");
default:
// Teknisk dokumentation: kan ännu inte verifieras maskinellt.
return true;
}
}
export function felorsaker(arende: Arende) {
return arende.handelser.filter((p) => p.handelse.typ === "felorsak");
}
// -- Åtgärdsfasen (Repair & Verification) --
// Loopen som symptomverifieringen öppnade sluts här: åtgärden
// dokumenteras (eller motiveras varför den uteblev) och kvalitets-
// kontrollen visar om symptomet faktiskt är borta.
export const INGEN_ATGARD_ORSAKER = [
"Kunden avböjde åtgärd",
"Väntar på reservdel",
"Åtgärd utförs av annan verkstad",
"Endast utredning beställd",
"Kostnadsförslag lämnat, inväntar besked",
];
// Kanaler för kundens besked — beskedet ska alltid gå att härleda till
// ett faktiskt samtal, besök eller meddelande.
export const KUNDKANALER = ["Telefon", "På plats", "E-post", "SMS", "Delningslänk"];
export function atgardsforslag(arende: Arende) {
return arende.handelser.filter((p) => p.handelse.typ === "atgardsforslag");
}
export function kundbeslut(arende: Arende) {
let senaste: { beslut: "godkant" | "avbojt" | "delvis"; kanal: string; kommentar?: string } | undefined;
for (const post of arende.handelser) {
if (post.handelse.typ === "kundbeslut") senaste = post.handelse;
}
return senaste;
}
export function atgarder(arende: Arende) {
return arende.handelser.filter((p) => p.handelse.typ === "atgard_utford");
}
export function kvalitetskontroll(arende: Arende) {
let senaste: { resultat: "symptomet_borta" | "kvarstar" | "delvis" | "ej_verifierbar"; beskrivning: string } | undefined;
for (const post of arende.handelser) {
if (post.handelse.typ === "kvalitetskontroll") senaste = post.handelse;
}
return senaste;
}
// -- Symptom Verification Protocol (SVP) --
// Kundens beskrivning → förtydligande (metodikens symptomfrågor) →
// reproducering (eller dokumenterat ej reproducerbar). Rapporten skiljer
// alltid kundens upplevelse från teknikerns verifierade observationer.
export function reproducering(arende: Arende) {
let senaste: { status: "ja" | "delvis" | "nej"; beskrivning: string } | undefined;
for (const post of arende.handelser) {
if (post.handelse.typ === "reproducering") senaste = post.handelse;
}
return senaste;
}
// Rapportformuleringen styrs av verifieringsläget — aldrig "felet
// konstaterat" utan reproducering eller annan dokumenterad verifiering.
export function reproduceringsText(status: "ja" | "delvis" | "nej"): string {
if (status === "ja") return "Symptomet reproducerades vid undersökningen.";
if (status === "delvis") return "Symptomet kunde delvis reproduceras vid undersökningen.";
return "Kundens beskrivning kunde inte reproduceras under de förhållanden som rådde vid undersökningen.";
}
// ---- 3. Compliance Engine ---------------------------------------------
// Ärendetypen styr vilka dokumentationskrav som gäller utöver metodiken.
export const ARENDETYPER = [
"Privat kund",
"Företagskund",
"Garanti",
"Goodwill",
"Försäkring",
"Reklamation",
"Begagnatgaranti",
"Intern kvalitetskontroll",
"Teknisk utredning",
] as const;
export type Arendetyp = (typeof ARENDETYPER)[number];
export function arendetyp(arende: Arende): Arendetyp {
let typ: Arendetyp = "Privat kund";
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "arendetyp_satt" && (ARENDETYPER as readonly string[]).includes(h.arendetyp)) {
typ = h.arendetyp as Arendetyp;
}
}
return typ;
}
// ---- ECM Knowledge Library --------------------------------------------
// Regelpaketet är data, inte kod: reglerna är deklarativa (krav-typ i
// stället för funktioner) och kan därför distribueras från servern
// (GET /api/ecm/regler, filen services/plattform/ecm-regler.json — i
// klustret utbytbar via ConfigMap) och uppdateras utan appändring.
// Klienten cachar senast hämtade paket och faller tillbaka till det
// inbyggda standardpaketet offline/i lokalt läge.
export type ComplianceKravTyp = "miltal" | "historik" | "claim" | "skadenummer" | "foto";
export interface ComplianceRegel {
id: string;
rubrik: string;
krav: ComplianceKravTyp;
detaljVidBrist: string;
}
export interface RegelPaket {
version: string;
arendetypRegler: Partial<Record<string, ComplianceRegel[]>>;
undantagsorsaker: string[];
orsakskategorier: string[];
underlagskallor: string[];
}
function objektFalt(arende: Arende, falt: "miltal" | "claim" | "skadenummer" | "arbetsorder"): string | undefined {
for (const post of arende.handelser) {
if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt[falt];
}
return undefined;
}
// Krav-typ → verifiering mot händelseloggen. Nya krav-typer läggs till
// här; vilka regler som använder dem styrs helt av regelpaketet.
const KRAV_KONTROLLER: Record<ComplianceKravTyp, (arende: Arende) => boolean> = {
miltal: (a) =>
a.handelser.some((p) => p.handelse.typ === "matarstallning" && !p.handelse.undantag) ||
!!objektFalt(a, "miltal"),
historik: (a) => a.handelser.some((p) => p.handelse.typ === "historik_kontrollerad" && p.handelse.kontrollerad),
claim: (a) => !!objektFalt(a, "claim"),
skadenummer: (a) => !!objektFalt(a, "skadenummer"),
foto: (a) => a.handelser.some((p) => p.handelse.typ === "foto"),
};
// Inbyggt standardpaket = fallback när servern inte nåtts ännu.
export const STANDARD_REGELPAKET: RegelPaket = {
version: ECM_VERSION,
arendetypRegler: {
Garanti: [
{ id: "garanti_miltal", rubrik: "Miltal dokumenterat", krav: "miltal", detaljVidBrist: "Garantiärenden kräver dokumenterad mätarställning." },
{ id: "garanti_historik", rubrik: "Servicehistorik kontrollerad", krav: "historik", detaljVidBrist: "Garantiärenden kräver kontrollerad servicehistorik." },
{ id: "garanti_claim", rubrik: "Claim-/garantinummer registrerat", krav: "claim", detaljVidBrist: "Ange claim-/garantinummer (läses ur arbetsordern)." },
],
Goodwill: [
{ id: "goodwill_miltal", rubrik: "Miltal dokumenterat", krav: "miltal", detaljVidBrist: "Goodwillärenden kräver dokumenterad mätarställning." },
{ id: "goodwill_historik", rubrik: "Servicehistorik kontrollerad", krav: "historik", detaljVidBrist: "Goodwillärenden kräver kontrollerad servicehistorik." },
],
Försäkring: [
{ id: "forsakring_skadenummer", rubrik: "Skadenummer registrerat", krav: "skadenummer", detaljVidBrist: "Försäkringsärenden kräver skadenummer (läses ur arbetsordern)." },
{ id: "forsakring_bildbevis", rubrik: "Bildbevis finns", krav: "foto", detaljVidBrist: "Försäkringsärenden kräver bilddokumentation." },
],
Reklamation: [
{ id: "reklamation_historik", rubrik: "Historik och tidigare försök kontrollerade", krav: "historik", detaljVidBrist: "Reklamationer kräver kontrollerad historik (tidigare reparationer/försök)." },
],
Begagnatgaranti: [
{ id: "begagnat_miltal", rubrik: "Miltal dokumenterat", krav: "miltal", detaljVidBrist: "Begagnatgaranti kräver dokumenterad mätarställning." },
],
},
undantagsorsaker: UNDANTAGSORSAKER,
orsakskategorier: ORSAKSKATEGORIER,
underlagskallor: UNDERLAGSKALLOR,
};
const REGELPAKET_NYCKEL = "gf-ecm-regelpaket";
// Validerar och normaliserar ett paket från servern/cachen — trasiga
// eller ofullständiga paket faller tillbaka till standard per fält.
export function normaliseraRegelpaket(data: unknown): RegelPaket {
const d = (data ?? {}) as Partial<RegelPaket>;
const lista = (varden: unknown, standard: string[]) =>
Array.isArray(varden) && varden.length > 0 && varden.every((v) => typeof v === "string")
? (varden as string[])
: standard;
const regler: RegelPaket["arendetypRegler"] = {};
if (d.arendetypRegler && typeof d.arendetypRegler === "object") {
for (const [typ, rader] of Object.entries(d.arendetypRegler)) {
if (!Array.isArray(rader)) continue;
const giltiga = rader.filter(
(r): r is ComplianceRegel =>
!!r && typeof r.id === "string" && typeof r.rubrik === "string" &&
typeof r.detaljVidBrist === "string" && typeof r.krav === "string" && r.krav in KRAV_KONTROLLER,
);
if (giltiga.length > 0) regler[typ] = giltiga;
}
}
return {
version: typeof d.version === "string" && d.version ? d.version : STANDARD_REGELPAKET.version,
arendetypRegler: Object.keys(regler).length > 0 ? regler : STANDARD_REGELPAKET.arendetypRegler,
undantagsorsaker: lista(d.undantagsorsaker, STANDARD_REGELPAKET.undantagsorsaker),
orsakskategorier: lista(d.orsakskategorier, STANDARD_REGELPAKET.orsakskategorier),
underlagskallor: lista(d.underlagskallor, STANDARD_REGELPAKET.underlagskallor),
};
}
export function aktivtRegelpaket(): RegelPaket {
try {
const rad = localStorage.getItem(REGELPAKET_NYCKEL);
return rad ? normaliseraRegelpaket(JSON.parse(rad)) : STANDARD_REGELPAKET;
} catch {
return STANDARD_REGELPAKET;
}
}
// Hämtar aktuellt regelpaket från plattformen (inloggat läge) och cachar
// det. Anropas vid sidladdning — misslyckas hämtningen gäller cachen
// eller standardpaketet.
export async function hamtaRegelpaket(): Promise<RegelPaket> {
try {
const { plattformAktiv, plattformToken, plattformFetch } = await import("./plattform");
if (plattformAktiv() && plattformToken()) {
const res = await plattformFetch("/api/ecm/regler");
if (res.ok) {
const paket = normaliseraRegelpaket(await res.json());
localStorage.setItem(REGELPAKET_NYCKEL, JSON.stringify(paket));
return paket;
}
}
} catch {
// Nätverksfel: cache/standard gäller.
}
return aktivtRegelpaket();
}
// ---- 4. Validation Engine ---------------------------------------------
// Kärnprincipen — inga påståenden utan underlag — verkar i tre lager:
// (a) orkesterns grundprompt: aldrig "OK/kontrollerad/inga fel" utan
// evidens; skriv "Evidens saknas" och begär rätt underlag,
// (b) projektionerna: hypoteser kan aldrig bli konstaterade fel,
// (c) kvalitetsgrinden nedan: rapport/avslut blockeras tills varje
// obligatoriskt påstående har evidens eller dokumenterat undantag.
// ---- Pre-Diagnostic Validation ----------------------------------------
export interface PreDiagRad {
id: "historik" | "matarstallning_in" | "felbeskrivning" | "tidiga_observationer";
rubrik: string;
klar: boolean;
varning?: string;
}
// Ingen felsökning påbörjas förrän grundkontrollerna är genomförda —
// eller dokumenterat motiverade. Allt härleds ur loggen.
export function preDiagnostik(arende: Arende): PreDiagRad[] {
let historik: PreDiagRad = { id: "historik", rubrik: "Fordonshistorik kontrollerad", klar: false };
let matarstallning = false;
let felbeskrivningVerifierad = false;
let tidiga = false;
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "historik_kontrollerad") {
historik = {
id: "historik",
rubrik: "Fordonshistorik kontrollerad",
klar: true,
varning: h.kontrollerad ? undefined : `Ej kontrollerad — orsak: ${h.kommentar ?? "saknas"}`,
};
}
if (h.typ === "matarstallning" && h.lage === "ingaende") matarstallning = true;
if (h.typ === "kommentar" && h.text.startsWith(MARKOR_FELBESKRIVNING_VERIFIERAD)) felbeskrivningVerifierad = true;
if (
h.typ === "kommentar" &&
(h.text.startsWith(MARKOR_INGA_TIDIGA_OBSERVATIONER) || h.text.startsWith(MARKOR_TIDIGA_OBSERVATIONER_KLARA))
) {
tidiga = true;
}
}
return [
historik,
{ id: "matarstallning_in", rubrik: "Ingående mätarställning dokumenterad", klar: matarstallning },
{ id: "felbeskrivning", rubrik: "Kundens felbeskrivning verifierad", klar: felbeskrivningVerifierad },
{ id: "tidiga_observationer", rubrik: "Tidiga observationer hanterade", klar: tidiga },
];
}
export function preDiagnostikKlar(arende: Arende): boolean {
return preDiagnostik(arende).every((rad) => rad.klar);
}
// ---- 5. Completion Engine ---------------------------------------------
export interface GrindRad {
id: string;
rubrik: string;
ok: boolean;
// Obligatorisk: blockerar slutrapporten tills den är grön.
kravs: boolean;
detalj?: string;
}
// Kvalitetsgrind före slutrapport/avslut. Varje rad är en verifierbar
// regel mot händelseloggen; regel-id och ECM-version följer med i
// exporten (Traceability Engine).
export function kvalitetsgrind(arende: Arende, metodik: Metodik): GrindRad[] {
const rader: GrindRad[] = [];
const handelser = arende.handelser.map((p) => p.handelse);
const avslutat = handelser.some((h) => h.typ === "arende_avslutat");
const objektFinns = handelser.some((h) => h.typ === "objekt_identifierat");
rader.push({
id: "objekt",
rubrik: "Fordons-/objektidentifiering verifierad",
ok: objektFinns,
kravs: true,
detalj: objektFinns ? undefined : "Evidens saknas — identifiera objektet.",
});
const arbetsorder = handelser.some((h) => h.typ === "arbetsorder_skannad") || !!objektFalt(arende, "arbetsorder");
rader.push({
id: "arbetsorder",
rubrik: "Arbetsorder inläst",
ok: arbetsorder,
kravs: false,
detalj: arbetsorder ? undefined : "Ärendet startades utan skannad arbetsorder.",
});
// Pre-diagnostiken ingår i grinden: historik + ingående mätarställning
// är obligatoriska (dokumenterade eller motiverade).
const pre = preDiagnostik(arende);
const historik = pre.find((r) => r.id === "historik")!;
rader.push({
id: "historik",
rubrik: "Fordonshistorik kontrollerad eller motiverad",
ok: historik.klar,
kravs: true,
detalj: historik.klar ? historik.varning : "Kontrollera historiken eller dokumentera varför det inte gått.",
});
const matIn = pre.find((r) => r.id === "matarstallning_in")!;
rader.push({
id: "matarstallning_in",
rubrik: "Ingående mätarställning dokumenterad",
ok: matIn.klar,
kravs: true,
detalj: matIn.klar ? undefined : "Fotografera instrumentpanelen (eller dokumentera undantag).",
});
const felb = pre.find((r) => r.id === "felbeskrivning")!;
rader.push({
id: "felbeskrivning_verifierad",
rubrik: "Kundens felbeskrivning verifierad",
ok: felb.klar,
kravs: false,
detalj: felb.klar ? undefined : "Bekräfta att kundens beskrivning är korrekt återgiven.",
});
// SVP: symptomet reproducerat eller dokumenterat ej reproducerbart —
// obligatoriskt innan ärendet avslutas.
const repro = reproducering(arende);
rader.push({
id: "svp",
rubrik: "Symptomverifiering: reproducerat eller dokumenterat ej reproducerbart",
ok: !!repro,
kravs: avslutat,
detalj: repro
? reproduceringsText(repro.status)
: "Dokumentera reproduceringen (Ja/Delvis/Nej med motivering) innan ärendet avslutas.",
});
// Felorsaksanalys: minst en dokumenterad felorsak (eller motiverad
// okänd orsak) krävs för att avsluta — aldrig bara komponent + åtgärd.
const orsaker = felorsaker(arende);
rader.push({
id: "felorsak",
rubrik: "Felorsaksanalys dokumenterad",
ok: orsaker.length > 0,
kravs: avslutat,
detalj:
orsaker.length > 0
? undefined
: "Beskriv varför felet uppstått — eller ange varför orsaken inte kunnat fastställas.",
});
// Åtgärdsfasen: reparationen dokumenterad — eller motiverat varför
// ingen åtgärd utfördes. Obligatoriskt innan ärendet avslutas.
const atgardsposter = atgarder(arende);
const utfordAtgard = atgardsposter.some((p) => p.handelse.typ === "atgard_utford" && p.handelse.utford);
rader.push({
id: "atgard",
rubrik: "Åtgärd dokumenterad eller motiverad",
ok: atgardsposter.length > 0,
kravs: avslutat,
detalj:
atgardsposter.length === 0
? "Dokumentera vad som gjordes — eller varför ingen åtgärd utfördes."
: utfordAtgard
? undefined
: "Ingen åtgärd utförd; orsaken är dokumenterad.",
});
// Kundgodkännande: har ett åtgärdsförslag lämnats ska kundens besked
// vara registrerat innan arbetet utförs — arbete utan besked flaggas.
const forslag = atgardsforslag(arende);
const beslut = kundbeslut(arende);
if (forslag.length > 0 || beslut) {
rader.push({
id: "kundgodkannande",
rubrik: "Kundens besked på åtgärdsförslaget registrerat",
ok: !!beslut,
kravs: utfordAtgard,
detalj: beslut
? `${beslut.beslut === "godkant" ? "Godkänt" : beslut.beslut === "avbojt" ? "Avböjt" : "Delvis godkänt"} via ${beslut.kanal}.`
: "Åtgärdsförslag lämnat — registrera kundens besked innan arbetet utförs.",
});
if (beslut?.beslut === "avbojt" && utfordAtgard) {
rader.push({
id: "godkannande_konflikt",
rubrik: "Utfört arbete trots avböjt åtgärdsförslag",
ok: false,
kravs: true,
detalj: "Kunden avböjde åtgärden men arbete är dokumenterat som utfört — kontrollera underlaget.",
});
}
}
// Kvalitetskontroll: är symptomet borta? Krävs när en åtgärd faktiskt
// utförts — annars finns inget att verifiera.
const kk = kvalitetskontroll(arende);
rader.push({
id: "kvalitetskontroll",
rubrik: "Kvalitetskontroll genomförd — symptomet verifierat",
ok: !!kk,
kravs: avslutat && utfordAtgard,
detalj: kk
? kk.resultat === "symptomet_borta"
? "Symptomet är verifierat borta efter åtgärd."
: kk.resultat === "ej_verifierbar"
? `Kunde inte verifieras: ${kk.beskrivning}`
: `Symptomet kvarstår helt eller delvis — ärendet bör inte avslutas som åtgärdat.`
: utfordAtgard
? "Verifiera att symptomet är borta efter åtgärden (provkörning/återtest)."
: "Ingen åtgärd utförd — inget att verifiera.",
});
// Utgående mätarställning: obligatorisk först när ärendet avslutas.
const matUt = handelser.some((h) => h.typ === "matarstallning" && h.lage === "utgaende");
rader.push({
id: "matarstallning_ut",
rubrik: "Utgående mätarställning dokumenterad",
ok: matUt,
kravs: avslutat,
detalj: matUt ? undefined : "Fotografera instrumentpanelen när arbetet är klart.",
});
// Metodikens kontroller: evidens eller dokumenterat undantag per kontroll.
const hanterade = new Map<string, { undantag?: string }>();
for (const h of handelser) {
if (h.typ === "kontroll_utford") hanterade.set(`${h.stegId}/${h.kontrollId}`, { undantag: h.undantag });
}
const fotonFinns = handelser.filter((h) => h.typ === "foto").length;
const saknade: string[] = [];
const undantagna: string[] = [];
let fotoKravUtanFoto = 0;
for (const steg of metodik.steg) {
for (const kontroll of steg.kontroller ?? []) {
const status = hanterade.get(`${steg.id}/${kontroll.id}`);
if (!status) saknade.push(kontroll.text);
else if (status.undantag) undantagna.push(`${kontroll.text} (${status.undantag})`);
else if (kontroll.krav === "foto" && fotonFinns === 0) fotoKravUtanFoto += 1;
}
}
rader.push({
id: "kontroller",
rubrik: "Metodikens kontroller: evidens eller dokumenterat undantag",
ok: saknade.length === 0,
kravs: true,
detalj:
saknade.length > 0
? `Evidens saknas: ${saknade.join("; ")}`
: undantagna.length > 0
? `Undantag med orsak: ${undantagna.join("; ")}`
: undefined,
});
rader.push({
id: "fotokrav",
rubrik: "Foton finns för fotokrävande kontroller",
ok: fotoKravUtanFoto === 0,
kravs: true,
detalj: fotoKravUtanFoto > 0 ? `${fotoKravUtanFoto} fotokrävande kontroll(er) utan bild i loggen.` : undefined,
});
// Compliance Engine: ärendetypens regler ur det aktiva regelpaketet
// (serverdistribuerat via ECM Knowledge Library, standard som fallback).
const paket = aktivtRegelpaket();
const typ = arendetyp(arende);
for (const regel of paket.arendetypRegler[typ] ?? []) {
const ok = KRAV_KONTROLLER[regel.krav](arende);
rader.push({
id: regel.id,
rubrik: `${typ}: ${regel.rubrik}`,
ok,
kravs: true,
detalj: ok ? undefined : regel.detaljVidBrist,
});
}
const avslutEvent = handelser.find((h) => h.typ === "arende_avslutat");
rader.push({
id: "signering",
rubrik: "Teknikerns slutsats signerad",
ok: !avslutat || (avslutEvent?.typ === "arende_avslutat" && !!avslutEvent.signatur),
kravs: false,
detalj:
avslutat && avslutEvent?.typ === "arende_avslutat" && avslutEvent.signatur
? `Signerad av ${avslutEvent.signatur}.`
: avslutat
? "Avslutet saknar signatur (äldre ärende)."
: "Signeras automatiskt när ärendet avslutas.",
});
const hypoteser = handelser.filter((h) => h.typ === "hypotes").length;
rader.push({
id: "hypoteser",
rubrik: "Hypoteser redovisas som ej verifierade",
ok: true,
kravs: false,
detalj: hypoteser > 0 ? `${hypoteser} hypotes(er) markeras som ej verifierade i rapporten — aldrig som konstaterade fel.` : undefined,
});
rader.push({
id: "evidensniva",
rubrik: `Evidensnivå: ${EVIDENS_LABEL[evidensNiva(arende)]}`,
ok: evidensNiva(arende) !== "E0",
kravs: true,
detalj: evidensNiva(arende) === "E0" ? "Ingen evidens i loggen ännu." : undefined,
});
return rader;
}
// Sant när alla obligatoriska grindrader är gröna — först då kan
// slutrapporten genereras.
export function grindGodkand(arende: Arende, metodik: Metodik): boolean {
return kvalitetsgrind(arende, metodik).every((rad) => rad.ok || !rad.kravs);
}
// ---- 6. Traceability Engine -------------------------------------------
// Spårbarhetspaketet som följer med varje export: regelverkets version,
// ärendets evidensnivå och samtliga evidensposter med innehållshash.
// Tillsammans med den append-only-loggen kan varje slutsats härledas:
// vilken bild, vilken mätning, vilken tekniker, vilken regel, när.
export function sparbarhetspaket(arende: Arende, metodik: Metodik) {
return {
ecmVersion: ECM_VERSION,
regelpaketVersion: aktivtRegelpaket().version,
arendetyp: arendetyp(arende),
evidensniva: evidensNiva(arende),
kvalitetsgrind: kvalitetsgrind(arende, metodik).map(({ id, rubrik, ok, kravs }) => ({ id, rubrik, ok, kravs })),
evidensposter: evidensposter(arende),
};
}
+48
View File
@@ -0,0 +1,48 @@
// Formatering och bildhantering — utan React-beroenden.
export function tidKlockslag(iso: string): string {
return new Date(iso).toLocaleTimeString("sv-SE", { hour: "2-digit", minute: "2-digit" });
}
export function tidDatum(iso: string): string {
return new Date(iso).toLocaleDateString("sv-SE");
}
// Skalar ner ett foto innan det loggas, så att händelseloggen förblir hanterbar.
export function skalaNerFoto(fil: File, maxSida = 1024): Promise<string> {
return new Promise((resolve, reject) => {
const lasare = new FileReader();
lasare.onerror = () => reject(new Error("Kunde inte läsa filen"));
lasare.onload = () => {
const bild = new Image();
bild.onerror = () => reject(new Error("Kunde inte tolka bilden"));
bild.onload = () => {
const skala = Math.min(1, maxSida / Math.max(bild.width, bild.height));
const canvas = document.createElement("canvas");
canvas.width = Math.round(bild.width * skala);
canvas.height = Math.round(bild.height * skala);
canvas.getContext("2d")!.drawImage(bild, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL("image/jpeg", 0.7));
};
bild.src = lasare.result as string;
};
lasare.readAsDataURL(fil);
});
}
// Video läses rå (ingen transkodning i webbläsaren) — därför hård
// storleksgräns: kort och komprimerad klipp, ca 1015 sekunder.
export const MAX_VIDEO_BYTE = 2_500_000;
export function lasVideo(fil: File): Promise<string> {
return new Promise((los, avvisa) => {
if (fil.size > MAX_VIDEO_BYTE) {
avvisa(new Error("Videon är för stor — spela in ett kort klipp (ca 1015 sekunder)."));
return;
}
const lasare = new FileReader();
lasare.onload = () => los(lasare.result as string);
lasare.onerror = () => avvisa(new Error("Videon kunde inte läsas."));
lasare.readAsDataURL(fil);
});
}
+132
View File
@@ -0,0 +1,132 @@
// Egen ikongrafik — enkla industriella linjeikoner (stroke: currentColor)
// i stället för emojis. Håller hela gränssnittet i samma dämpade,
// professionella ton oavsett plattform och typsnitt.
import type { ReactNode } from "react";
function Ikon({ children, storlek = 14 }: { children: ReactNode; storlek?: number }) {
return (
<svg
width={storlek}
height={storlek}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="inline-block align-[-2px]"
aria-hidden="true"
>
{children}
</svg>
);
}
export function IkonKamera({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z" />
<circle cx="12" cy="13" r="3.5" />
</Ikon>
);
}
export function IkonSok({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<circle cx="10.5" cy="10.5" r="6.5" />
<path d="M15.5 15.5 21 21" />
</Ikon>
);
}
export function IkonLank({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M9.5 14.5 14.5 9.5" />
<path d="M8 16l-2.5 2.5a3.5 3.5 0 0 1-5-5v0L3 11" transform="translate(3 -1)" />
<path d="M13 5l2.5-2.5a3.5 3.5 0 0 1 5 5L18 10" transform="translate(0 2)" />
</Ikon>
);
}
export function IkonDiagram({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M3 3v18h18" />
<path d="M7 15v3M12 10v8M17 6v12" />
</Ikon>
);
}
export function IkonKugghjul({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1" />
</Ikon>
);
}
export function IkonCheck({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M4 12.5 9.5 18 20 6" />
</Ikon>
);
}
export function IkonKryss({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M6 6l12 12M18 6 6 18" />
</Ikon>
);
}
export function IkonVarning({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M12 3 2.5 20h19L12 3z" />
<path d="M12 9.5V14M12 16.8v.2" />
</Ikon>
);
}
export function IkonMik({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3" />
</Ikon>
);
}
export function IkonUppdatera({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<path d="M20 5v5h-5" />
<path d="M4 19v-5h5" />
<path d="M19.5 10a8 8 0 0 0-14-3.5M4.5 14a8 8 0 0 0 14 3.5" />
</Ikon>
);
}
export function IkonKlocka({ storlek }: { storlek?: number }) {
return (
<Ikon storlek={storlek}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3.5 2" />
</Ikon>
);
}
// Statuspunkt (ersätter 🟢🟡🔴): fylld cirkel i angiven färg.
export function IkonPunkt({ farg, storlek = 10 }: { farg: string; storlek?: number }) {
return (
<svg width={storlek} height={storlek} viewBox="0 0 10 10" className="inline-block" aria-hidden="true">
<circle cx="5" cy="5" r="4.5" fill={farg} />
</svg>
);
}
@@ -0,0 +1,86 @@
// Organisationsinställningar: systemadministratören väljer vilka
// objekttyper och identifieringsmetoder som visas när ett ärende startas.
// På plattformen gäller valet hela organisationen (sparas på servern);
// i lokalt läge sparas valet på den här enheten. En cache i localStorage
// gör att vyerna kan rendera direkt och uppdateras när servern svarat.
import { plattformAktiv, plattformFetch, plattformToken } from "./plattform";
export const ALLA_OBJEKTTYPER = [
"Personbil",
"Lastbil/Buss",
"Entreprenadmaskin",
"Industrimaskin",
"Elsystem",
"Hydraulik",
"Övrigt",
];
export const ALLA_IDENTIFIERINGSMETODER = [
"Regnr",
"VIN",
"Serienummer",
"Maskinnummer",
"Manuell inmatning",
];
export interface Installningar {
objekttyper: string[];
identifieringsmetoder: string[];
}
const NYCKEL = "gf-installningar";
// Okända värden filtreras bort och en tom lista faller tillbaka till
// hela standardlistan — det går aldrig att låsa ute alla alternativ.
export function normalisera(data: unknown): Installningar {
const rad = (varden: unknown, alla: string[]): string[] => {
const valda = Array.isArray(varden) ? alla.filter((v) => varden.includes(v)) : [];
return valda.length > 0 ? valda : [...alla];
};
const d = (data ?? {}) as Partial<Installningar>;
return {
objekttyper: rad(d.objekttyper, ALLA_OBJEKTTYPER),
identifieringsmetoder: rad(d.identifieringsmetoder, ALLA_IDENTIFIERINGSMETODER),
};
}
export function lastaInstallningar(): Installningar {
try {
return normalisera(JSON.parse(localStorage.getItem(NYCKEL) ?? "{}"));
} catch {
return normalisera({});
}
}
export async function hamtaInstallningar(): Promise<Installningar> {
if (plattformAktiv() && plattformToken()) {
try {
const res = await plattformFetch("/api/organisation");
if (res.ok) {
const { installningar } = (await res.json()) as { installningar: unknown };
const inst = normalisera(installningar);
localStorage.setItem(NYCKEL, JSON.stringify(inst));
return inst;
}
} catch {
// Nätverksfel: cachen/enhetens val gäller tills servern nås igen.
}
}
return lastaInstallningar();
}
export async function sparaInstallningar(inst: Installningar): Promise<void> {
const ren = normalisera(inst);
localStorage.setItem(NYCKEL, JSON.stringify(ren));
if (plattformAktiv() && plattformToken()) {
const res = await plattformFetch("/api/organisation/installningar", {
method: "POST",
body: JSON.stringify(ren),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Fel ${res.status}`);
}
}
}
+267
View File
@@ -0,0 +1,267 @@
// Metodikmotor: deterministiska felsökningsflöden.
// Systemet presenterar aldrig en hypotes som ett konstaterat fel —
// motorn föreslår nästa verifierbara steg utifrån vad som redan är dokumenterat.
//
// I MVP:t är metodikerna fördefinierade flöden. Den framtida AI-integrationen
// ansluter här: den väljer/genererar steg, men händelseloggen och
// projektionerna är oförändrade.
import type { Arende } from "./domain";
export interface Fraga {
id: string;
text: string;
svarstyp: "janej" | "text" | "val";
val?: string[];
}
// Minimikrav enligt modulen Verifierade checklistor: en kontrollpunkt är
// inte slutförd enbart genom en kryssruta — den samlar bevis och kontext.
export type KontrollKrav = "matvarde" | "kommentar" | "foto";
export interface Kontroll {
id: string;
text: string;
krav?: KontrollKrav;
}
export interface MetodikSteg {
id: string;
rubrik: string;
beskrivning?: string;
fragor?: Fraga[];
kontroller?: Kontroll[];
}
export interface Metodik {
id: string;
namn: string;
steg: MetodikSteg[];
}
export const VIBRATION_METODIK: Metodik = {
id: "vibration",
namn: "Vibration under körning",
steg: [
{
id: "symptom",
rubrik: "Verifiera symptom",
fragor: [
{ id: "hastighetsberoende", text: "Är vibrationen hastighetsberoende?", svarstyp: "janej" },
{
id: "var",
text: "Var känns vibrationen?",
svarstyp: "val",
val: ["I ratten", "I stolen", "I hela bilen"],
},
{
id: "nar",
text: "När uppstår vibrationen?",
svarstyp: "val",
val: ["Vid acceleration", "Vid jämn fart", "Vid inbromsning", "Alltid under körning"],
},
{
id: "intervall",
text: "Försvinner den över eller under ett visst hastighetsintervall?",
svarstyp: "text",
},
],
},
{
id: "visuell",
rubrik: "Visuell kontroll",
beskrivning: "Fotografera samtliga hjul. Skilj på observation och slutsats.",
kontroller: [
{ id: "foto_vf", text: "Fotografera vänster framhjul", krav: "foto" },
{ id: "foto_hf", text: "Fotografera höger framhjul", krav: "foto" },
{ id: "foto_vb", text: "Fotografera vänster bakhjul", krav: "foto" },
{ id: "foto_hb", text: "Fotografera höger bakhjul", krav: "foto" },
{ id: "dot", text: "Dokumentera DOT-/tillverkningsdatum", krav: "kommentar" },
{ id: "slitage", text: "Kontrollera däckslitage", krav: "kommentar" },
],
},
{
id: "kontroller",
rubrik: "Rekommenderade kontroller",
kontroller: [
{ id: "lufttryck", text: "Kontrollera lufttryck", krav: "matvarde" },
{ id: "hjulmoment", text: "Kontrollera hjulmoment", krav: "matvarde" },
{ id: "kast", text: "Kontrollera radial- och sidokast", krav: "matvarde" },
{ id: "balansering", text: "Kontrollera hjulbalansering", krav: "kommentar" },
{ id: "bussningar", text: "Kontrollera bussningar och leder", krav: "kommentar" },
],
},
{
id: "provkorning",
rubrik: "Provkörning",
beskrivning: "Verifiera under provkörning:",
kontroller: [
{ id: "pk_hastighet", text: "Hastighet där vibration uppstår", krav: "kommentar" },
{ id: "pk_acc", text: "Förändring vid acceleration", krav: "kommentar" },
{ id: "pk_motorbroms", text: "Förändring vid motorbroms", krav: "kommentar" },
{ id: "pk_kurva", text: "Förändring vid kurvtagning", krav: "kommentar" },
{ id: "pk_var", text: "Om vibration känns i ratt eller kaross", krav: "kommentar" },
],
},
],
};
export const GENERISK_METODIK: Metodik = {
id: "generisk",
namn: "Generell strukturerad felsökning",
steg: [
{
id: "symptom",
rubrik: "Verifiera symptom",
fragor: [
{ id: "reproducerbart", text: "Går felet att reproducera?", svarstyp: "janej" },
{
id: "nar",
text: "När uppträder felet?",
svarstyp: "val",
val: ["Alltid", "Ibland", "Endast under drift", "Endast vid start"],
},
{
id: "var",
text: "Var upplevs felet?",
svarstyp: "val",
val: ["Fram", "Bak", "Höger", "Vänster", "Motor/drivkälla", "Kupé/förarplats", "Okänt"],
},
{
id: "hur",
text: "Hur upplevs felet?",
svarstyp: "val",
val: ["Skakar/vibrerar", "Missljud (knack/gnissel/vinande)", "Rycker", "Tappar kraft", "Stannar helt", "Annat"],
},
{ id: "forlopp", text: "Beskriv förloppet när felet uppstår", svarstyp: "text" },
{ id: "forandring", text: "Har något förändrats nyligen (service, reparation, miljö)?", svarstyp: "text" },
],
},
{
id: "visuell",
rubrik: "Visuell kontroll",
kontroller: [
{ id: "foto_objekt", text: "Fotografera objektet och det berörda området", krav: "foto" },
{ id: "skador", text: "Kontrollera synliga skador, läckage eller lösa anslutningar", krav: "kommentar" },
{ id: "typskylt", text: "Dokumentera typskylt/märkning", krav: "foto" },
],
},
{
id: "grundkontroller",
rubrik: "Grundkontroller",
kontroller: [
{ id: "matning", text: "Utför grundläggande mätningar (spänning, tryck, temperatur — det som är relevant)", krav: "matvarde" },
{ id: "sakringar", text: "Kontrollera säkringar/skydd", krav: "kommentar" },
{ id: "anslutningar", text: "Kontrollera kontaktdon och anslutningar", krav: "kommentar" },
],
},
{
id: "funktionstest",
rubrik: "Funktionstest",
kontroller: [
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet", krav: "kommentar" },
],
},
],
};
// Elsystem-metodik enligt visionens relä-exempel: mätningar med krav på
// värden, reläkontroll och funktionstest.
export const ELSYSTEM_METODIK: Metodik = {
id: "elsystem",
namn: "Elsystem / strömförsörjning",
steg: [
{
id: "symptom",
rubrik: "Verifiera symptom",
fragor: [
{ id: "reproducerbart", text: "Går felet att reproducera?", svarstyp: "janej" },
{
id: "omfattning",
text: "Är funktionen helt död eller delvis fungerande?",
svarstyp: "val",
val: ["Helt död", "Delvis fungerande", "Intermittent"],
},
{ id: "funktion", text: "Vilken funktion saknas eller felar?", svarstyp: "text" },
{ id: "forandring", text: "Har något åtgärdats eller förändrats nyligen?", svarstyp: "text" },
],
},
{
id: "visuell",
rubrik: "Visuell kontroll",
kontroller: [
{ id: "foto_komponent", text: "Fotografera komponenten och dess anslutningar", krav: "foto" },
{ id: "skador", text: "Kontrollera synliga skador, korrosion och lösa kontaktdon", krav: "kommentar" },
],
},
{
id: "matningar",
rubrik: "Mätningar",
kontroller: [
{ id: "batterispanning", text: "Mät batterispänning", krav: "matvarde" },
{ id: "sakringar", text: "Kontrollera berörda säkringar", krav: "kommentar" },
{ id: "matningsspanning", text: "Mät matningsspänning vid komponenten", krav: "matvarde" },
{ id: "jord", text: "Mät spänningsfall mot jord", krav: "matvarde" },
],
},
{
id: "rela",
rubrik: "Relä och styrsignal",
kontroller: [
{ id: "rela_klick", text: "Kontrollera om reläet klickar vid aktivering", krav: "kommentar" },
{ id: "styrsignal", text: "Mät styrsignal till reläet", krav: "matvarde" },
],
},
{
id: "funktionstest",
rubrik: "Funktionstest",
kontroller: [
{ id: "test", text: "Genomför funktionstest och dokumentera resultatet", krav: "kommentar" },
],
},
],
};
export const METODIKER = [VIBRATION_METODIK, ELSYSTEM_METODIK, GENERISK_METODIK];
export function metodikForId(id: string): Metodik {
return METODIKER.find((m) => m.id === id) ?? GENERISK_METODIK;
}
export function valjMetodik(felbeskrivning: string): Metodik {
const text = felbeskrivning.toLowerCase();
if (/vibr|skak|obalans/.test(text)) return VIBRATION_METODIK;
if (/relä|rela\b|säkring|elektr|spänning|volt\b|batteri|lampa|belysning|ström|kortslut/.test(text)) {
return ELSYSTEM_METODIK;
}
return GENERISK_METODIK;
}
export interface NastaSteg {
steg: MetodikSteg;
fraga?: Fraga;
kontroll?: Kontroll;
klart: boolean;
}
// Nästa steg = första obesvarade frågan, därefter första ej utförda kontrollen,
// i metodikens ordning. Helt härlett ur händelseloggen.
export function nastaSteg(arende: Arende, metodik: Metodik): NastaSteg {
const besvarade = new Set<string>();
const utforda = new Set<string>();
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "fraga_besvarad") besvarade.add(`${h.stegId}/${h.frageId}`);
if (h.typ === "kontroll_utford") utforda.add(`${h.stegId}/${h.kontrollId}`);
}
for (const steg of metodik.steg) {
for (const fraga of steg.fragor ?? []) {
if (!besvarade.has(`${steg.id}/${fraga.id}`)) return { steg, fraga, klart: false };
}
for (const kontroll of steg.kontroller ?? []) {
if (!utforda.has(`${steg.id}/${kontroll.id}`)) return { steg, kontroll, klart: false };
}
}
const sista = metodik.steg[metodik.steg.length - 1];
return { steg: sista, klart: true };
}
+282
View File
@@ -0,0 +1,282 @@
// Självhostat läge: klienten pratar med plattformstjänsten i klustret
// (services/plattform) i stället för Supabase. Aktiveras vid bygget med
// VITE_PLATTFORM_URL; utan den används Supabase-läget precis som förut.
export const PLATTFORM_URL = (import.meta.env.VITE_PLATTFORM_URL as string | undefined)?.replace(/\/$/, "");
const TOKEN_NYCKEL = "gf-plattform-token";
const KONTO_NYCKEL = "gf-plattform-konto";
export type PlattformRoll = "tekniker" | "arbetsledare" | "admin";
export interface PlattformKonto {
namn: string;
roll: PlattformRoll;
organisation: string;
}
export function plattformAktiv(): boolean {
return !!PLATTFORM_URL;
}
export function plattformToken(): string | null {
return localStorage.getItem(TOKEN_NYCKEL);
}
export function plattformKonto(): PlattformKonto | null {
const rad = localStorage.getItem(KONTO_NYCKEL);
if (!rad || !plattformToken()) return null;
try {
return JSON.parse(rad) as PlattformKonto;
} catch {
return null;
}
}
export function loggaUtPlattform(): void {
localStorage.removeItem(TOKEN_NYCKEL);
localStorage.removeItem(KONTO_NYCKEL);
}
async function authAnrop(vag: string, kropp: object): Promise<PlattformKonto> {
const res = await fetch(`${PLATTFORM_URL}${vag}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(kropp),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error ?? `Fel ${res.status}`);
const { token, namn, roll, organisation } = data as PlattformKonto & { token: string };
localStorage.setItem(TOKEN_NYCKEL, token);
const konto: PlattformKonto = { namn, roll, organisation };
localStorage.setItem(KONTO_NYCKEL, JSON.stringify(konto));
return konto;
}
export function loggaInPlattform(epost: string, losenord: string): Promise<PlattformKonto> {
return authAnrop("/api/auth/logga-in", { epost, losenord });
}
// Registrering skapar en ny organisation med användaren som
// systemadministratör; övriga användare skapas av admin.
export function registreraPlattform(
epost: string,
losenord: string,
namn: string,
organisation: string,
): Promise<PlattformKonto> {
return authAnrop("/api/auth/registrera", { epost, losenord, namn, organisation });
}
// Användarhantering (kräver admin-roll; servern verifierar).
export interface PlattformAnvandare {
id: string;
epost: string;
namn: string;
roll: PlattformRoll;
aktiv: boolean;
}
export async function hamtaAnvandare(): Promise<PlattformAnvandare[]> {
const res = await plattformFetch("/api/anvandare");
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { anvandare: PlattformAnvandare[] }).anvandare;
}
export async function skapaAnvandare(
epost: string,
losenord: string,
namn: string,
roll: PlattformRoll,
): Promise<void> {
const res = await plattformFetch("/api/anvandare", {
method: "POST",
body: JSON.stringify({ epost, losenord, namn, roll }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Fel ${res.status}`);
}
}
// Stänga av eller öppna ett konto. En avstängning återkallar samtidigt
// pågående sessioner — annars vore den verkningslös tills token gick ut.
export async function sattKontoAktiv(id: string, aktiv: boolean): Promise<void> {
const res = await plattformFetch(`/api/anvandare/${id}/${aktiv ? "aktivera" : "avaktivera"}`, {
method: "POST",
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Fel ${res.status}`);
}
}
// Logga ut på alla enheter — vägen ut när en telefon tappats bort.
export async function loggaUtAllaEnheter(): Promise<void> {
const res = await plattformFetch("/api/auth/logga-ut-alla", { method: "POST" });
if (!res.ok) throw new Error(`Fel ${res.status}`);
loggaUtPlattform();
}
// Live Share-delningar: återkallbara länkar med behörighetsnivå.
export type DelningsNiva = "kund" | "partner" | "intern";
export interface Delning {
kod: string;
niva: DelningsNiva;
skapad: string;
aterkallad: string | null;
}
export async function hamtaDelningar(arendeId: string): Promise<Delning[]> {
const res = await plattformFetch(`/api/arenden/${arendeId}/delningar`);
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { delningar: Delning[] }).delningar;
}
export async function skapaDelning(arendeId: string, niva: DelningsNiva): Promise<string> {
const res = await plattformFetch(`/api/arenden/${arendeId}/delningar`, {
method: "POST",
body: JSON.stringify({ niva }),
});
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { kod: string }).kod;
}
export async function aterkallaDelning(kod: string): Promise<void> {
const res = await plattformFetch(`/api/delningar/${kod}/aterkalla`, { method: "POST" });
if (!res.ok) throw new Error(`Fel ${res.status}`);
}
// Organisationsöversikt (arbetsledare/admin; servern verifierar).
export interface OversiktsRad {
id: string;
nummer: number;
skapad: string;
delningskod: string | null;
metodik_id: string | null;
antal_handelser: number;
forsta: string | null;
senaste: string | null;
avslutat: boolean;
objekt: string | null;
felbeskrivning: string | null;
ansvarig: string | null;
skapare: string | null;
tekniker: string[] | null;
}
export async function hamtaOversikt(): Promise<OversiktsRad[]> {
const res = await plattformFetch("/api/oversikt");
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { arenden: OversiktsRad[] }).arenden;
}
// Fordonshistorik: organisationens tidigare ärenden på samma objekt.
export interface FordonshistorikRad {
id: string;
nummer: number;
skapad: string;
avslutat: boolean;
felbeskrivning: string | null;
felorsaker: { avvikelse: string; orsaker: string[]; atgard: string }[];
}
export async function hamtaFordonshistorik(identifierare: string): Promise<FordonshistorikRad[]> {
const res = await plattformFetch(`/api/fordon/${encodeURIComponent(identifierare)}/historik`);
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { arenden: FordonshistorikRad[] }).arenden;
}
// Flottdata: felorsaksstatistik per orsakskategori (arbetsledare/admin).
export async function hamtaFelorsaksstatistik(): Promise<{ orsak: string; antal: number }[]> {
const res = await plattformFetch("/api/statistik/felorsaker");
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { orsaker: { orsak: string; antal: number }[] }).orsaker;
}
// Märkesspecifika kopplingar. Uppgifterna lagras krypterat på servern
// och returneras alltid maskerade — klienten ser aldrig hemligheterna.
export interface LeverantorsFalt {
nyckel: string;
etikett: string;
hemlig: boolean;
}
export interface Leverantor {
id: string;
namn: string;
beskrivning?: string;
nyckeltyp?: string;
falt: LeverantorsFalt[];
}
export interface Integration {
leverantor: string;
namn: string;
aktiv: boolean;
uppdaterad: string;
senast_testad: string | null;
senaste_status: string | null;
uppgifter: Record<string, string>;
}
export async function hamtaLeverantorer(): Promise<Leverantor[]> {
const res = await plattformFetch("/api/integrationer/leverantorer");
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { leverantorer: Leverantor[] }).leverantorer;
}
export async function hamtaIntegrationer(): Promise<{ integrationer: Integration[]; krypteringKonfigurerad: boolean }> {
const res = await plattformFetch("/api/integrationer");
if (!res.ok) throw new Error(`Fel ${res.status}`);
return (await res.json()) as { integrationer: Integration[]; krypteringKonfigurerad: boolean };
}
export async function sparaIntegration(leverantor: string, uppgifter: Record<string, string>): Promise<void> {
const res = await plattformFetch("/api/integrationer", {
method: "POST",
body: JSON.stringify({ leverantor, uppgifter }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Fel ${res.status}`);
}
}
export async function taBortIntegration(leverantor: string): Promise<void> {
const res = await plattformFetch(`/api/integrationer/${leverantor}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Fel ${res.status}`);
}
// Uppslag mot leverantören — anropet görs av servern med organisationens
// krypterade uppgifter; klienten skickar bara identifieraren.
export async function gorUppslag(
leverantor: string,
identifierare: string,
): Promise<Record<string, string>> {
const res = await plattformFetch(`/api/integrationer/${leverantor}/uppslag`, {
method: "POST",
body: JSON.stringify({ identifierare }),
});
const data = (await res.json().catch(() => ({}))) as { fordon?: Record<string, string>; error?: string };
if (!res.ok) throw new Error(data.error ?? `Fel ${res.status}`);
return data.fordon ?? {};
}
// Autentiserat anrop mot plattformen. En utgången token rensas (401) så
// att appen faller tillbaka till lokalt läge tills nästa inloggning.
export async function plattformFetch(vag: string, init?: RequestInit): Promise<Response> {
const token = plattformToken();
if (!token) throw new Error("Ej inloggad");
const res = await fetch(`${PLATTFORM_URL}${vag}`, {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers,
Authorization: `Bearer ${token}`,
},
});
if (res.status === 401) loggaUtPlattform();
return res;
}
@@ -0,0 +1,356 @@
// Projektioner: alla vyer (ärendebrief, tidrapport, kundrapport) är rena
// funktioner av händelseloggen. De kan alltid regenereras — loggen vinner
// om en vy och loggen skulle säga olika saker.
import type { Arende, Bilaga, LoggPost, Objekt, TidKategori, Tillforlitlighet } from "./domain";
import { TIDKATEGORI_LABEL } from "./domain";
import type { Metodik } from "./metodik";
import { nastaSteg } from "./metodik";
export function objekt(arende: Arende): Objekt | undefined {
for (const post of arende.handelser) {
if (post.handelse.typ === "objekt_identifierat") return post.handelse.objekt;
}
return undefined;
}
export function felbeskrivning(arende: Arende): string | undefined {
for (const post of arende.handelser) {
if (post.handelse.typ === "felbeskrivning") return post.handelse.text;
}
return undefined;
}
// Ansvarig tekniker härleds ur loggen: skaparen, tills en överlämning
// med mottagare eller en omfördelning (ansvarig_satt) pekar ut någon annan.
export function ansvarig(arende: Arende): string | undefined {
let namn = arende.handelser[0]?.anvandare;
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "ansvarig_satt") namn = h.ansvarig;
if (h.typ === "overlamning" && h.till) namn = h.till;
}
return namn;
}
// Ärendeidentiteten (Case Identity): registreras en gång och återanvänds
// i felsökningsvyn, Live Share, slutrapporten och exporten. Fordons-
// objektet är den röda tråden — det ska alltid vara omedelbart tydligt
// vilket fordon och vilket ärende informationen avser.
export interface Arendeidentitet {
nummer: number;
arbetsorder?: string;
claim?: string;
skadenummer?: string;
identifierare?: string;
vin?: string;
beskrivning?: string;
miltal?: string;
kund?: string;
ansvarig?: string;
avslutat: boolean;
}
export function arendeidentitet(arende: Arende): Arendeidentitet {
const o = objekt(arende);
let miltal = o?.miltal;
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "matarstallning" && h.lage === "ingaende" && !h.undantag) miltal = h.varde;
}
return {
nummer: arende.nummer,
arbetsorder: o?.arbetsorder,
claim: o?.claim,
skadenummer: o?.skadenummer,
identifierare: o?.identifierare,
vin: o?.vin,
beskrivning: o?.beskrivning,
miltal,
kund: o?.kund,
ansvarig: ansvarig(arende),
avslutat: arAvslutat(arende),
};
}
export function arAvslutat(arende: Arende): boolean {
return arende.handelser.some((p) => p.handelse.typ === "arende_avslutat");
}
// Fordonshistorik: tidigare ärenden på samma objekt (regnr/VIN) med deras
// dokumenterade felorsaker — grunden för pre-diagnostikens historiksteg
// och orsakskedjan. Lokal variant; i plattformsläge svarar servern på
// samma fråga över hela organisationen.
export interface HistorikArende {
id: string;
nummer: number;
skapad: string;
avslutat: boolean;
felbeskrivning?: string;
felorsaker: { avvikelse: string; orsaker: string[]; atgard: string }[];
}
export function lokalFordonshistorik(
arenden: Record<string, Arende>,
identifierare: string,
exkluderaId?: string,
): HistorikArende[] {
const ident = identifierare.trim().toUpperCase();
if (!ident) return [];
const resultat: HistorikArende[] = [];
for (const arende of Object.values(arenden)) {
if (arende.id === exkluderaId) continue;
const o = objekt(arende);
if (!o || o.identifierare.toUpperCase() !== ident) continue;
const felorsaker: HistorikArende["felorsaker"] = [];
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "felorsak") felorsaker.push({ avvikelse: h.avvikelse, orsaker: h.orsaker, atgard: h.atgard });
}
resultat.push({
id: arende.id,
nummer: arende.nummer,
skapad: arende.skapad,
avslutat: arAvslutat(arende),
felbeskrivning: felbeskrivning(arende),
felorsaker,
});
}
return resultat.sort((a, b) => b.skapad.localeCompare(a.skapad)).slice(0, 20);
}
export interface UtfordKontroll {
text: string;
resultat?: string;
// Satt när underlaget inte kunde tas fram (dokumenterad orsak).
undantag?: string;
tidpunkt: string;
anvandare: string;
}
export function utfordaKontroller(arende: Arende): UtfordKontroll[] {
const resultat: UtfordKontroll[] = [];
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "kontroll_utford") {
resultat.push({ text: h.text, resultat: h.resultat, undantag: h.undantag, tidpunkt: post.tidpunkt, anvandare: post.anvandare });
}
}
return resultat;
}
export function ejKontrollerat(arende: Arende, metodik: Metodik): string[] {
const utforda = new Set<string>();
for (const post of arende.handelser) {
const h = post.handelse;
if (h.typ === "kontroll_utford") utforda.add(`${h.stegId}/${h.kontrollId}`);
}
const kvar: string[] = [];
for (const steg of metodik.steg) {
for (const kontroll of steg.kontroller ?? []) {
if (!utforda.has(`${steg.id}/${kontroll.id}`)) kvar.push(kontroll.text);
}
}
return kvar;
}
export function observationer(arende: Arende): string[] {
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}` : ""}`);
}
return resultat;
}
export function hypoteser(arende: Arende): { text: string; niva: Tillforlitlighet }[] {
const resultat: { text: string; niva: Tillforlitlighet }[] = [];
for (const post of arende.handelser) {
if (post.handelse.typ === "hypotes") resultat.push({ text: post.handelse.text, niva: post.handelse.niva });
}
return resultat;
}
export function foton(arende: Arende): { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] {
const resultat: { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] = [];
for (const post of arende.handelser) {
if (post.handelse.typ === "foto") {
resultat.push({ beskrivning: post.handelse.beskrivning, bilaga: post.handelse, tidpunkt: post.tidpunkt });
}
}
return resultat;
}
export function videor(arende: Arende): { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] {
const resultat: { beskrivning: string; bilaga: Bilaga; tidpunkt: string }[] = [];
for (const post of arende.handelser) {
if (post.handelse.typ === "video") {
resultat.push({ beskrivning: post.handelse.beskrivning, bilaga: post.handelse, tidpunkt: post.tidpunkt });
}
}
return resultat;
}
// Tidsredovisning: varje intervall mellan två händelser tillhör den kategori
// som var aktiv när intervallet började. Kategori styrs av kategori_byte-
// händelser; standard är aktiv felsökning. Paus räknas inte in i total tid.
export interface Tidsfordelning {
totalMs: number;
perKategori: Partial<Record<TidKategori, number>>;
}
export function tidsfordelning(arende: Arende, nu?: string): Tidsfordelning {
const poster = arende.handelser;
const perKategori: Partial<Record<TidKategori, number>> = {};
if (poster.length === 0) return { totalMs: 0, perKategori };
let kategori: TidKategori = "aktiv_felsokning";
let forra = new Date(poster[0].tidpunkt).getTime();
let avslutad = false;
const laggTill = (till: number) => {
const ms = Math.max(0, till - forra);
perKategori[kategori] = (perKategori[kategori] ?? 0) + ms;
forra = till;
};
for (const post of poster) {
laggTill(new Date(post.tidpunkt).getTime());
const h = post.handelse;
if (h.typ === "kategori_byte") kategori = h.kategori;
if (h.typ === "arende_avslutat") {
avslutad = true;
break;
}
}
if (!avslutad && nu) laggTill(new Date(nu).getTime());
let totalMs = 0;
for (const [k, ms] of Object.entries(perKategori)) {
if (k !== "paus") totalMs += ms;
}
return { totalMs, perKategori };
}
export function formateraTid(ms: number): string {
const minuter = Math.round(ms / 60000);
const h = Math.floor(minuter / 60);
const m = minuter % 60;
if (h === 0) return `${m} min`;
return `${h} tim ${m} min`;
}
export interface TillforlitlighetsRad {
niva: Tillforlitlighet;
text: string;
}
export function tillforlitlighet(arende: Arende): TillforlitlighetsRad[] {
const rader: TillforlitlighetsRad[] = [];
if (objekt(arende)) rader.push({ niva: "hog", text: "Objekt identifierat" });
if (foton(arende).length > 0) rader.push({ niva: "hog", text: "Bilder dokumenterade" });
if (arende.handelser.some((p) => p.handelse.typ === "matvarde")) {
rader.push({ niva: "hog", text: "Mätvärden registrerade" });
}
if (utfordaKontroller(arende).length > 0) {
rader.push({ niva: "hog", text: "Kontroller dokumenterade" });
}
// Felorsaken är per definition inte verifierad förrän ärendet avslutats
// med en verifierad slutsats — MVP:t markerar den alltid som ej verifierad
// så länge ärendet är öppet.
if (!arAvslutat(arende)) {
rader.push({ niva: "medel", text: "Felorsak ännu inte verifierad" });
}
return rader;
}
export interface Brief {
objekt?: Objekt;
ansvarig?: string;
felbeskrivning?: string;
utfordaKontroller: UtfordKontroll[];
observationer: string[];
hypoteser: { text: string; niva: Tillforlitlighet }[];
ejKontrollerat: string[];
rekommenderatNastaSteg: string[];
totalArbetstid: string;
tillforlitlighet: TillforlitlighetsRad[];
avslutat: boolean;
}
// Ärendebriefen: hela den strukturerade sammanfattningen, regenererbar
// ur loggen vid varje anrop.
export function brief(arende: Arende, metodik: Metodik, nu?: string): Brief {
const kvar = ejKontrollerat(arende, metodik);
const nasta = nastaSteg(arende, metodik);
const rekommenderat: string[] = [];
if (!arAvslutat(arende)) {
if (nasta.fraga) rekommenderat.push(`Besvara: ${nasta.fraga.text}`);
else if (nasta.kontroll) rekommenderat.push(nasta.kontroll.text);
for (const text of kvar.slice(0, 3)) {
if (!rekommenderat.includes(text)) rekommenderat.push(text);
}
if (rekommenderat.length === 0) {
rekommenderat.push("Samtliga kontroller i metodiken utförda. Verifiera slutsats eller utöka felsökningen.");
}
}
return {
objekt: objekt(arende),
ansvarig: ansvarig(arende),
felbeskrivning: felbeskrivning(arende),
utfordaKontroller: utfordaKontroller(arende),
observationer: observationer(arende),
hypoteser: hypoteser(arende),
ejKontrollerat: kvar,
rekommenderatNastaSteg: rekommenderat.slice(0, 3),
totalArbetstid: formateraTid(tidsfordelning(arende, nu).totalMs),
tillforlitlighet: tillforlitlighet(arende),
avslutat: arAvslutat(arende),
};
}
// Överlämningsrapport som text — används vid "Lämna över arbete".
export function overlamningstext(arende: Arende, metodik: Metodik, nu?: string): string {
const b = brief(arende, metodik, nu);
const rader: string[] = [];
rader.push(`ÖVERLÄMNING Ärende #${arende.nummer}`);
if (b.objekt) rader.push(`Objekt: ${b.objekt.beskrivning} (${b.objekt.identifierare})${b.objekt.kund ? ` Kund: ${b.objekt.kund}` : ""}`);
if (b.felbeskrivning) rader.push(`Felbeskrivning: ${b.felbeskrivning}`);
rader.push("");
rader.push("Utförda kontroller:");
if (b.utfordaKontroller.length === 0) rader.push(" (inga ännu)");
for (const k of b.utfordaKontroller)
rader.push(k.undantag ? ` ! ${k.text} — underlag saknas: ${k.undantag}` : `${k.text}${k.resultat ? `${k.resultat}` : ""}`);
rader.push("");
rader.push("Observationer:");
if (b.observationer.length === 0) rader.push(" (inga ännu)");
for (const o of b.observationer) rader.push(`${o}`);
if (b.hypoteser.length > 0) {
rader.push("");
rader.push("Hypoteser (ej verifierade):");
for (const h of b.hypoteser) rader.push(` ? ${h.text}`);
}
rader.push("");
rader.push("Ej kontrollerat:");
if (b.ejKontrollerat.length === 0) rader.push(" (allt i metodiken utfört)");
for (const e of b.ejKontrollerat) rader.push(` ${e}`);
rader.push("");
rader.push("Rekommenderat nästa steg:");
b.rekommenderatNastaSteg.forEach((s, i) => rader.push(` ${i + 1}. ${s}`));
rader.push("");
rader.push(`Total arbetstid: ${b.totalArbetstid}`);
return rader.join("\n");
}
export function tidsfordelningsRader(arende: Arende, nu?: string): { label: string; tid: string }[] {
const { perKategori } = tidsfordelning(arende, nu);
return (Object.entries(perKategori) as [TidKategori, number][])
.filter(([, ms]) => ms >= 30000)
.sort((a, b) => b[1] - a[1])
.map(([k, ms]) => ({ label: TIDKATEGORI_LABEL[k], tid: formateraTid(ms) }));
}
export function sistaAktivitet(arende: Arende): LoggPost | undefined {
return arende.handelser[arende.handelser.length - 1];
}
+65
View File
@@ -0,0 +1,65 @@
// Tal-till-text enligt kommunikationsmodellen: rösten är ett inmatningssätt,
// inte ett separat gränssnitt. All logik bygger på text — transkriberingen
// hamnar i ett redigerbart fält och ingenting skickas utan bekräftelse.
//
// MVP:t använder webbläsarens SpeechRecognition (sv-SE). I produktions-
// versionen ersätts motorn av leverantörens Voice-to-Text-API bakom samma
// gränssnitt.
interface IgenkanningsResultat {
transkript: string;
slutgiltigt: boolean;
}
interface Igenkanning {
start(): void;
stop(): void;
abort(): void;
}
type SpeechRecognitionCtor = new () => {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: { resultIndex: number; results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }> }) => void) | null;
onend: (() => void) | null;
onerror: ((event: { error: string }) => void) | null;
start(): void;
stop(): void;
abort(): void;
};
function ctor(): SpeechRecognitionCtor | undefined {
if (typeof window === "undefined") return undefined;
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition;
}
export function stodRost(): boolean {
return ctor() !== undefined;
}
export function startaIgenkanning(
paResultat: (r: IgenkanningsResultat) => void,
paSlut: () => void,
): Igenkanning | undefined {
const Ctor = ctor();
if (!Ctor) return undefined;
const igenkanning = new Ctor();
igenkanning.lang = "sv-SE";
igenkanning.continuous = true;
igenkanning.interimResults = true;
igenkanning.onresult = (event) => {
for (let i = event.resultIndex; i < event.results.length; i++) {
const resultat = event.results[i];
paResultat({ transkript: resultat[0].transcript, slutgiltigt: resultat.isFinal });
}
};
igenkanning.onend = paSlut;
igenkanning.onerror = paSlut;
igenkanning.start();
return igenkanning;
}
+106
View File
@@ -0,0 +1,106 @@
// Tillstånd för Guidad Felsökning (MVP).
// Persistens: localStorage. I produktionsversionen ersätts detta av ett
// API-first-backend med samma händelsemodell — append-only-loggen gör
// synkronisering (inkl. offline-läge) konfliktfri.
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Arende, Handelse, LoggPost, Objekt } from "./domain";
import { nyLoggPost } from "./domain";
import { felbeskrivning } from "./projektioner";
import { GENERISK_METODIK, metodikForId, valjMetodik, type Metodik } from "./metodik";
interface FelsokningState {
anvandare: string;
arenden: Record<string, Arende>;
nastaNummer: number;
sattAnvandare: (namn: string) => void;
skapaArende: (objekt: Objekt, felbeskrivningText: string, metodikId?: string) => string;
laggTill: (arendeId: string, handelse: Handelse) => void;
// Används endast av synken: ersätter listan med den ihopflätade versionen.
// Semantiken är fortfarande append-only — flätningen lägger bara till.
sammanfoga: (arendeId: string, handelser: LoggPost[]) => void;
// Lägger in ett färdigbyggt ärende (demoärende, importerat ärende).
laggInArende: (arende: Arende) => void;
}
function nyDelningskod(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) =>
"abcdefghijklmnopqrstuvwxyz0123456789"[b % 36],
).join("");
}
export const useFelsokning = create<FelsokningState>()(
persist(
(set, get) => ({
anvandare: "",
arenden: {},
nastaNummer: 1,
sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
skapaArende: (objekt, felbeskrivningText, metodikId) => {
const { anvandare, nastaNummer } = get();
const id = `arende-${Date.now().toString(36)}`;
const arende: Arende = {
id,
nummer: nastaNummer,
skapad: new Date().toISOString(),
delningskod: nyDelningskod(),
metodikId,
handelser: [
nyLoggPost(anvandare, { typ: "objekt_identifierat", objekt }),
nyLoggPost(anvandare, { typ: "felbeskrivning", text: felbeskrivningText }),
],
};
set((s) => ({
arenden: { ...s.arenden, [id]: arende },
nastaNummer: s.nastaNummer + 1,
}));
return id;
},
laggTill: (arendeId, handelse) => {
const { anvandare } = get();
set((s) => {
const arende = s.arenden[arendeId];
if (!arende) return s;
return {
arenden: {
...s.arenden,
[arendeId]: {
...arende,
handelser: [...arende.handelser, nyLoggPost(anvandare, handelse)],
},
},
};
});
},
sammanfoga: (arendeId, handelser) => {
set((s) => {
const arende = s.arenden[arendeId];
if (!arende || handelser.length === arende.handelser.length) return s;
return {
arenden: { ...s.arenden, [arendeId]: { ...arende, handelser } },
};
});
},
laggInArende: (arende) => {
set((s) => ({
arenden: { ...s.arenden, [arende.id]: arende },
nastaNummer: Math.max(s.nastaNummer, arende.nummer + 1),
}));
},
}),
{ name: "guidad-felsokning" },
),
);
export function metodikForArende(arende: Arende | undefined): Metodik {
if (!arende) return GENERISK_METODIK;
if (arende.metodikId) return metodikForId(arende.metodikId);
const text = felbeskrivning(arende);
return text ? valjMetodik(text) : GENERISK_METODIK;
}
+131
View File
@@ -0,0 +1,131 @@
// QR-, streckkods- och typskyltsläsning för objektidentifiering.
//
// Två vägar, samma resultat:
// 1. Webbläsarens BarcodeDetector (Chrome/Android) läser QR, Code 39/128
// och Data Matrix direkt ur kameraströmmen — vanligast på typskyltar,
// VIN-etiketter och maskinmärkning.
// 2. Saknas API:t (t.ex. iOS/Safari) fotograferas typskylten i stället
// och plattformens bildtolkning läser av den — visual-first: kameran
// är gränssnittet oavsett vad webbläsaren stöder.
//
// Den avlästa koden klassificeras alltid innan den används, så att rätt
// identifieringsmetod sätts på fordonsobjektet.
export type KodTyp = "VIN" | "Regnr" | "Serienummer";
export interface AvlastKod {
varde: string;
typ: KodTyp;
format?: string;
}
// VIN: 17 tecken, aldrig I, O eller Q (för att inte förväxlas med 1/0).
// Kontrollsiffran (position 9) valideras medvetet inte — den är
// obligatorisk i Nordamerika men följs inte konsekvent i Europa.
const VIN_MONSTER = /^[A-HJ-NPR-Z0-9]{17}$/;
// Svenskt registreringsnummer: ABC123 eller ABC12A (nyare serie).
const REGNR_MONSTER = /^[A-Z]{3}[0-9]{2}[0-9A-Z]$/;
// Normaliserar och klassificerar en avläst kod. QR-koder på fordon och
// maskiner innehåller ofta en URL eller nyckel=värde — då plockas
// identifieraren ut ur innehållet.
export function tolkaKod(ratext: string): AvlastKod | null {
const text = ratext.trim();
if (!text) return null;
// QR med URL eller fält: leta efter vin/regnr i innehållet.
const vinFalt = text.match(/(?:vin|chassi)[=:/\s]+([A-HJ-NPR-Z0-9]{17})/i);
if (vinFalt) return { varde: vinFalt[1].toUpperCase(), typ: "VIN" };
const regnrFalt = text.match(/(?:reg(?:nr)?|plate)[=:/\s]+([A-Za-z]{3}\s?[0-9]{2}[0-9A-Za-z])/i);
if (regnrFalt) return { varde: regnrFalt[1].replace(/\s/g, "").toUpperCase(), typ: "Regnr" };
const rent = text.replace(/[\s-]/g, "").toUpperCase();
if (VIN_MONSTER.test(rent)) return { varde: rent, typ: "VIN" };
if (REGNR_MONSTER.test(rent)) return { varde: rent, typ: "Regnr" };
// Allt annat (maskin-/serienummer) tas som det är, men bara om det ser
// ut som en identifierare — inte som fritext eller en hel URL.
if (/^[A-Z0-9._/-]{4,40}$/.test(rent) && !/^HTTPS?:/.test(rent)) {
return { varde: rent, typ: "Serienummer" };
}
return null;
}
interface BarcodeDetectorLik {
detect(kalla: CanvasImageSource): Promise<{ rawValue: string; format?: string }[]>;
}
function detektorKlass(): (new (init?: { formats?: string[] }) => BarcodeDetectorLik) | undefined {
return (globalThis as { BarcodeDetector?: new (init?: { formats?: string[] }) => BarcodeDetectorLik })
.BarcodeDetector;
}
export function stodStreckkod(): boolean {
return !!detektorKlass() && !!navigator.mediaDevices?.getUserMedia;
}
export interface Skanning {
stoppa: () => void;
}
// Startar kameraströmmen i det angivna video-elementet och anropar
// vidTraff första gången en giltig kod lästs av. Anroparen ansvarar för
// att stoppa skanningen (returvärdet) när panelen stängs.
export async function startaSkanning(
video: HTMLVideoElement,
vidTraff: (kod: AvlastKod) => void,
vidFel?: (meddelande: string) => void,
): Promise<Skanning> {
const Detektor = detektorKlass();
if (!Detektor) {
vidFel?.("Webbläsaren saknar streckkodsläsning — fotografera typskylten i stället.");
return { stoppa: () => {} };
}
// Samlat tillstånd: stoppa() kan då stänga ner även om skanningen
// avbryts innan kameraströmmen hunnit starta.
const lage: { aktiv: boolean; strom?: MediaStream; timer?: ReturnType<typeof setInterval> } = { aktiv: true };
const stoppa = () => {
lage.aktiv = false;
if (lage.timer !== undefined) clearInterval(lage.timer);
lage.strom?.getTracks().forEach((spar) => spar.stop());
};
try {
lage.strom = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
if (!lage.aktiv) {
lage.strom.getTracks().forEach((spar) => spar.stop());
return { stoppa };
}
video.srcObject = lage.strom;
// play() väntas medvetet inte in: på vissa enheter resolvar löftet
// först när första bildrutan kommit, och då skulle avläsningen aldrig
// starta. Detektorn får helt enkelt tomma rutor tills strömmen är i gång.
void video.play().catch(() => {});
} catch {
vidFel?.("Kameran kunde inte startas — kontrollera behörigheten eller fotografera typskylten.");
return { stoppa };
}
const detektor = new Detektor({ formats: ["qr_code", "code_128", "code_39", "data_matrix", "pdf417"] });
// Avläsning ~2,5 ggr/sekund räcker gott och håller nere batteridraget.
lage.timer = setInterval(async () => {
if (!lage.aktiv) return;
try {
const traffar = await detektor.detect(video);
for (const traff of traffar) {
const kod = tolkaKod(traff.rawValue);
if (kod) {
stoppa();
vidTraff({ ...kod, format: traff.format });
return;
}
}
} catch {
// Enstaka avläsningsfel är normalt — nästa bildruta får försöka.
}
}, 400);
return { stoppa };
}
+141
View File
@@ -0,0 +1,141 @@
// Synkronisering mot backend. Append-only-modellen gör den konfliktfri:
// händelser flätas ihop per id och ingenting skrivs över — enheter kan
// arbeta offline och synka i efterhand utan att någon post går förlorad.
//
// Kräver inloggning (Supabase Auth). Utan session arbetar appen vidare
// i lokalt läge; synk återupptas när användaren loggat in.
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Arende, Handelse, LoggPost } from "./domain";
// Deterministisk ihopflätning: dedupe per id, ordning per tidpunkt (id som
// stabil sekundärnyckel). Samma indata ger alltid samma resultat.
export function flataIhop(lokala: LoggPost[], fjarr: LoggPost[]): LoggPost[] {
const perId = new Map<string, LoggPost>();
for (const post of [...lokala, ...fjarr]) {
if (!perId.has(post.id)) perId.set(post.id, post);
}
return [...perId.values()].sort(
(a, b) => a.tidpunkt.localeCompare(b.tidpunkt) || a.id.localeCompare(b.id),
);
}
export type SynkStatus = "lokal" | "synkar" | "synkad" | "offline";
export interface SynkResultat {
status: SynkStatus;
handelser?: LoggPost[];
}
interface FjarrHandelseRad {
id: string;
tidpunkt: string;
anvandare: string;
handelse: Handelse;
}
async function klient(): Promise<SupabaseClient | undefined> {
try {
const { supabase } = await import("@/integrations/supabase/client");
// Tabellerna är nya — den genererade Database-typen regenereras av
// Lovable/Supabase CLI; tills dess används klienten otypad här.
return supabase as unknown as SupabaseClient;
} catch {
return undefined;
}
}
// Självhostat läge: samma konfliktfria flätning, men mot plattformstjänsten.
async function synkroniseraMotPlattform(arende: Arende): Promise<SynkResultat> {
const { plattformToken, plattformFetch } = await import("./plattform");
if (!plattformToken()) return { status: "lokal" };
try {
await plattformFetch("/api/arenden", {
method: "POST",
body: JSON.stringify({
id: arende.id,
nummer: arende.nummer,
skapad: arende.skapad,
delningskod: arende.delningskod,
metodikId: arende.metodikId,
}),
});
const hamtat = await plattformFetch(`/api/arenden/${arende.id}/handelser`);
if (!hamtat.ok) throw new Error(`Fel ${hamtat.status}`);
const { handelser: fjarrRader } = (await hamtat.json()) as { handelser: FjarrHandelseRad[] };
const fjarr: LoggPost[] = fjarrRader.map((rad) => ({
id: rad.id,
tidpunkt: new Date(rad.tidpunkt).toISOString(),
anvandare: rad.anvandare,
handelse: rad.handelse,
}));
const fjarrIds = new Set(fjarr.map((p) => p.id));
const nya = arende.handelser.filter((p) => !fjarrIds.has(p.id));
if (nya.length > 0) {
const skrivet = await plattformFetch(`/api/arenden/${arende.id}/handelser`, {
method: "POST",
body: JSON.stringify({ handelser: nya }),
});
if (!skrivet.ok) throw new Error(`Fel ${skrivet.status}`);
}
return { status: "synkad", handelser: flataIhop(arende.handelser, fjarr) };
} catch {
const { plattformToken: kvarToken } = await import("./plattform");
return { status: kvarToken() ? "offline" : "lokal" };
}
}
export async function synkroniseraArende(arende: Arende): Promise<SynkResultat> {
const { plattformAktiv } = await import("./plattform");
if (plattformAktiv()) return synkroniseraMotPlattform(arende);
const supabase = await klient();
if (!supabase) return { status: "lokal" };
try {
const { data } = await supabase.auth.getSession();
if (!data.session) return { status: "lokal" };
const { error: arendeFel } = await supabase.from("felsokning_arenden").upsert(
{
id: arende.id,
nummer: arende.nummer,
skapad: arende.skapad,
delningskod: arende.delningskod ?? null,
},
{ onConflict: "id", ignoreDuplicates: true },
);
if (arendeFel) throw arendeFel;
const { data: fjarrRader, error: lasFel } = await supabase
.from("felsokning_handelser")
.select("id,tidpunkt,anvandare,handelse")
.eq("arende_id", arende.id);
if (lasFel) throw lasFel;
const fjarr: LoggPost[] = ((fjarrRader ?? []) as FjarrHandelseRad[]).map((rad) => ({
id: rad.id,
tidpunkt: new Date(rad.tidpunkt).toISOString(),
anvandare: rad.anvandare,
handelse: rad.handelse,
}));
const fjarrIds = new Set(fjarr.map((p) => p.id));
const nya = arende.handelser
.filter((p) => !fjarrIds.has(p.id))
.map((p) => ({
id: p.id,
arende_id: arende.id,
tidpunkt: p.tidpunkt,
anvandare: p.anvandare,
handelse: p.handelse,
}));
if (nya.length > 0) {
const { error: skrivFel } = await supabase.from("felsokning_handelser").insert(nya);
if (skrivFel) throw skrivFel;
}
return { status: "synkad", handelser: flataIhop(arende.handelser, fjarr) };
} catch {
return { status: "offline" };
}
}
+174
View File
@@ -0,0 +1,174 @@
// Delade UI-komponenter för Guidad Felsökning.
// Designfilosofi: industriellt verkstadsverktyg i ETKA-tradition —
// plana ytor, skarpa kanter, tät informationslayout, dämpad palett
// (ljusgrå ytor, djup marinblå som primärfärg), inga dekorationer.
// Verktygsrad ~44 px, rektangulära knappar (max 4 px radie), kompakta
// formulär med vänsterställda etiketter.
import { Link } from "react-router-dom";
import { useRef, type ReactNode } from "react";
import type { Tillforlitlighet } from "./domain";
import { TILLFORLITLIGHET_LABEL } from "./domain";
import { MikrofonKnapp } from "./Mikrofon";
import { IkonPunkt } from "./ikoner";
// Industriell palett (inspirerad av tyska verkstadssystem):
// bakgrund #ECECEC · paneler #F7F7F7 · kanter #C6C6C6
// primär (djup blå) #00437A · markerad rad #D6E4F2
// varning #9A6700 · fel #8B1A1A · ok #1E6B34
const TYPSNITT = "'Inter', 'IBM Plex Sans', 'Segoe UI', Roboto, system-ui, sans-serif";
export function FelsokningSkal({
rubrik,
tillbaka,
hoger,
bred,
children,
}: {
rubrik: string;
tillbaka?: { till: string; text: string };
hoger?: ReactNode;
bred?: boolean;
children: ReactNode;
}) {
const maxBredd = bred ? "max-w-[1240px]" : "max-w-3xl";
return (
<div
className="felsokning-print min-h-screen bg-[#ECECEC] text-[13px] text-[#1A1A1A]"
style={{ fontFamily: TYPSNITT }}
>
{/* Verktygsrad: låg, mörk marinblå — breadcrumb till vänster,
status till höger. Inget stort sidhuvud. */}
<header className="sticky top-0 z-20 border-b border-[#0A2A4A] bg-[#123A63] px-3 text-white print:static">
<div className={`mx-auto flex min-h-11 ${maxBredd} items-center justify-between gap-3 py-1`}>
<div className="flex min-w-0 items-center gap-2">
{tillbaka && (
<>
<Link
to={tillbaka.till}
className="whitespace-nowrap text-[12px] font-medium text-[#A9C3DE] hover:text-white hover:underline"
>
{tillbaka.text}
</Link>
<span className="text-[#5B7CA0]">/</span>
</>
)}
<h1 className="truncate text-[14px] font-semibold tracking-tight">{rubrik}</h1>
</div>
{hoger}
</div>
</header>
<main className={`mx-auto ${maxBredd} px-3 py-4 pb-20`}>{children}</main>
</div>
);
}
export function StorKnapp({
children,
onClick,
variant = "primar",
disabled,
type = "button",
className = "",
}: {
children: ReactNode;
onClick?: () => void;
variant?: "primar" | "sekundar" | "fara";
disabled?: boolean;
type?: "button" | "submit";
className?: string;
}) {
const farg =
variant === "primar"
? "border border-[#003561] bg-[#00437A] text-white hover:bg-[#005A9E] active:bg-[#003561]"
: variant === "fara"
? "border border-[#6E1414] bg-[#8B1A1A] text-white hover:bg-[#A32020] active:bg-[#6E1414]"
: "border border-[#ADADAD] bg-white text-[#1A1A1A] hover:bg-[#EDF2F7] hover:border-[#00437A] active:bg-[#D6E4F2]";
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`min-h-10 w-full rounded px-3 py-1.5 text-[13px] font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40 print:hidden ${farg} ${className}`}
>
{children}
</button>
);
}
export function Panel({ rubrik, children }: { rubrik?: string; children: ReactNode }) {
return (
<section className="mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-3 shadow-[0_1px_2px_rgba(0,0,0,0.06)]">
{rubrik && (
<h2 className="mb-2 border-b border-[#DDDDDD] pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-[#4A5560]">
{rubrik}
</h2>
)}
{children}
</section>
);
}
const NIVA_FARG: Record<Tillforlitlighet, string> = {
hog: "#1E6B34",
medel: "#C08A00",
lag: "#8B1A1A",
};
export function NivaBadge({ niva }: { niva: Tillforlitlighet }) {
return (
<span className="inline-flex items-center gap-1 whitespace-nowrap text-[12px] font-semibold">
<IkonPunkt farg={NIVA_FARG[niva]} />
{TILLFORLITLIGHET_LABEL[niva]}
</span>
);
}
export function TextFalt({
label,
varde,
satt,
platshallare,
flerRad,
rost,
losenord,
}: {
label: string;
varde: string;
satt: (v: string) => void;
platshallare?: string;
flerRad?: boolean;
rost?: boolean;
losenord?: boolean;
}) {
// Tal-till-text skriver in i samma redigerbara fält — användaren
// granskar och bekräftar alltid innan något sparas i loggen.
const vardeRef = useRef(varde);
vardeRef.current = varde;
const klass =
"w-full rounded border border-[#ADADAD] bg-white px-2.5 py-1.5 text-[13px] text-[#1A1A1A] placeholder-[#9AA0A6] focus:border-[#00437A] focus:outline-none focus:ring-1 focus:ring-[#00437A]";
return (
<label className="mb-2.5 block">
<span className="mb-1 flex min-h-6 items-center justify-between gap-2">
<span className="text-left text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">{label}</span>
{rost && (
<MikrofonKnapp
paText={(text) => satt(vardeRef.current ? `${vardeRef.current.trimEnd()} ${text}` : text)}
/>
)}
</span>
{flerRad ? (
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
) : (
<input
type={losenord ? "password" : "text"}
value={varde}
onChange={(e) => satt(e.target.value)}
placeholder={platshallare}
className={klass}
/>
)}
</label>
);
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+186
View File
@@ -0,0 +1,186 @@
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
+110
View File
@@ -0,0 +1,110 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
export interface Order {
id: string;
order_number: string;
customer_email: string;
customer_name: string | null;
items: any[];
total_amount: number;
currency: string;
status: string;
created_at: string;
}
export interface Customer {
id: string;
email: string;
name: string | null;
phone: string | null;
company: string | null;
total_orders: number;
total_spent: number;
created_at: string;
}
export interface Payment {
id: string;
order_id: string | null;
amount: number;
currency: string;
status: string;
payment_method: string | null;
created_at: string;
}
export function useOrders() {
return useQuery({
queryKey: ["admin-orders"],
queryFn: async () => {
const { data, error } = await supabase
.from("orders")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data as Order[];
},
});
}
export function useCustomers() {
return useQuery({
queryKey: ["admin-customers"],
queryFn: async () => {
const { data, error } = await supabase
.from("customers")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data as Customer[];
},
});
}
export function usePayments() {
return useQuery({
queryKey: ["admin-payments"],
queryFn: async () => {
const { data, error } = await supabase
.from("payments")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data as Payment[];
},
});
}
export function useAdminStats() {
return useQuery({
queryKey: ["admin-stats"],
queryFn: async () => {
const [ordersRes, customersRes, paymentsRes] = await Promise.all([
supabase.from("orders").select("total_amount, status"),
supabase.from("customers").select("id"),
supabase.from("payments").select("amount, status"),
]);
const orders = ordersRes.data || [];
const customers = customersRes.data || [];
const payments = paymentsRes.data || [];
const totalRevenue = orders.reduce((sum, o) => sum + Number(o.total_amount), 0);
const pendingOrders = orders.filter(o => o.status === "pending").length;
const completedPayments = payments.filter(p => p.status === "completed").length;
return {
totalOrders: orders.length,
totalCustomers: customers.length,
totalRevenue,
pendingOrders,
totalPayments: payments.length,
completedPayments,
};
},
});
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from "react";
import { User, Session } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Set up auth state listener BEFORE getting session
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
}
);
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
const signUp = async (email: string, password: string) => {
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: window.location.origin,
},
});
return { error };
};
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
return { error };
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
return { error };
};
const resetPassword = async (email: string) => {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/reset-password`,
});
return { error };
};
const updatePassword = async (newPassword: string) => {
const { error } = await supabase.auth.updateUser({
password: newPassword,
});
return { error };
};
return {
user,
session,
loading,
signUp,
signIn,
signOut,
resetPassword,
updatePassword,
};
}
+239
View File
@@ -0,0 +1,239 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Lennart Svenssons Konditorivaror - Traditionell Svensk Konditoristil */
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&display=swap');
@layer base {
:root {
--background: 38 25% 96%;
--foreground: 25 30% 12%;
--card: 40 30% 94%;
--card-foreground: 25 30% 12%;
--popover: 40 30% 94%;
--popover-foreground: 25 30% 12%;
--primary: 4 70% 45%;
--primary-foreground: 40 30% 96%;
--secondary: 35 20% 88%;
--secondary-foreground: 25 30% 18%;
--muted: 35 15% 85%;
--muted-foreground: 0 0% 0%;
--accent: 4 70% 45%;
--accent-foreground: 40 30% 96%;
--destructive: 4 70% 40%;
--destructive-foreground: 40 30% 96%;
--border: 30 20% 80%;
--input: 30 20% 80%;
--ring: 4 70% 45%;
--radius: 0.25rem;
/* Custom tokens */
--gradient-warm: linear-gradient(180deg, hsl(40 30% 94%) 0%, hsl(35 25% 90%) 100%);
--shadow-classic: 0 4px 20px -4px hsl(25 30% 12% / 0.15);
--shadow-card: 0 2px 8px hsl(25 30% 12% / 0.08);
--font-display: 'Cormorant Garamond', serif;
--font-body: 'Crimson Text', serif;
}
.dark {
--background: 25 20% 8%;
--foreground: 38 25% 92%;
--card: 25 18% 12%;
--card-foreground: 38 25% 92%;
--primary: 30 50% 55%;
--primary-foreground: 25 20% 8%;
--secondary: 25 15% 18%;
--secondary-foreground: 38 25% 92%;
--muted: 25 12% 22%;
--muted-foreground: 30 15% 60%;
--border: 25 15% 25%;
}
}
@layer base {
* {
@apply border-border;
}
html {
scroll-behavior: smooth;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
html::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-body);
}
/* Ensure full-width layout (avoid template max-width/padding on #root) */
#root {
max-width: none;
margin: 0;
padding: 0;
text-align: inherit;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
}
}
@layer components {
.hero-gradient {
background: var(--gradient-warm);
}
.text-accent-warm {
color: hsl(var(--accent));
}
.card-classic {
box-shadow: var(--shadow-card);
border: 1px solid hsl(var(--border));
transition: all 0.3s ease;
}
.card-classic:hover {
box-shadow: var(--shadow-classic);
transform: translateY(-4px);
}
.btn-classic {
@apply bg-primary text-primary-foreground font-semibold transition-all duration-300;
border: 2px solid hsl(var(--primary));
}
.btn-classic:hover {
@apply bg-transparent;
color: hsl(var(--primary));
}
.btn-outline-classic {
@apply bg-transparent font-semibold transition-all duration-300;
color: hsl(var(--primary));
border: 2px solid hsl(var(--primary));
}
.btn-outline-classic:hover {
@apply bg-primary text-primary-foreground;
}
.divider-ornament {
display: flex;
align-items: center;
gap: 1rem;
}
.divider-ornament::before,
.divider-ornament::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, hsl(var(--border)), transparent);
}
.ornament {
color: hsl(var(--primary));
font-family: var(--font-display);
font-size: 1.5rem;
}
}
@layer utilities {
.animate-fade-up {
animation: fadeUp 0.8s ease-out forwards;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.text-balance {
text-wrap: balance;
}
/* Calculator slider — minimal, exclusive */
.calculator-slider {
-webkit-appearance: none;
appearance: none;
height: 4px;
background: hsl(var(--border));
border-radius: 999px;
outline: none;
cursor: pointer;
transition: background 0.2s ease;
}
.calculator-slider:hover {
background: hsl(var(--muted));
}
.calculator-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 26px;
height: 26px;
border-radius: 50%;
background: hsl(var(--primary));
border: 3px solid hsl(var(--card));
box-shadow: 0 2px 10px hsl(var(--primary) / 0.35);
cursor: grab;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.calculator-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px hsl(var(--primary) / 0.5);
}
.calculator-slider::-webkit-slider-thumb:active {
cursor: grabbing;
}
.calculator-slider::-moz-range-thumb {
width: 26px;
height: 26px;
border-radius: 50%;
background: hsl(var(--primary));
border: 3px solid hsl(var(--card));
box-shadow: 0 2px 10px hsl(var(--primary) / 0.35);
cursor: grab;
}
}
/* Guidad Felsökning: utskriftsvänlig kundrapport — mörkt arbetsläge på
skärm, svart på vitt på papper. Interaktiva element döljs vid utskrift. */
@media print {
.felsokning-print,
.felsokning-print * {
background: #fff !important;
color: #111 !important;
border-color: #d4d4d8 !important;
animation: none !important;
}
.felsokning-print nav,
.felsokning-print button,
.felsokning-print input,
.felsokning-print textarea {
display: none !important;
}
}
@@ -0,0 +1,17 @@
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
}
});
@@ -0,0 +1,419 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.1"
}
public: {
Tables: {
customers: {
Row: {
company: string | null
created_at: string
email: string
id: string
name: string | null
phone: string | null
total_orders: number | null
total_spent: number | null
updated_at: string
}
Insert: {
company?: string | null
created_at?: string
email: string
id?: string
name?: string | null
phone?: string | null
total_orders?: number | null
total_spent?: number | null
updated_at?: string
}
Update: {
company?: string | null
created_at?: string
email?: string
id?: string
name?: string | null
phone?: string | null
total_orders?: number | null
total_spent?: number | null
updated_at?: string
}
Relationships: []
}
orders: {
Row: {
created_at: string
currency: string
customer_email: string
customer_id: string | null
customer_name: string | null
id: string
items: Json
order_number: string
shopify_checkout_id: string | null
status: string
total_amount: number
updated_at: string
}
Insert: {
created_at?: string
currency?: string
customer_email: string
customer_id?: string | null
customer_name?: string | null
id?: string
items?: Json
order_number: string
shopify_checkout_id?: string | null
status?: string
total_amount: number
updated_at?: string
}
Update: {
created_at?: string
currency?: string
customer_email?: string
customer_id?: string | null
customer_name?: string | null
id?: string
items?: Json
order_number?: string
shopify_checkout_id?: string | null
status?: string
total_amount?: number
updated_at?: string
}
Relationships: [
{
foreignKeyName: "orders_customer_id_fkey"
columns: ["customer_id"]
isOneToOne: false
referencedRelation: "customers"
referencedColumns: ["id"]
},
]
}
payments: {
Row: {
amount: number
created_at: string
currency: string
id: string
order_id: string | null
payment_method: string | null
shopify_payment_id: string | null
status: string
}
Insert: {
amount: number
created_at?: string
currency?: string
id?: string
order_id?: string | null
payment_method?: string | null
shopify_payment_id?: string | null
status?: string
}
Update: {
amount?: number
created_at?: string
currency?: string
id?: string
order_id?: string | null
payment_method?: string | null
shopify_payment_id?: string | null
status?: string
}
Relationships: [
{
foreignKeyName: "payments_order_id_fkey"
columns: ["order_id"]
isOneToOne: false
referencedRelation: "orders"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
address: string | null
city: string | null
company_name: string | null
created_at: string
display_name: string | null
id: string
phone: string | null
postal_code: string | null
street_address: string | null
updated_at: string
user_id: string
}
Insert: {
address?: string | null
city?: string | null
company_name?: string | null
created_at?: string
display_name?: string | null
id?: string
phone?: string | null
postal_code?: string | null
street_address?: string | null
updated_at?: string
user_id: string
}
Update: {
address?: string | null
city?: string | null
company_name?: string | null
created_at?: string
display_name?: string | null
id?: string
phone?: string | null
postal_code?: string | null
street_address?: string | null
updated_at?: string
user_id?: string
}
Relationships: []
}
subscriptions: {
Row: {
cancel_at_period_end: boolean
collection_method: string | null
company_name: string | null
created_at: string
current_period_end: string | null
current_period_start: string | null
customer_email: string
environment: string
id: string
kg_per_week: number | null
monthly_amount: number | null
price_id: string | null
status: string
stripe_customer_id: string
stripe_subscription_id: string
updated_at: string
user_id: string | null
}
Insert: {
cancel_at_period_end?: boolean
collection_method?: string | null
company_name?: string | null
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
customer_email: string
environment?: string
id?: string
kg_per_week?: number | null
monthly_amount?: number | null
price_id?: string | null
status?: string
stripe_customer_id: string
stripe_subscription_id: string
updated_at?: string
user_id?: string | null
}
Update: {
cancel_at_period_end?: boolean
collection_method?: string | null
company_name?: string | null
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
customer_email?: string
environment?: string
id?: string
kg_per_week?: number | null
monthly_amount?: number | null
price_id?: string | null
status?: string
stripe_customer_id?: string
stripe_subscription_id?: string
updated_at?: string
user_id?: string | null
}
Relationships: []
}
user_roles: {
Row: {
created_at: string
id: string
role: Database["public"]["Enums"]["app_role"]
user_id: string
}
Insert: {
created_at?: string
id?: string
role: Database["public"]["Enums"]["app_role"]
user_id: string
}
Update: {
created_at?: string
id?: string
role?: Database["public"]["Enums"]["app_role"]
user_id?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
has_active_kaka_subscription: {
Args: { user_uuid: string }
Returns: boolean
}
has_role: {
Args: {
_role: Database["public"]["Enums"]["app_role"]
_user_id: string
}
Returns: boolean
}
}
Enums: {
app_role: "admin" | "user"
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {
app_role: ["admin", "user"],
},
},
} as const
+255
View File
@@ -0,0 +1,255 @@
import { toast } from "sonner";
// Shopify API Configuration
const SHOPIFY_API_VERSION = '2025-07';
const SHOPIFY_STORE_PERMANENT_DOMAIN = 'sweet-office-treats-2ar62.myshopify.com';
const SHOPIFY_STOREFRONT_URL = `https://${SHOPIFY_STORE_PERMANENT_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`;
const SHOPIFY_STOREFRONT_TOKEN = 'd9c2c43fc30d96d1742cc76da58c4ddb';
export interface ShopifyProduct {
node: {
id: string;
title: string;
description: string;
handle: string;
priceRange: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
images: {
edges: Array<{
node: {
url: string;
altText: string | null;
};
}>;
};
variants: {
edges: Array<{
node: {
id: string;
title: string;
price: {
amount: string;
currencyCode: string;
};
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
};
}>;
};
sellingPlanGroups?: {
edges: Array<{
node: {
name: string;
sellingPlans: {
edges: Array<{
node: {
id: string;
name: string;
};
}>;
};
};
}>;
};
options: Array<{
name: string;
values: string[];
}>;
};
}
// Storefront API helper function
export async function storefrontApiRequest(query: string, variables: Record<string, unknown> = {}) {
const response = await fetch(SHOPIFY_STOREFRONT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': SHOPIFY_STOREFRONT_TOKEN
},
body: JSON.stringify({
query,
variables,
}),
});
if (response.status === 402) {
toast.error("Shopify: Betalning krävs", {
description: "Shopify API kräver en aktiv betalplan. Besök admin.shopify.com för att uppgradera.",
});
return null;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.errors) {
throw new Error(`Error calling Shopify: ${data.errors.map((e: { message: string }) => e.message).join(', ')}`);
}
return data;
}
// Fetch products query
export const STOREFRONT_PRODUCTS_QUERY = `
query GetProducts($first: Int!, $query: String) {
products(first: $first, query: $query) {
edges {
node {
id
title
description
handle
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 5) {
edges {
node {
url
altText
}
}
}
variants(first: 10) {
edges {
node {
id
title
price {
amount
currencyCode
}
availableForSale
selectedOptions {
name
value
}
}
}
}
sellingPlanGroups(first: 5) {
edges {
node {
name
sellingPlans(first: 10) {
edges {
node {
id
name
}
}
}
}
}
}
options {
name
values
}
}
}
}
}
`;
// Cart mutations
export const CART_QUERY = `
query cart($id: ID!) {
cart(id: $id) {
id
totalQuantity
checkoutUrl
lines(first: 100) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
price {
amount
currencyCode
}
}
}
}
}
}
}
}
`;
export const CART_CREATE_MUTATION = `
mutation cartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
lines(first: 100) { edges { node { id merchandise { ... on ProductVariant { id } } } } }
}
userErrors { field message }
}
}
`;
export const CART_LINES_ADD_MUTATION = `
mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
lines(first: 100) { edges { node { id merchandise { ... on ProductVariant { id } } } } }
}
userErrors { field message }
}
}
`;
export const CART_LINES_UPDATE_MUTATION = `
mutation cartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart { id }
userErrors { field message }
}
}
`;
export const CART_LINES_REMOVE_MUTATION = `
mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart { id }
userErrors { field message }
}
}
`;
export function formatCheckoutUrl(checkoutUrl: string): string {
try {
const url = new URL(checkoutUrl);
url.searchParams.set('channel', 'online_store');
return url.toString();
} catch {
return checkoutUrl;
}
}
export function isCartNotFoundError(userErrors: Array<{ field: string[] | null; message: string }>): boolean {
return userErrors.some(e =>
e.message.toLowerCase().includes('cart not found') ||
e.message.toLowerCase().includes('does not exist')
);
}
+31
View File
@@ -0,0 +1,31 @@
import { loadStripe, Stripe } from "@stripe/stripe-js";
export type StripeEnv = "sandbox" | "live";
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
function paymentsEnvironment(): StripeEnv {
if (clientToken?.startsWith("pk_test_")) return "sandbox";
if (clientToken?.startsWith("pk_live_")) return "live";
throw new Error(
"Betalningar är inte konfigurerade för denna build. Slutför go-live i Payments-fliken för att aktivera skarpa betalningar."
);
}
let stripePromise: Promise<Stripe | null> | null = null;
export function getStripe(): Promise<Stripe | null> {
if (!stripePromise) {
paymentsEnvironment();
stripePromise = loadStripe(clientToken as string);
}
return stripePromise;
}
export function getStripeEnvironment(): StripeEnv {
return paymentsEnvironment();
}
export function hasPaymentsConfigured(): boolean {
return !!clientToken && (clientToken.startsWith("pk_test_") || clientToken.startsWith("pk_live_"));
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
+476
View File
@@ -0,0 +1,476 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { supabase } from "@/integrations/supabase/client";
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
import Header from "@/components/Header";
import { Save, Package, ExternalLink } from "lucide-react";
// Validate Stockholm postal codes (100 00 - 199 99)
const isStockholmPostalCode = (postalCode: string) => {
if (!postalCode || postalCode.trim() === "") return true; // Allow empty
const cleaned = postalCode.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned)) return false;
const num = parseInt(cleaned, 10);
return num >= 10000 && num <= 19999;
};
const profileSchema = z.object({
company_name: z.string().trim().max(100, { message: "Företagsnamn får max vara 100 tecken" }).optional(),
street_address: z.string().trim().max(200, { message: "Gatuadress får max vara 200 tecken" }).optional(),
postal_code: z
.string()
.trim()
.max(10, { message: "Postnummer får max vara 10 tecken" })
.refine((val) => isStockholmPostalCode(val), {
message: "Vi levererar endast inom Storstockholm (100 00 199 99)",
})
.optional(),
city: z.string().trim().max(100, { message: "Ort får max vara 100 tecken" }).optional(),
});
interface ProfileData {
company_name: string;
street_address: string;
postal_code: string;
city: string;
}
const getPostalCodeError = (postalCode: string) => {
if (!postalCode || postalCode.trim() === "") return null;
const cleaned = postalCode.replace(/\s/g, "");
if (cleaned.length < 5) return null; // Don't show error while typing
if (!/^\d{5}$/.test(cleaned)) return "Ange ett giltigt postnummer (5 siffror)";
const num = parseInt(cleaned, 10);
if (num < 10000 || num > 19999) {
return "Vi levererar endast inom Storstockholm (100 00 199 99)";
}
return null;
};
interface SubscriptionRow {
id: string;
status: string;
kg_per_week: number | null;
monthly_amount: number | null;
current_period_end: string | null;
cancel_at_period_end: boolean;
collection_method: string | null;
}
const Account = () => {
const [profile, setProfile] = useState<ProfileData>({
company_name: "",
street_address: "",
postal_code: "",
city: "",
});
const [originalProfile, setOriginalProfile] = useState<ProfileData>({
company_name: "",
street_address: "",
postal_code: "",
city: "",
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [subscription, setSubscription] = useState<SubscriptionRow | null>(null);
const [openingPortal, setOpeningPortal] = useState(false);
const { user, loading: authLoading, signOut } = useAuth();
const { toast } = useToast();
const navigate = useNavigate();
const hasChanges =
profile.company_name !== originalProfile.company_name ||
profile.street_address !== originalProfile.street_address ||
profile.postal_code !== originalProfile.postal_code ||
profile.city !== originalProfile.city;
useEffect(() => {
if (!authLoading && !user) {
navigate("/");
return;
}
if (user) {
fetchProfile();
fetchSubscription();
}
}, [user, authLoading, navigate]);
const fetchSubscription = async () => {
if (!user) return;
try {
const env = hasPaymentsConfigured() ? getStripeEnvironment() : "sandbox";
const { data } = await supabase
.from("subscriptions")
.select("id, status, kg_per_week, monthly_amount, current_period_end, cancel_at_period_end, collection_method")
.eq("user_id", user.id)
.eq("environment", env)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
setSubscription((data as SubscriptionRow | null) ?? null);
} catch (e) {
console.error("fetchSubscription error:", e);
}
};
const handleManageSubscription = async () => {
if (!hasPaymentsConfigured()) {
toast({ title: "Betalningar inte konfigurerade", variant: "destructive" });
return;
}
setOpeningPortal(true);
try {
const { data, error } = await supabase.functions.invoke("create-portal-session", {
body: {
return_url: `${window.location.origin}/account`,
environment: getStripeEnvironment(),
},
});
if (error) throw error;
if ((data as any)?.error) throw new Error((data as any).error);
const url = (data as any)?.url;
if (!url) throw new Error("Ingen portal-URL mottagen");
window.open(url, "_blank", "noopener,noreferrer");
} catch (e: any) {
toast({
title: "Kunde inte öppna kundportalen",
description: e?.message ?? String(e),
variant: "destructive",
});
} finally {
setOpeningPortal(false);
}
};
const fetchProfile = async () => {
try {
const { data, error } = await supabase
.from("profiles")
.select("company_name, street_address, postal_code, city")
.eq("user_id", user!.id)
.maybeSingle();
if (error) {
console.error("Error fetching profile:", error);
toast({
title: "Kunde inte hämta profil",
description: error.message,
variant: "destructive",
});
} else if (data) {
const profileData = {
company_name: data.company_name || "",
street_address: data.street_address || "",
postal_code: data.postal_code || "",
city: data.city || "",
};
setProfile(profileData);
setOriginalProfile(profileData);
}
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validation = profileSchema.safeParse(profile);
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
const { error } = await supabase
.from("profiles")
.update({
company_name: profile.company_name || null,
street_address: profile.street_address || null,
postal_code: profile.postal_code || null,
city: profile.city || null,
})
.eq("user_id", user!.id);
if (error) {
toast({
title: "Kunde inte spara profil",
description: error.message,
variant: "destructive",
});
} else {
// Navigate to confirmation page after successful save
navigate("/account/confirmation", { state: { justSaved: true } });
}
} finally {
setIsSaving(false);
}
};
const handleSignOut = async () => {
await signOut();
navigate("/");
};
if (authLoading || isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 flex items-center justify-center">
<p className="text-muted-foreground">Laddar...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 px-4 pb-12">
{/* Back link - top left */}
<div className="mb-6">
<button
type="button"
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:underline"
>
Tillbaka till startsidan
</button>
</div>
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="font-serif text-3xl md:text-4xl font-semibold mb-2">
Mitt konto
</h1>
<p className="text-muted-foreground">{user?.email}</p>
</div>
{/* Profile Card */}
<div className="bg-card border border-border rounded-lg p-6 md:p-8 shadow-sm mb-6">
<h2 className="font-serif text-xl mb-6">Mina uppgifter</h2>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input
id="email"
type="email"
value={user?.email || ""}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Input
id="company_name"
type="text"
value={profile.company_name}
onChange={(e) =>
setProfile({ ...profile, company_name: e.target.value })
}
placeholder="Företagsnamn"
/>
</div>
<div className="space-y-4">
<Label>Leveransadress</Label>
<div className="space-y-2">
<Input
id="street_address"
type="text"
value={profile.street_address}
onChange={(e) =>
setProfile({ ...profile, street_address: e.target.value })
}
placeholder="Gatuadress"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Input
id="postal_code"
type="text"
value={profile.postal_code}
onChange={(e) =>
setProfile({ ...profile, postal_code: e.target.value })
}
placeholder="Postnummer"
className={getPostalCodeError(profile.postal_code) ? "border-destructive focus-visible:ring-destructive" : ""}
/>
{getPostalCodeError(profile.postal_code) && (
<p className="text-destructive text-xs">
{getPostalCodeError(profile.postal_code)}
</p>
)}
</div>
<div className="space-y-2">
<Input
id="city"
type="text"
value={profile.city}
onChange={(e) =>
setProfile({ ...profile, city: e.target.value })
}
placeholder="Ort"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Denna adress används vid beställning av abonnemang.
</p>
</div>
{hasChanges && (
<Button
type="submit"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
disabled={isSaving}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Sparar..." : "Spara ändringar"}
</Button>
)}
</form>
</div>
{/* Subscription Section */}
<div className="bg-card border border-border rounded-lg p-6 md:p-8 shadow-sm mb-6">
<div className="flex items-center gap-2 mb-6">
<Package className="h-5 w-5 text-primary" />
<h2 className="font-serif text-xl">Mitt abonnemang</h2>
</div>
{subscription ? (
<div className="space-y-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<div className="text-muted-foreground text-xs uppercase tracking-widest">
Ditt abonnemang
</div>
<div className="font-serif text-2xl font-semibold text-foreground">
{subscription.kg_per_week ?? "—"} kg / vecka
</div>
{subscription.monthly_amount && (
<div className="text-muted-foreground text-sm">
{Math.round(subscription.monthly_amount * 1.25).toLocaleString("sv-SE")} kr/mån ink moms
{" · "}
{subscription.collection_method === "send_invoice" ? "Faktura" : "Kort"}
</div>
)}
</div>
<span
className={`text-xs px-2 py-1 rounded-sm border ${
subscription.cancel_at_period_end
? "border-destructive/40 text-destructive"
: subscription.status === "past_due"
? "border-amber-500/40 text-amber-700"
: "border-primary/40 text-primary"
}`}
>
{subscription.cancel_at_period_end
? "Avslutas vid periodens slut"
: subscription.status === "active"
? "Aktiv"
: subscription.status === "trialing"
? "Provperiod"
: subscription.status === "past_due"
? "Obetald Stripe försöker igen"
: subscription.status === "canceled"
? "Avslutad"
: subscription.status}
</span>
</div>
{subscription.current_period_end && (
<p className="text-xs text-muted-foreground">
{subscription.cancel_at_period_end || subscription.status === "canceled"
? "Sista leveransperiod till: "
: "Nästa förnyelse: "}
{new Date(subscription.current_period_end).toLocaleDateString("sv-SE")}
</p>
)}
<Button
onClick={() => navigate("/account/change-subscription")}
variant="outline"
className="w-full"
disabled={subscription.cancel_at_period_end || subscription.status === "canceled"}
>
<Package className="h-4 w-4 mr-2" />
Ändra kg/vecka
</Button>
<Button
onClick={handleManageSubscription}
disabled={openingPortal}
className="w-full"
>
<ExternalLink className="h-4 w-4 mr-2" />
{openingPortal ? "Öppnar kundportal..." : "Hantera abonnemang"}
</Button>
<p className="text-xs text-muted-foreground text-center">
Ändra kort, ladda ner fakturor eller säg upp tillgång kvar till periodens slut.
</p>
</div>
) : (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
Du har för närvarande inget aktivt abonnemang.
</p>
<Button
variant="outline"
onClick={() => {
navigate("/");
setTimeout(() => {
const element = document.getElementById("abonnemang");
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}, 100);
}}
className="w-full"
>
Beställ abonnemang
</Button>
</div>
)}
</div>
<div className="text-center mt-6">
<button
type="button"
onClick={handleSignOut}
className="text-sm text-destructive hover:underline"
>
Logga ut
</button>
</div>
</div>
</div>
</div>
);
};
export default Account;
@@ -0,0 +1,167 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { CheckCircle, Package, User } from "lucide-react";
import Header from "@/components/Header";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client";
interface ProfileData {
street_address: string;
postal_code: string;
city: string;
}
const AccountConfirmation = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading: authLoading } = useAuth();
const [profile, setProfile] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Check if user just saved their profile
const justSaved = location.state?.justSaved === true;
useEffect(() => {
if (!authLoading && !user) {
navigate("/");
return;
}
if (user) {
fetchProfile();
}
}, [user, authLoading, navigate]);
const fetchProfile = async () => {
try {
const { data } = await supabase
.from("profiles")
.select("street_address, postal_code, city")
.eq("user_id", user!.id)
.maybeSingle();
if (data) {
setProfile({
street_address: data.street_address || "",
postal_code: data.postal_code || "",
city: data.city || "",
});
}
} finally {
setIsLoading(false);
}
};
if (authLoading || isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 flex items-center justify-center">
<p className="text-muted-foreground">Laddar...</p>
</div>
</div>
);
}
const hasAddress = profile && (profile.street_address || profile.postal_code || profile.city);
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 px-4 pb-12">
{/* Back link */}
<div className="mb-6">
<button
type="button"
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:underline"
>
Tillbaka till startsidan
</button>
</div>
<div className="max-w-2xl mx-auto">
{/* Header section */}
<div className="bg-card border border-border rounded-lg p-6 md:p-8 shadow-sm mb-6">
<div className="flex items-center gap-4 mb-4">
{justSaved ? (
<CheckCircle className="h-8 w-8 text-primary flex-shrink-0" />
) : (
<User className="h-8 w-8 text-primary flex-shrink-0" />
)}
<div>
<h1 className="font-serif text-2xl font-semibold">
{justSaved ? "Uppgifter sparade!" : "Mitt konto"}
</h1>
{justSaved && (
<p className="text-muted-foreground text-sm">
Dina kontuppgifter har uppdaterats.
</p>
)}
{!justSaved && user?.email && (
<p className="text-muted-foreground text-sm">
{user.email}
</p>
)}
</div>
</div>
{hasAddress && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm font-medium mb-2">Leveransadress:</p>
<p className="text-muted-foreground text-sm">
{profile.street_address && <span>{profile.street_address}<br /></span>}
{(profile.postal_code || profile.city) && (
<span>{profile.postal_code} {profile.city}</span>
)}
</p>
</div>
)}
<div className="mt-4 pt-4 border-t border-border">
<Button
variant="outline"
onClick={() => navigate("/account")}
className="w-full"
>
Redigera uppgifter
</Button>
</div>
</div>
{/* Subscription status */}
<div className="bg-card border border-border rounded-lg p-6 md:p-8 shadow-sm mb-6">
<div className="flex items-center gap-3 mb-4">
<Package className="h-6 w-6 text-primary" />
<h2 className="font-serif text-xl font-semibold">Mitt abonnemang</h2>
</div>
<div className="bg-muted/50 rounded-lg p-4 mb-6">
<p className="text-muted-foreground text-sm">
Du har inget aktivt abonnemang just nu.
</p>
</div>
<Button
onClick={() => {
navigate("/");
setTimeout(() => {
const element = document.getElementById("abonnemang");
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}, 100);
}}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
>
Beställ abonnemang
</Button>
</div>
</div>
</div>
</div>
);
};
export default AccountConfirmation;
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { AdminStats } from "@/components/admin/AdminStats";
import { OrdersTable } from "@/components/admin/OrdersTable";
import { CustomersTable } from "@/components/admin/CustomersTable";
import { PaymentsTable } from "@/components/admin/PaymentsTable";
import { LeadsGenerator } from "@/components/admin/LeadsGenerator";
import AuthDialog from "@/components/AuthDialog";
import { Package, Users, CreditCard, LogOut, ArrowLeft, Sparkles, Lock } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
type AuthState = "loading" | "signed_out" | "not_admin" | "admin";
export default function Admin() {
const navigate = useNavigate();
const [state, setState] = useState<AuthState>("loading");
const [authOpen, setAuthOpen] = useState(false);
const [authMode, setAuthMode] = useState<"login" | "signup">("login");
useEffect(() => {
const check = async (session: any) => {
if (!session?.user) {
setState("signed_out");
return;
}
const { data, error } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", session.user.id)
.eq("role", "admin")
.maybeSingle();
if (error) console.error("Role check failed:", error);
setState(data ? "admin" : "not_admin");
};
const { data: { subscription } } = supabase.auth.onAuthStateChange((_e, session) => {
check(session);
});
supabase.auth.getSession().then(({ data }) => check(data.session));
return () => subscription.unsubscribe();
}, []);
const handleLogout = async () => {
await supabase.auth.signOut();
};
if (state === "loading") {
return (
<div className="min-h-screen bg-background p-8">
<div className="max-w-7xl mx-auto space-y-8">
<Skeleton className="h-10 w-48" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-32" />)}
</div>
<Skeleton className="h-96" />
</div>
</div>
);
}
if (state === "signed_out") {
return (
<>
<div className="min-h-screen flex items-center justify-center bg-background px-6">
<div className="max-w-md text-center space-y-6">
<Lock className="h-12 w-12 mx-auto text-primary" />
<h1 className="text-3xl font-serif">Admin-inloggning krävs</h1>
<p className="text-muted-foreground">
Denna sida är privat. Logga in med ditt admin-konto för att se ordrar och annan intern information.
</p>
<div className="flex gap-3 justify-center">
<Button onClick={() => setAuthOpen(true)}>Logga in</Button>
<Button variant="outline" onClick={() => navigate("/")}>Till startsidan</Button>
</div>
</div>
</div>
<AuthDialog open={authOpen} onOpenChange={setAuthOpen} mode={authMode} onModeChange={setAuthMode} />
</>
);
}
if (state === "not_admin") {
return (
<div className="min-h-screen flex items-center justify-center bg-background px-6">
<div className="max-w-md text-center space-y-6">
<Lock className="h-12 w-12 mx-auto text-destructive" />
<h1 className="text-3xl font-serif">Ingen behörighet</h1>
<p className="text-muted-foreground">
Ditt konto har inte tillgång till admin-sidan. Endast ägaren kan se den här sidan.
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={handleLogout}>Logga ut</Button>
<Button onClick={() => navigate("/")}>Till startsidan</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<header className="border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/")}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
</div>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="h-4 w-4 mr-2" />
Logga ut
</Button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="space-y-8">
<AdminStats />
<Tabs defaultValue="orders" className="space-y-6">
<TabsList>
<TabsTrigger value="orders" className="gap-2">
<Package className="h-4 w-4" />
Ordrar
</TabsTrigger>
<TabsTrigger value="customers" className="gap-2">
<Users className="h-4 w-4" />
Kunder
</TabsTrigger>
<TabsTrigger value="payments" className="gap-2">
<CreditCard className="h-4 w-4" />
Betalningar
</TabsTrigger>
<TabsTrigger value="leads" className="gap-2">
<Sparkles className="h-4 w-4" />
AI Leads
</TabsTrigger>
</TabsList>
<TabsContent value="orders">
<OrdersTable />
</TabsContent>
<TabsContent value="customers">
<CustomersTable />
</TabsContent>
<TabsContent value="payments">
<PaymentsTable />
</TabsContent>
<TabsContent value="leads">
<LeadsGenerator />
</TabsContent>
</Tabs>
</div>
</main>
</div>
);
}
@@ -0,0 +1,216 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
import Header from "@/components/Header";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Loader2 } from "lucide-react";
const PRICE_PER_KG_EXKL = 325;
const VAT_RATE = 0.25;
const PRICE_PER_KG_INK = PRICE_PER_KG_EXKL * (1 + VAT_RATE);
const WEEKS_PER_MONTH = 4;
const ChangeSubscription = () => {
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const { toast } = useToast();
const [currentKg, setCurrentKg] = useState<number | null>(null);
const [newKg, setNewKg] = useState<number>(10);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!authLoading && !user) navigate("/");
}, [authLoading, user, navigate]);
useEffect(() => {
(async () => {
if (!user) return;
const env = hasPaymentsConfigured() ? getStripeEnvironment() : "sandbox";
const { data } = await supabase
.from("subscriptions")
.select("kg_per_week, status")
.eq("user_id", user.id)
.eq("environment", env)
.in("status", ["active", "trialing", "past_due"])
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (!data) {
toast({
title: "Inget aktivt abonnemang",
description: "Du behöver ett aktivt abonnemang för att ändra mängd.",
variant: "destructive",
});
navigate("/account");
return;
}
const kg = Number(data.kg_per_week ?? 10);
setCurrentKg(kg);
setNewKg(kg);
setLoading(false);
})();
}, [user, navigate, toast]);
const newMonthlyInk = useMemo(
() => newKg * WEEKS_PER_MONTH * PRICE_PER_KG_INK,
[newKg],
);
const currentMonthlyInk = useMemo(
() => (currentKg ?? 0) * WEEKS_PER_MONTH * PRICE_PER_KG_INK,
[currentKg],
);
const diff = newMonthlyInk - currentMonthlyInk;
const unchanged = currentKg !== null && newKg === currentKg;
const handleSave = async () => {
if (!hasPaymentsConfigured()) {
toast({ title: "Betalningar inte konfigurerade", variant: "destructive" });
return;
}
setSaving(true);
try {
const { data, error } = await supabase.functions.invoke("update-subscription-quantity", {
body: { new_kg_per_week: newKg, environment: getStripeEnvironment() },
});
if (error) throw error;
if ((data as any)?.error) throw new Error((data as any).error);
toast({
title: "Abonnemang uppdaterat",
description: `Ny mängd: ${newKg} kg/vecka. Prisdifferens justeras på nästa faktura.`,
});
navigate("/account");
} catch (e: any) {
toast({
title: "Kunde inte uppdatera",
description: e?.message ?? String(e),
variant: "destructive",
});
} finally {
setSaving(false);
}
};
if (authLoading || loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 px-4 pb-16">
<div className="max-w-2xl mx-auto">
<button
type="button"
onClick={() => navigate("/account")}
className="text-sm text-muted-foreground hover:underline mb-6 flex items-center gap-1"
>
<ArrowLeft className="w-4 h-4" /> Tillbaka till mitt konto
</button>
<div className="text-center mb-8">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6" />
<h1 className="font-display text-3xl md:text-5xl font-semibold mb-2">
Ändra abonnemang
</h1>
<p className="text-muted-foreground font-display italic">
Justera antal kg kakor per vecka
</p>
</div>
<div className="bg-card border border-border rounded-sm p-6 md:p-10 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] space-y-8">
<div className="text-center">
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-2">
Ny mängd
</div>
<div
key={newKg}
className="font-display text-6xl md:text-7xl font-semibold text-primary tabular-nums animate-fade-in"
>
{newKg} kg
</div>
<div className="text-muted-foreground text-sm mt-1">per vecka</div>
</div>
<div>
<input
type="range"
min={1}
max={100}
step={1}
value={newKg}
onChange={(e) => setNewKg(parseInt(e.target.value, 10))}
className="calculator-slider w-full"
aria-label="Kg per vecka"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-3">
<span>1 kg</span>
<span>100 kg</span>
</div>
</div>
<div className="border-t border-border pt-6 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Nuvarande månadskostnad</span>
<span className="font-medium tabular-nums">
{Math.round(currentMonthlyInk).toLocaleString("sv-SE")} kr
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Ny månadskostnad</span>
<span className="font-medium tabular-nums text-primary">
{Math.round(newMonthlyInk).toLocaleString("sv-SE")} kr
</span>
</div>
{!unchanged && (
<div className="flex justify-between text-sm border-t border-border pt-3">
<span className="text-muted-foreground">Skillnad</span>
<span
className={`font-semibold tabular-nums ${
diff > 0 ? "text-foreground" : "text-primary"
}`}
>
{diff > 0 ? "+" : ""}
{Math.round(diff).toLocaleString("sv-SE")} kr/mån
</span>
</div>
)}
<p className="text-xs text-muted-foreground text-center pt-2">
ink moms · prisdifferens proportioneras nästa faktura
</p>
</div>
<Button
onClick={handleSave}
disabled={saving || unchanged}
className="w-full py-6 text-base"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Uppdaterar...
</>
) : unchanged ? (
"Ingen ändring gjord"
) : (
`Bekräfta ${newKg} kg/vecka`
)}
</Button>
</div>
</div>
</div>
</div>
);
};
export default ChangeSubscription;
+168
View File
@@ -0,0 +1,168 @@
import { useState } from "react";
import { z } from "zod";
import { useNavigate } from "react-router-dom";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { Send, ArrowLeft } from "lucide-react";
const contactSchema = z.object({
email: z.string().trim().email("Ogiltig e-postadress").max(255, "Max 255 tecken"),
company: z.string().trim().min(1, "Företag krävs").max(100, "Max 100 tecken"),
message: z.string().trim().min(1, "Meddelande krävs").max(2000, "Max 2000 tecken"),
});
type ContactForm = z.infer<typeof contactSchema>;
const Contact = () => {
const navigate = useNavigate();
const [form, setForm] = useState<ContactForm>({
email: "",
company: "",
message: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validation = contactSchema.safeParse(form);
if (!validation.success) {
toast.error(validation.error.errors[0].message);
return;
}
setIsSubmitting(true);
try {
const { error } = await supabase.functions.invoke("send-contact-email", {
body: {
email: form.email,
company: form.company,
message: form.message,
},
});
if (error) {
throw error;
}
setIsSubmitted(true);
toast.success("Tack för ditt meddelande!");
} catch (error: any) {
console.error("Contact form error:", error);
toast.error("Något gick fel. Försök igen senare.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-background flex flex-col">
<Header />
<div className="px-6 pt-32">
<button
onClick={() => navigate("/")}
className="inline-flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft size={16} />
<span>Tillbaka till startsidan</span>
</button>
</div>
<main className="flex-1 pt-8 pb-16 px-4">
<div className="max-w-xl mx-auto">
{/* Header */}
<div className="text-center mb-10">
<div className="w-16 h-px bg-primary/40 mx-auto mb-6"></div>
<h1 className="font-display text-3xl md:text-4xl font-semibold mb-3">
Kontakta oss
</h1>
<p className="text-muted-foreground">
Har du frågor om våra abonnemang eller vill veta mer? Hör av dig!
</p>
</div>
{isSubmitted ? (
<div className="bg-card border border-border rounded-lg p-8 text-center">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Send className="w-8 h-8 text-primary" />
</div>
<h2 className="font-display text-2xl font-semibold mb-2">
Tack för ditt meddelande!
</h2>
<p className="text-muted-foreground mb-6">
Vi återkommer till dig snart som möjligt.
</p>
<Button onClick={() => navigate("/")} variant="outline">
Tillbaka till startsidan
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="bg-card border border-border rounded-lg p-6 md:p-8 space-y-5">
<div className="space-y-2">
<Label htmlFor="company">Företag *</Label>
<Input
id="company"
type="text"
value={form.company}
onChange={(e) => setForm({ ...form, company: e.target.value })}
placeholder="Företagsnamn"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-post *</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="din@epost.se"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="message">Meddelande *</Label>
<Textarea
id="message"
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
placeholder="Skriv ditt meddelande här..."
rows={5}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? (
"Skickar..."
) : (
<>
<Send className="w-4 h-4 mr-2" />
Skicka meddelande
</>
)}
</Button>
</form>
)}
</div>
</main>
<Footer />
</div>
);
};
export default Contact;
+70
View File
@@ -0,0 +1,70 @@
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
const faqs = [
{
question: "Vilka leveransområden har ni?",
answer: "Vi levererar till alla typer av företag i Storstockholm restauranger, caféer, konditorier, hotell, kontor och andra verksamheter som vill bjuda sina gäster och anställda på kvalitetsfika."
},
{
question: "Hur ofta sker leveranserna?",
answer: "Vi levererar en gång i veckan, samma dag varje vecka, så du alltid har färska bakverk till dina gäster och anställda."
},
{
question: "Vilken tid levererar ni?",
answer: "Vi levererar under dagtid. Har du särskilda önskemål om tid eller annat kan du ange det i din beställning eller kontakta oss via mejl."
},
{
question: "Kan jag ändra mitt abonnemang?",
answer: "Ja, du kan när som helst ändra eller pausa ditt abonnemang genom att logga in på Mitt konto."
}
];
const FAQ = () => {
return (
<div className="bg-background text-foreground min-h-screen">
<Header />
<div className="px-6 pt-32">
<Link
to="/"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft size={16} />
<span>Tillbaka till startsidan</span>
</Link>
</div>
<main className="max-w-3xl mx-auto px-6 pt-8 pb-16">
<h1 className="text-3xl md:text-4xl font-bold text-center mb-4">
Vanliga frågor
</h1>
<p className="text-muted-foreground text-center mb-10">
Här hittar du svar de vanligaste frågorna om våra produkter och tjänster.
</p>
<Accordion type="single" collapsible className="w-full">
{faqs.map((faq, index) => (
<AccordionItem key={index} value={`item-${index}`}>
<AccordionTrigger className="text-left">
{faq.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</main>
<Footer />
</div>
);
};
export default FAQ;
+21
View File
@@ -0,0 +1,21 @@
import Header from "@/components/Header";
import HeroSection from "@/components/HeroSection";
import HeritageSection from "@/components/HeritageSection";
import PlansSection from "@/components/PlansSection";
import DeliverySection from "@/components/DeliverySection";
import Footer from "@/components/Footer";
const Index = () => {
return (
<div className="bg-background text-foreground min-h-screen">
<Header />
<HeroSection />
<HeritageSection />
<PlansSection />
<DeliverySection />
<Footer />
</div>
);
};
export default Index;
+24
View File
@@ -0,0 +1,24 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;
@@ -0,0 +1,108 @@
import { useSearchParams, useNavigate } from "react-router-dom";
import { CheckCircle2, Truck, Mail, Calendar } from "lucide-react";
import Header from "@/components/Header";
import { Button } from "@/components/ui/button";
const OrderConfirmation = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const method = searchParams.get("method") ?? "card";
const kg = searchParams.get("kg");
const email = searchParams.get("email");
const sessionId = searchParams.get("session_id");
return (
<div className="min-h-screen bg-background">
<Header />
<main className="pt-28 md:pt-32 px-6 pb-20">
<div className="max-w-2xl mx-auto">
<div className="text-center mb-10 animate-fade-in">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
<CheckCircle2 className="w-11 h-11 text-primary" strokeWidth={1.5} />
</div>
<div className="w-24 h-px bg-primary/40 mx-auto mb-6" />
<h1 className="font-display text-4xl md:text-5xl font-semibold mb-4">
Tack för din beställning!
</h1>
<p className="font-display italic text-lg md:text-xl text-muted-foreground">
Goda saker är väg.
</p>
</div>
<div className="bg-card border border-border rounded-sm shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] p-6 md:p-10 space-y-6">
{kg && (
<div className="text-center pb-6 border-b border-border">
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-2">
Ditt abonnemang
</div>
<div className="font-display text-3xl md:text-4xl font-semibold text-primary tabular-nums">
{kg} kg kakor / vecka
</div>
</div>
)}
<div className="flex items-start gap-4">
<Truck className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<div className="font-display text-lg font-semibold mb-1">Leverans varje tisdag</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Vi levererar färska kakor till er adress inom Storstockholm varje tisdag
morgon. Första leveransen kommer inom kort.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Calendar className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<div className="font-display text-lg font-semibold mb-1">
{method === "invoice" ? "Faktura varje månad" : "Månatlig kortdragning"}
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{method === "invoice"
? "Faktura skickas till er e-post varje månad med 30 dagars betalningsvillkor."
: "Beloppet dras automatiskt från ert kort en gång i månaden. Du kan säga upp när som helst."}
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Mail className="w-6 h-6 text-primary flex-shrink-0 mt-0.5" strokeWidth={1.5} />
<div>
<div className="font-display text-lg font-semibold mb-1">Orderbekräftelse</div>
<p className="text-sm text-muted-foreground leading-relaxed">
En bekräftelse{email ? <> skickas till <strong className="text-foreground">{email}</strong></> : " skickas till din e-post"} inom kort.
</p>
</div>
</div>
{sessionId && (
<div className="pt-4 border-t border-border text-center">
<p className="text-xs text-muted-foreground font-mono break-all">
Referens: {sessionId}
</p>
</div>
)}
</div>
<div className="text-center mt-10">
<Button
onClick={() => navigate("/")}
variant="outline"
className="font-display"
>
Tillbaka till startsidan
</Button>
</div>
<p className="text-center text-sm text-muted-foreground mt-8 italic font-display">
Äkta svenskt konditorhantverk
</p>
</div>
</main>
</div>
);
};
export default OrderConfirmation;
+186
View File
@@ -0,0 +1,186 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { supabase } from "@/integrations/supabase/client";
import Header from "@/components/Header";
const profileSchema = z.object({
phone: z.string().trim().max(20, { message: "Telefonnummer får max vara 20 tecken" }).optional(),
address: z.string().trim().max(500, { message: "Adress får max vara 500 tecken" }).optional(),
});
interface ProfileData {
phone: string;
address: string;
}
const Profile = () => {
const [profile, setProfile] = useState<ProfileData>({
phone: "",
address: "",
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const { user, loading: authLoading } = useAuth();
const { toast } = useToast();
const navigate = useNavigate();
useEffect(() => {
if (!authLoading && !user) {
navigate("/");
return;
}
if (user) {
fetchProfile();
}
}, [user, authLoading, navigate]);
const fetchProfile = async () => {
try {
const { data, error } = await supabase
.from("profiles")
.select("phone, address")
.eq("user_id", user!.id)
.maybeSingle();
if (error) {
console.error("Error fetching profile:", error);
toast({
title: "Kunde inte hämta profil",
description: error.message,
variant: "destructive",
});
} else if (data) {
setProfile({
phone: data.phone || "",
address: data.address || "",
});
}
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validation = profileSchema.safeParse(profile);
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
const { error } = await supabase
.from("profiles")
.update({
phone: profile.phone || null,
address: profile.address || null,
})
.eq("user_id", user!.id);
if (error) {
toast({
title: "Kunde inte spara profil",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Profil sparad!",
description: "Dina uppgifter har uppdaterats.",
});
}
} finally {
setIsSaving(false);
}
};
if (authLoading || isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 flex items-center justify-center">
<p className="text-muted-foreground">Laddar...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<div className="pt-24 px-4 pb-12">
<div className="max-w-lg mx-auto">
<div className="bg-card border border-border rounded-lg p-8 shadow-sm">
<h1 className="font-serif text-2xl text-center mb-2">Min profil</h1>
<p className="text-muted-foreground text-center text-sm mb-6">
{user?.email}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input
id="phone"
type="tel"
value={profile.phone}
onChange={(e) =>
setProfile({ ...profile, phone: e.target.value })
}
placeholder="070-123 45 67"
/>
</div>
<div className="space-y-2">
<Label htmlFor="address">Leveransadress</Label>
<Textarea
id="address"
value={profile.address}
onChange={(e) =>
setProfile({ ...profile, address: e.target.value })
}
placeholder="Gatuadress, postnummer, ort"
rows={3}
/>
</div>
<Button
type="submit"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
disabled={isSaving}
>
{isSaving ? "Sparar..." : "Spara ändringar"}
</Button>
</form>
<div className="text-center mt-6">
<button
type="button"
onClick={() => navigate("/")}
className="text-sm text-primary hover:underline"
>
Tillbaka till startsidan
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default Profile;
+139
View File
@@ -0,0 +1,139 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
const passwordSchema = z.object({
password: z.string().min(6, { message: "Lösenordet måste vara minst 6 tecken" }).max(100),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Lösenorden matchar inte",
path: ["confirmPassword"],
});
const ResetPassword = () => {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isReady, setIsReady] = useState(false);
const { updatePassword, session } = useAuth();
const { toast } = useToast();
const navigate = useNavigate();
useEffect(() => {
// Wait for session to be available (user clicked email link)
if (session) {
setIsReady(true);
}
}, [session]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validation = passwordSchema.safeParse({ password, confirmPassword });
if (!validation.success) {
toast({
title: "Fel",
description: validation.error.errors[0].message,
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const { error } = await updatePassword(password);
if (error) {
toast({
title: "Kunde inte uppdatera lösenord",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Lösenord uppdaterat!",
description: "Du kan nu logga in med ditt nya lösenord.",
});
navigate("/");
}
} finally {
setIsSubmitting(false);
}
};
if (!isReady) {
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="max-w-md w-full text-center">
<h1 className="font-serif text-2xl mb-4">Laddar...</h1>
<p className="text-muted-foreground">
Väntar verifiering av din återställningslänk.
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="max-w-md w-full">
<div className="bg-card border border-border rounded-lg p-8 shadow-sm">
<h1 className="font-serif text-2xl text-center mb-6">
Välj nytt lösenord
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Nytt lösenord</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Bekräfta lösenord</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<Button
type="submit"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
disabled={isSubmitting}
>
{isSubmitting ? "Uppdaterar..." : "Uppdatera lösenord"}
</Button>
</form>
<div className="text-center mt-4">
<button
type="button"
onClick={() => navigate("/")}
className="text-sm text-primary hover:underline"
>
Tillbaka till startsidan
</button>
</div>
</div>
</div>
</div>
);
};
export default ResetPassword;
@@ -0,0 +1,2433 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Arende, Handelse, TidKategori, Tillforlitlighet } from "@/felsokning/domain";
import { KUNDBESLUT_LABEL, KVALITETSKONTROLL_LABEL, TIDKATEGORI_LABEL, TILLFORLITLIGHET_LABEL, handelseRubrik } from "@/felsokning/domain";
import type { Metodik, NastaSteg } from "@/felsokning/metodik";
import { nastaSteg } from "@/felsokning/metodik";
import {
arAvslutat,
arendeidentitet,
brief,
felbeskrivning,
formateraTid,
foton,
videor,
lokalFordonshistorik,
objekt,
overlamningstext,
sistaAktivitet,
tidsfordelning,
tidsfordelningsRader,
type HistorikArende,
} from "@/felsokning/projektioner";
import { metodikForArende, useFelsokning } from "@/felsokning/store";
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk";
import {
aterkallaDelning,
hamtaDelningar,
plattformAktiv,
skapaDelning,
type Delning,
type DelningsNiva,
} from "@/felsokning/plattform";
import {
AI_RADTYP_LABEL,
fragaAi,
granskaUnderlag,
lasAvInstrument,
sammanfattaOverlamning,
type InstrumentTolkning,
} from "@/felsokning/ai";
import { byggDemoInstrument } from "@/felsokning/demo";
import {
ARENDETYPER,
ECM_VERSION,
MARKOR_FELBESKRIVNING_VERIFIERAD,
MARKOR_INGA_TIDIGA_OBSERVATIONER,
INGEN_ATGARD_ORSAKER,
KUNDKANALER,
MARKOR_TIDIGA_OBSERVATIONER_KLARA,
aktivtRegelpaket,
atgarder,
atgardsforslag,
arendetyp,
felorsaker,
granskaAvvikelse,
grindGodkand,
kvalitetsgrind,
hamtaRegelpaket,
kundbeslut,
kvalitetskontroll,
preDiagnostik,
reproducering,
reproduceringsText,
sparbarhetspaket,
underlagFinns,
UNDANTAGSORSAKER,
} from "@/felsokning/ecm";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { lasVideo, skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format";
import { IkonCheck, IkonKamera, IkonKryss, IkonLank, IkonPunkt, IkonSok, IkonVarning } from "@/felsokning/ikoner";
import { Bild, Klipp } from "@/felsokning/Bilagevisning";
import { bilageFalt } from "@/felsokning/bilagor";
const FLIKAR = [
{ id: "guide", label: "Guide" },
{ id: "logg", label: "Logg" },
{ id: "brief", label: "Brief" },
{ id: "rapport", label: "Rapport" },
] as const;
type Flik = (typeof FLIKAR)[number]["id"];
const SYNKSTATUS_LABEL: Record<SynkStatus, string> = {
lokal: "Lokalt läge",
synkar: "Synkar …",
synkad: "Synkad",
offline: "Offline",
};
// Synkar ärendet mot backend: direkt vid nya händelser och därefter var
// 15:e sekund (hämtar kollegors händelser). Utan inloggning: lokalt läge.
function useSynk(arende: Arende | undefined): SynkStatus {
const [status, setStatus] = useState<SynkStatus>("lokal");
const sammanfoga = useFelsokning((s) => s.sammanfoga);
const arendeId = arende?.id;
const antal = arende?.handelser.length ?? 0;
useEffect(() => {
if (!arende || !arendeId) return;
let aktiv = true;
const kor = async () => {
setStatus((s) => (s === "synkad" ? "synkar" : s));
const resultat = await synkroniseraArende(arende);
if (!aktiv) return;
setStatus(resultat.status);
if (resultat.handelser) sammanfoga(arendeId, resultat.handelser);
};
kor();
const timer = setInterval(kor, 15000);
return () => {
aktiv = false;
clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- antal driver omsynk vid nya händelser
}, [arendeId, antal, sammanfoga]);
return status;
}
export default function ArendeSida() {
const { id } = useParams<{ id: string }>();
const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined));
const laggTill = useFelsokning((s) => s.laggTill);
const [flik, setFlik] = useState<Flik>("guide");
const [nu, setNu] = useState(() => new Date().toISOString());
const synkStatus = useSynk(arende);
useEffect(() => {
const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
// ECM Knowledge Library: färskt regelpaket från plattformen (cache/
// standardpaket gäller offline).
hamtaRegelpaket();
return () => clearInterval(timer);
}, []);
const metodik = metodikForArende(arende);
if (!arende || !id) {
return (
<FelsokningSkal rubrik="Ärendet hittades inte" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<p className="text-[14px] text-[#333333]">Ärendet finns inte den här enheten.</p>
</FelsokningSkal>
);
}
const o = objekt(arende);
const avslutat = arAvslutat(arende);
const total = formateraTid(tidsfordelning(arende, nu).totalMs);
// Ett ställe flyttar bilagornas innehåll ut ur händelsen. Alla
// panelerna nedan skickar som förut med en inbäddad data-URL; här
// laddas den upp och händelsen får i stället en referens med
// innehållets hash. Misslyckas uppladdningen — eller finns ingen
// server, som i lokalt läge — sparas den inbäddat precis som förut.
// Dokumentationen får aldrig gå förlorad för att nätet ligger nere.
const skicka = (h: Handelse) => {
if (!("dataUrl" in h) || !h.dataUrl) {
laggTill(id, h);
return;
}
const { dataUrl, ...utan } = h;
void bilageFalt(id, dataUrl).then((bilaga) => laggTill(id, { ...utan, ...bilaga } as Handelse));
};
return (
<FelsokningSkal
bred
rubrik={`#${arende.nummer} ${o?.beskrivning ?? ""}`}
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
hoger={
<div className="text-right">
<p className="text-[13px] font-semibold text-white">{total}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#A9C3DE]">
{avslutat ? "Avslutat" : "Pågår"} · {SYNKSTATUS_LABEL[synkStatus]}
</p>
</div>
}
>
{/* Ärendeidentiteten: registreras en gång, alltid synlig — det ska
aldrig råda tvekan om vilket fordon och ärende som avses. */}
<IdentitetsRad arende={arende} skicka={skicka} />
{/* Klassisk trekolumnslayout på skrivbord: navigationsträd till
vänster, arbetsyta i mitten, kontextpanel till höger. På smala
skärmar: flikrad + en kolumn. */}
<nav className="mb-3 grid grid-cols-4 gap-1 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-1 lg:hidden">
{FLIKAR.map((f) => (
<button
key={f.id}
onClick={() => setFlik(f.id)}
className={`min-h-9 rounded text-[13px] font-semibold transition-colors ${
flik === f.id ? "bg-[#00437A] text-white" : "text-[#333333] hover:bg-[#E4E9EE]"
}`}
>
{f.label}
</button>
))}
</nav>
<div className="lg:grid lg:grid-cols-[210px_minmax(0,1fr)] lg:items-start lg:gap-3 xl:grid-cols-[210px_minmax(0,1fr)_300px]">
<aside className="sticky top-14 hidden lg:block print:hidden">
<VyTrad flik={flik} sattFlik={setFlik} arende={arende} metodik={metodik} />
</aside>
<div className="min-w-0">
{!avslutat && <InaktivitetsBanner arende={arende} nu={nu} skicka={skicka} />}
{flik === "guide" && (
<GuideFlik arende={arende} metodik={metodik} avslutat={avslutat} skicka={skicka} nu={nu} />
)}
{flik === "logg" && <LoggFlik arende={arende} />}
{flik === "brief" && <BriefFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
{flik === "rapport" && <RapportFlik arende={arende} metodik={metodik} nu={nu} skicka={skicka} />}
</div>
<aside className="sticky top-14 hidden xl:block print:hidden">
<KontextPanel arende={arende} metodik={metodik} nu={nu} synkStatus={synkStatus} />
</aside>
</div>
</FelsokningSkal>
);
}
// Nyckel/värde-rader för identitetspanelerna (rapport + delade vyer).
function identitetsRader(rader: [string, string | undefined][]) {
return rader
.filter(([, varde]) => varde)
.map(([etikett, varde]) => (
<div key={etikett} className="flex justify-between gap-2 border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
<span className="text-[#4A5560]">{etikett}</span>
<span className="text-right font-medium">{varde}</span>
</div>
));
}
// Ärendeidentiteten (Case Identity): kompakt rad med fordonsobjektet och
// ärendereferenserna — alltid synlig i arbetsytan. Ärendetypen väljs här
// och styr vilka dokumentationskrav ECM ställer.
function IdentitetsRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const idn = arendeidentitet(arende);
const typ = arendetyp(arende);
const del = (etikett: string, varde?: string) =>
varde ? (
<span className="whitespace-nowrap">
{etikett && <span className="text-[#707070]">{etikett} </span>}
<span className="font-semibold text-[#1A1A1A]">{varde}</span>
</span>
) : null;
return (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 rounded border border-[#C6C6C6] bg-[#F7F7F7] px-3 py-1.5 text-[12px] print:hidden">
{del("AO", idn.arbetsorder)}
{del("Claim", idn.claim)}
{del("Skadenr", idn.skadenummer)}
{del("", idn.beskrivning)}
{del("Regnr", idn.identifierare)}
{del("VIN", idn.vin)}
{del("Miltal", idn.miltal)}
{del("Ansvarig", idn.ansvarig)}
<label className="ml-auto flex items-center gap-1">
<span className="text-[#707070]">Ärendetyp</span>
<select
value={typ}
disabled={idn.avslutat}
onChange={(e) => skicka({ typ: "arendetyp_satt", arendetyp: e.target.value })}
className="rounded border border-[#ADADAD] bg-white px-1.5 py-0.5 text-[12px] focus:border-[#00437A] focus:outline-none"
>
{ARENDETYPER.map((t) => (
<option key={t}>{t}</option>
))}
</select>
</label>
</div>
);
}
// Mätarställning in/ut: instrumentpanelen fotograferas, bildtolkningen
// föreslår värdet och teknikern bekräftar. Fotot blir den officiella
// mätarställningen (E2-evidens). Undantag kräver dokumenterad orsak.
function MatarstallningSteg({ lage, skicka }: { lage: "ingaende" | "utgaende"; skicka: (h: Handelse) => void }) {
const filRef = useRef<HTMLInputElement>(null);
const [foto, setFoto] = useState<string | null>(null);
const [varde, setVarde] = useState("");
const [laser, setLaser] = useState(false);
const [demo, setDemo] = useState(false);
const fota = async (fil: File) => {
setLaser(true);
const dataUrl = await skalaNerFoto(fil);
setFoto(dataUrl);
try {
const res = await lasAvInstrument(dataUrl);
const km = res?.tolkning.varden.find((v) => /mätar|odo|km|mil/i.test(`${v.beskrivning} ${v.enhet ?? ""}`)) ?? res?.tolkning.varden[0];
if (km) setVarde(`${km.varde}${km.enhet ? ` ${km.enhet}` : ""}`);
if (!res) {
setDemo(true);
setVarde(lage === "ingaende" ? "84 320 km" : "84 512 km");
}
} catch {
// Avläsningen kunde inte nås — värdet anges manuellt.
}
setLaser(false);
};
return (
<div>
<input
ref={filRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const fil = e.target.files?.[0];
if (fil) fota(fil);
e.target.value = "";
}}
/>
{!foto && !laser && (
<StorKnapp variant="sekundar" onClick={() => filRef.current?.click()}>
<IkonKamera /> Fotografera instrumentpanelen
</StorKnapp>
)}
{laser && <p className="animate-pulse py-1 text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av mätarställningen </p>}
{foto && !laser && (
<div className="mt-2">
{demo && (
<p className="mb-2 rounded border border-[#E0C36A] bg-[#FFF8E1] p-1.5 text-[11px] font-semibold text-[#9A6700]">
Demo-avläsning bildtolkningen kräver inloggning. Kontrollera värdet.
</p>
)}
<img src={foto} alt="Instrumentpanel" className="mb-2 max-h-40 rounded border border-[#C6C6C6]" />
<TextFalt label={`Mätarställning (${lage === "ingaende" ? "ingående" : "utgående"})`} varde={varde} satt={setVarde} platshallare="T.ex. 84 320 km" />
<StorKnapp
disabled={!varde.trim()}
onClick={() => {
skicka({ typ: "matarstallning", lage, varde: varde.trim(), dataUrl: foto });
setFoto(null);
setVarde("");
}}
>
Spara mätarställning
</StorKnapp>
</div>
)}
<Undantag vidUndantag={(orsak) => skicka({ typ: "matarstallning", lage, varde: "", undantag: orsak })} />
</div>
);
}
// Fordonshistorik i pre-diagnostiken: tidigare ärenden på samma objekt
// hämtas automatiskt (plattformen i inloggat läge, lokala storen annars)
// med deras dokumenterade felorsaker. Teknikern kan koppla orsakskedjan
// till det aktuella ärendet med ett tryck.
function TidigareArenden({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const arenden = useFelsokning((s) => s.arenden);
const o = objekt(arende);
const [rader, setRader] = useState<HistorikArende[]>(() =>
o ? lokalFordonshistorik(arenden, o.identifierare, arende.id) : [],
);
const identifierare = o?.identifierare;
useEffect(() => {
if (!identifierare) return;
let aktiv = true;
(async () => {
const { plattformAktiv: aktivPlattform, plattformToken, hamtaFordonshistorik } = await import("@/felsokning/plattform");
if (!aktivPlattform() || !plattformToken()) return;
try {
const svar = await hamtaFordonshistorik(identifierare);
if (aktiv) {
setRader(
svar
.filter((r) => r.id !== arende.id)
.map((r) => ({ ...r, felbeskrivning: r.felbeskrivning ?? undefined, felorsaker: r.felorsaker ?? [] })),
);
}
} catch {
// Lokal historik gäller.
}
})();
return () => {
aktiv = false;
};
}, [identifierare, arende.id]);
if (rader.length === 0) return null;
return (
<div className="mb-2 rounded border border-[#C6C6C6] bg-white p-2">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
Tidigare ärenden {identifierare} granska innan historiken kvitteras
</p>
{rader.map((rad) => (
<div key={rad.id} className="border-b border-[#EBEBEB] py-1.5 text-[12px] last:border-0">
<p>
<span className="font-semibold">Ärende #{rad.nummer}</span> · {tidDatum(rad.skapad)} ·{" "}
{rad.avslutat ? "avslutat" : "pågående"}
{rad.felbeskrivning && <span className="text-[#4A5560]"> {rad.felbeskrivning}</span>}
</p>
{rad.felorsaker.map((f, i) => (
<p key={i} className="pl-3 text-[#4A5560]">
Felorsak: {f.avvikelse} ({f.orsaker.join(", ")})
</p>
))}
<button
className="mt-1 text-[11px] font-semibold text-[#00437A] underline-offset-2 hover:underline"
onClick={() =>
skicka({
typ: "kommentar",
text: `Orsakskedja: kopplat till tidigare ärende #${rad.nummer}${
rad.felorsaker[0] ? `${rad.felorsaker[0].avvikelse}` : rad.felbeskrivning ? `${rad.felbeskrivning}` : ""
}`,
})
}
>
+ Koppla till orsakskedjan
</button>
</div>
))}
</div>
);
}
// Pre-Diagnostic Validation: ingen felsökning påbörjas förrän
// grundkontrollerna är genomförda — eller dokumenterat motiverade.
function PreDiagnostikPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const rader = preDiagnostik(arende);
const rad = (id: string) => rader.find((r) => r.id === id)!;
const [historikVal, setHistorikVal] = useState<"" | "ja" | "nej">("");
const [historikText, setHistorikText] = useState("");
const fb = felbeskrivning(arende);
const harTidiga = arende.handelser.some((p) => p.handelse.typ === "observation" || p.handelse.typ === "foto");
return (
<Panel rubrik="Pre-diagnostik — innan felsökningen börjar">
{rader.map((r) => (
<p key={r.id} className="py-0.5 text-[13px]">
<span className={r.klar ? "text-[#1E6B34]" : "text-[#8B1A1A]"}>{r.klar ? <IkonCheck /> : "☐"}</span> {r.rubrik}
{r.varning && <span className="ml-1 text-[11px] font-semibold text-[#9A6700]"><IkonVarning /> {r.varning}</span>}
</p>
))}
{!rad("historik").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Har fordonets historik kontrollerats? (tidigare arbeten, återkommande fel, TSB, kampanjer)
</p>
<TidigareArenden arende={arende} skicka={skicka} />
<div className="mb-2 grid grid-cols-2 gap-2">
<StorKnapp variant={historikVal === "ja" ? "primar" : "sekundar"} onClick={() => setHistorikVal("ja")}>
Ja kontrollerad
</StorKnapp>
<StorKnapp variant={historikVal === "nej" ? "fara" : "sekundar"} onClick={() => setHistorikVal("nej")}>
Nej
</StorKnapp>
</div>
{historikVal && (
<>
<TextFalt
label={historikVal === "ja" ? "Relevanta tidigare arbeten (valfritt — orsakskedja)" : "Orsak till att historiken inte kontrollerats (obligatoriskt)"}
varde={historikText}
satt={setHistorikText}
platshallare={historikVal === "ja" ? "T.ex. Vattenpump bytt för 8 mån sedan — misstänkt läckage samma område" : ""}
rost
/>
<StorKnapp
disabled={historikVal === "nej" && !historikText.trim()}
onClick={() => {
skicka({ typ: "historik_kontrollerad", kontrollerad: historikVal === "ja", kommentar: historikText.trim() || undefined });
setHistorikVal("");
setHistorikText("");
}}
>
Dokumentera
</StorKnapp>
</>
)}
</div>
)}
{!rad("matarstallning_in").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Ingående mätarställning fotografera instrumentpanelen
</p>
<MatarstallningSteg lage="ingaende" skicka={skicka} />
</div>
)}
{!rad("felbeskrivning").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Är kundens felbeskrivning korrekt återgiven?
</p>
{fb && <p className="mb-2 text-[13px]">{fb}</p>}
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_FELBESKRIVNING_VERIFIERAD}.` })}>
Stämmer verifierad
</StorKnapp>
<p className="mt-1 text-[11px] text-[#707070]">
Ytterligare symptom dokumenteras som separata observationer blanda dem inte med kundens beskrivning.
</p>
</div>
)}
{!rad("tidiga_observationer").klar && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Något ytterligare vid mottagandet? (reparationsspår, modifieringar, skador, läckage, korrosion )
</p>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_INGA_TIDIGA_OBSERVATIONER}.` })}>
Inga ytterligare observationer
</StorKnapp>
<StorKnapp
variant="sekundar"
disabled={!harTidiga}
onClick={() => skicka({ typ: "kommentar", text: `${MARKOR_TIDIGA_OBSERVATIONER_KLARA}.` })}
>
Observationerna är dokumenterade
</StorKnapp>
</div>
<p className="mt-1 text-[11px] text-[#707070]">
Dokumentera med foto eller observation i panelen nedan knappen låses upp när något loggats.
</p>
</div>
)}
</Panel>
);
}
// Symptom Verification Protocol: kundens beskrivning blir aldrig ett
// konstaterat fel förrän den reproducerats — eller dokumenterats som ej
// reproducerbar med obligatorisk motivering.
function ReproduceringPanel({ skicka }: { skicka: (h: Handelse) => void }) {
const [status, setStatus] = useState<"" | "ja" | "delvis" | "nej">("");
const [text, setText] = useState("");
const etikett =
status === "ja"
? "Hur reproducerades felet? (förhållanden, hastighet, temperatur, belastning …)"
: status === "delvis"
? "Vad kunde respektive kunde inte reproduceras?"
: "Motivering (obligatorisk) — varför kunde felet inte reproduceras?";
return (
<Panel rubrik="Symptomverifiering — reproducering">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Har kundens fel kunnat reproduceras?
</p>
<div className="mb-2 grid grid-cols-3 gap-2">
{(["ja", "delvis", "nej"] as const).map((val) => (
<StorKnapp key={val} variant={status === val ? "primar" : "sekundar"} onClick={() => setStatus(val)}>
{val === "ja" ? "Ja" : val === "delvis" ? "Delvis" : "Nej"}
</StorKnapp>
))}
</div>
{status && (
<>
<TextFalt label={etikett} varde={text} satt={setText} flerRad rost
platshallare={status === "ja" ? "T.ex. Reproducerad tre gånger vid 92108 km/h på plan väg, varm motor" : ""} />
<StorKnapp
disabled={!text.trim()}
onClick={() => {
skicka({ typ: "reproducering", status, beskrivning: text.trim() });
setStatus("");
setText("");
}}
>
Dokumentera reproducering
</StorKnapp>
</>
)}
</Panel>
);
}
// Felorsaksanalys (Root Cause Analysis): vad är avvikelsen, varför har
// den uppstått, vilket underlag stöder bedömningen och hur säker är den.
// Kvalitetsregeln avvisar generella formuleringar ("trasig", "defekt")
// och evidenskällor som inte finns i loggen.
function FelorsaksPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const dokumenterade = felorsaker(arende);
const [oppen, setOppen] = useState(false);
const [avvikelse, setAvvikelse] = useState("");
const [orsaker, setOrsaker] = useState<string[]>([]);
const [underlag, setUnderlag] = useState<string[]>([]);
const [sakerhet, setSakerhet] = useState<Tillforlitlighet>("hog");
const [atgard, setAtgard] = useState("");
const [motivering, setMotivering] = useState("");
const [ytterligare, setYtterligare] = useState("");
const [fel, setFel] = useState("");
const vaxla = (lista: string[], satt: (v: string[]) => void, val: string) =>
satt(lista.includes(val) ? lista.filter((v) => v !== val) : [...lista, val]);
const spara = () => {
const avvikelseFel = granskaAvvikelse(avvikelse);
if (avvikelseFel) return setFel(avvikelseFel);
if (orsaker.length === 0) return setFel("Välj minst en orsakskategori.");
if (underlag.length === 0) return setFel("Koppla minst en evidenskälla till bedömningen.");
const saknat = underlag.find((k) => !underlagFinns(arende, k));
if (saknat) return setFel(`Underlaget ”${saknat}” finns inte i ärendets logg — dokumentera det först eller välj en annan källa.`);
if (orsaker.includes("Okänd orsak") && !motivering.trim())
return setFel("Okänd orsak kräver en motivering till varför orsaken inte kunnat fastställas.");
if (sakerhet !== "hog" && !ytterligare.trim())
return setFel("Vid medel/låg säkerhet: ange vilka ytterligare kontroller som skulle stärka bedömningen.");
if (!atgard.trim()) return setFel("Ange rekommenderad åtgärd.");
skicka({
typ: "felorsak",
avvikelse: avvikelse.trim(),
orsaker,
underlag,
sakerhet,
atgard: atgard.trim(),
motivering: motivering.trim() || undefined,
ytterligareKontroller: ytterligare.trim() || undefined,
});
setOppen(false);
setAvvikelse("");
setOrsaker([]);
setUnderlag([]);
setSakerhet("hog");
setAtgard("");
setMotivering("");
setYtterligare("");
setFel("");
};
return (
<Panel rubrik="Felorsaksanalys — obligatorisk före avslut">
{dokumenterade.map((p) => {
const h = p.handelse;
if (h.typ !== "felorsak") return null;
return (
<div key={p.id} className="mb-2 border-b border-[#EBEBEB] pb-2 text-[13px] last:border-0">
<p className="font-semibold">{h.avvikelse}</p>
<p className="text-[#4A5560]">
Orsak: {h.orsaker.join(", ")} · Underlag: {h.underlag.join(", ")} · Säkerhet: {TILLFORLITLIGHET_LABEL[h.sakerhet]}
</p>
<p>Åtgärd: {h.atgard}</p>
</div>
);
})}
{!oppen ? (
<StorKnapp variant="sekundar" onClick={() => setOppen(true)}>
+ Dokumentera felorsak
</StorKnapp>
) : (
<div>
<TextFalt
label="1. Konstaterad avvikelse (inte bara komponenten)"
varde={avvikelse}
satt={setAvvikelse}
flerRad
rost
platshallare="T.ex. Startmotorn aktiverar inte trots korrekt matningsspänning och god jordförbindelse."
/>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">2. Mest sannolik orsak (en eller flera)</p>
<div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-3">
{aktivtRegelpaket().orsakskategorier.map((o) => (
<button
key={o}
onClick={() => vaxla(orsaker, setOrsaker, o)}
className={`min-h-8 rounded border px-1.5 text-[12px] font-medium ${
orsaker.includes(o) ? "border-[#00437A] bg-[#D6E4F2] text-[#00437A]" : "border-[#C6C6C6] bg-white text-[#333333]"
}`}
>
{o}
</button>
))}
</div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">3. Underlag som stöder bedömningen</p>
<div className="mb-3 grid grid-cols-2 gap-1 sm:grid-cols-4">
{aktivtRegelpaket().underlagskallor.map((k) => (
<button
key={k}
onClick={() => vaxla(underlag, setUnderlag, k)}
className={`min-h-8 rounded border px-1.5 text-[12px] font-medium ${
underlag.includes(k) ? "border-[#00437A] bg-[#D6E4F2] text-[#00437A]" : "border-[#C6C6C6] bg-white text-[#333333]"
}`}
>
{k}
</button>
))}
</div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">4. Säkerhet i bedömningen</p>
<div className="mb-3 grid grid-cols-3 gap-2">
{(["hog", "medel", "lag"] as const).map((n) => (
<button
key={n}
onClick={() => setSakerhet(n)}
className={`min-h-9 rounded border text-[12px] font-semibold ${
sakerhet === n ? "border-[#00437A] bg-[#00437A] text-white" : "border-[#ADADAD] bg-white text-[#333333]"
}`}
>
{TILLFORLITLIGHET_LABEL[n]}
</button>
))}
</div>
{sakerhet !== "hog" && (
<TextFalt
label="Vilka ytterligare kontroller skulle stärka bedömningen?"
varde={ytterligare}
satt={setYtterligare}
rost
/>
)}
{orsaker.includes("Okänd orsak") && (
<TextFalt
label="Motivering — varför kunde orsaken inte fastställas?"
varde={motivering}
satt={setMotivering}
flerRad
rost
/>
)}
<TextFalt label="Rekommenderad åtgärd" varde={atgard} satt={setAtgard} rost platshallare="T.ex. Balansera framhjulen och genomför ny provkörning." />
{fel && <p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setOppen(false)}>
Avbryt
</StorKnapp>
<StorKnapp onClick={spara}>Spara felorsak</StorKnapp>
</div>
</div>
)}
</Panel>
);
}
// Åtgärdsfasen: reparationen dokumenteras (eller motiveras varför den
// uteblev) och kvalitetskontrollen sluter loopen som symptom-
// verifieringen öppnade — är symptomet faktiskt borta?
function AtgardsPanel({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const poster = atgarder(arende);
const utford = poster.some((p) => p.handelse.typ === "atgard_utford" && p.handelse.utford);
const kk = kvalitetskontroll(arende);
const repro = reproducering(arende);
const forslag = atgardsforslag(arende);
const beslut = kundbeslut(arende);
const senasteFelorsak = felorsaker(arende).at(-1);
const foreslagenAtgard =
senasteFelorsak?.handelse.typ === "felorsak" ? senasteFelorsak.handelse.atgard : "";
const [forslagsLage, setForslagsLage] = useState(false);
const [forslagText, setForslagText] = useState("");
const [kostnad, setKostnad] = useState("");
const [beslutsVal, setBeslutsVal] = useState<"" | "godkant" | "avbojt" | "delvis">("");
const [kanal, setKanal] = useState("");
const [beslutsKommentar, setBeslutsKommentar] = useState("");
const [lage, setLage] = useState<"" | "utford" | "ingen">("");
const [beskrivning, setBeskrivning] = useState("");
const [delar, setDelar] = useState("");
const [orsak, setOrsak] = useState("");
const [kkResultat, setKkResultat] = useState<"" | "symptomet_borta" | "kvarstar" | "delvis" | "ej_verifierbar">("");
const [kkText, setKkText] = useState("");
return (
<Panel rubrik="Åtgärd och kvalitetskontroll">
{poster.map((p) => {
const h = p.handelse;
if (h.typ !== "atgard_utford") return null;
return (
<p key={p.id} className="border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
{h.utford ? (
<>
<span className="font-semibold">Utförd åtgärd:</span> {h.beskrivning}
{h.delar && <span className="text-[#4A5560]"> · Delar: {h.delar}</span>}
</>
) : (
<span className="text-[#9A6700]">Ingen åtgärd utförd {h.motivering}</span>
)}
</p>
);
})}
{kk && (
<p className={`py-1 text-[13px] font-semibold ${kk.resultat === "symptomet_borta" ? "text-[#1E6B34]" : "text-[#9A6700]"}`}>
Kvalitetskontroll: {KVALITETSKONTROLL_LABEL[kk.resultat]} <span className="font-normal">{kk.beskrivning}</span>
</p>
)}
{/* Kundkommunikation: förslaget lämnas för godkännande innan
arbetet påbörjas, och beskedet registreras med kanal. */}
{forslag.map((p) => {
const h = p.handelse;
if (h.typ !== "atgardsforslag") return null;
return (
<p key={p.id} className="border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
<span className="font-semibold">Åtgärdsförslag till kund:</span> {h.beskrivning}
{h.uppskattadKostnad && <span className="text-[#4A5560]"> · Uppskattad kostnad: {h.uppskattadKostnad}</span>}
</p>
);
})}
{beslut && (
<p className={`py-1 text-[13px] font-semibold ${beslut.beslut === "godkant" ? "text-[#1E6B34]" : beslut.beslut === "avbojt" ? "text-[#8B1A1A]" : "text-[#9A6700]"}`}>
Kundens besked ({beslut.kanal}): {KUNDBESLUT_LABEL[beslut.beslut]}
{beslut.kommentar && <span className="font-normal"> {beslut.kommentar}</span>}
</p>
)}
{forslag.length === 0 && !forslagsLage && poster.length === 0 && (
<StorKnapp
variant="sekundar"
className="mb-2"
onClick={() => {
setForslagsLage(true);
setForslagText(foreslagenAtgard);
}}
>
Lämna åtgärdsförslag till kund
</StorKnapp>
)}
{forslagsLage && (
<div className="mb-3 rounded border border-[#C6C6C6] bg-white p-2">
<TextFalt
label="Föreslagen åtgärd (visas för kunden i delningsvyn)"
varde={forslagText}
satt={setForslagText}
flerRad
rost
platshallare="T.ex. Balansera om höger framhjul och genomför ny provkörning."
/>
<TextFalt label="Uppskattad kostnad (valfritt)" varde={kostnad} satt={setKostnad} platshallare="T.ex. 1 200 kr inkl. moms" />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setForslagsLage(false)}>
Avbryt
</StorKnapp>
<StorKnapp
disabled={forslagText.trim().length < 10}
onClick={() => {
skicka({
typ: "atgardsforslag",
beskrivning: forslagText.trim(),
uppskattadKostnad: kostnad.trim() || undefined,
});
setForslagsLage(false);
setForslagText("");
setKostnad("");
}}
>
Lämna förslag
</StorKnapp>
</div>
</div>
)}
{forslag.length > 0 && !beslut && (
<div className="mb-3 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#9A6700]">
Registrera kundens besked innan arbetet påbörjas
</p>
<div className="mb-2 grid grid-cols-3 gap-2">
{(["godkant", "avbojt", "delvis"] as const).map((val) => (
<button
key={val}
onClick={() => setBeslutsVal(val)}
className={`min-h-9 rounded border text-[12px] font-semibold ${
beslutsVal === val ? "border-[#00437A] bg-[#00437A] text-white" : "border-[#ADADAD] bg-white text-[#333333]"
}`}
>
{KUNDBESLUT_LABEL[val]}
</button>
))}
</div>
{beslutsVal && (
<>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">Hur lämnades beskedet?</p>
<div className="mb-2 grid grid-cols-3 gap-1">
{KUNDKANALER.map((k) => (
<button
key={k}
onClick={() => setKanal(k)}
className={`min-h-8 rounded border px-1 text-[12px] font-medium ${
kanal === k ? "border-[#00437A] bg-[#D6E4F2] text-[#00437A]" : "border-[#C6C6C6] bg-white text-[#333333]"
}`}
>
{k}
</button>
))}
</div>
<TextFalt
label={beslutsVal === "godkant" ? "Kommentar (valfritt)" : "Kundens motivering (obligatorisk)"}
varde={beslutsKommentar}
satt={setBeslutsKommentar}
rost
/>
<StorKnapp
disabled={!kanal || (beslutsVal !== "godkant" && !beslutsKommentar.trim())}
onClick={() => {
skicka({
typ: "kundbeslut",
beslut: beslutsVal,
kanal,
kommentar: beslutsKommentar.trim() || undefined,
});
setBeslutsVal("");
setKanal("");
setBeslutsKommentar("");
}}
>
Registrera besked
</StorKnapp>
</>
)}
</div>
)}
{poster.length === 0 && !lage && (
<div className="grid grid-cols-2 gap-2">
<StorKnapp
variant="sekundar"
disabled={forslag.length > 0 && (!beslut || beslut.beslut === "avbojt")}
onClick={() => setLage("utford")}
>
Dokumentera utförd åtgärd
</StorKnapp>
<StorKnapp variant="sekundar" onClick={() => setLage("ingen")}>
Ingen åtgärd utförd
</StorKnapp>
</div>
)}
{lage === "utford" && (
<div className="mt-2">
<TextFalt
label="Vad gjordes? (kopplat till den dokumenterade felorsaken)"
varde={beskrivning}
satt={setBeskrivning}
flerRad
rost
platshallare="T.ex. Balanserade om höger framhjul, ny balansvikt 40 g på insidan."
/>
<TextFalt label="Delar (valfritt)" varde={delar} satt={setDelar} platshallare="T.ex. Balansvikter, artikelnr 30-1234" rost />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("")}>
Avbryt
</StorKnapp>
<StorKnapp
disabled={beskrivning.trim().length < 10}
onClick={() => {
skicka({ typ: "atgard_utford", beskrivning: beskrivning.trim(), delar: delar.trim() || undefined, utford: true });
setLage("");
setBeskrivning("");
setDelar("");
}}
>
Spara åtgärd
</StorKnapp>
</div>
</div>
)}
{lage === "ingen" && (
<div className="mt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Orsak till att ingen åtgärd utfördes (obligatorisk)
</p>
<div className="mb-2 grid gap-1">
{INGEN_ATGARD_ORSAKER.map((val) => (
<button
key={val}
onClick={() => setOrsak(val)}
className={`min-h-8 rounded border px-2 text-left text-[12px] font-medium ${
orsak === val ? "border-[#00437A] bg-[#D6E4F2]" : "border-[#C6C6C6] bg-white"
}`}
>
{val}
</button>
))}
</div>
<TextFalt label="Eller egen orsak" varde={orsak && !INGEN_ATGARD_ORSAKER.includes(orsak) ? orsak : ""} satt={setOrsak} rost />
{beslut?.beslut === "avbojt" && (
<p className="mb-2 text-[11px] text-[#707070]">
Kunden har avböjt åtgärden via {beslut.kanal} beskedet finns registrerat i loggen.
</p>
)}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("")}>
Avbryt
</StorKnapp>
<StorKnapp
disabled={!orsak.trim()}
onClick={() => {
skicka({ typ: "atgard_utford", beskrivning: "Ingen åtgärd utförd", utford: false, motivering: orsak.trim() });
setLage("");
setOrsak("");
}}
>
Dokumentera
</StorKnapp>
</div>
</div>
)}
{utford && !kk && (
<div className="mt-3 border-t border-[#DDDDDD] pt-2">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-[#4A5560]">
Kvalitetskontroll är symptomet borta efter åtgärden?
</p>
{repro?.status === "ja" && (
<p className="mb-2 text-[12px] text-[#707070]">
Symptomet reproducerades vid undersökningen återskapa samma förhållanden för att verifiera.
</p>
)}
<div className="mb-2 grid grid-cols-2 gap-2">
{(["symptomet_borta", "kvarstar", "delvis", "ej_verifierbar"] as const).map((val) => (
<button
key={val}
onClick={() => setKkResultat(val)}
className={`min-h-9 rounded border px-2 text-[12px] font-semibold ${
kkResultat === val ? "border-[#00437A] bg-[#00437A] text-white" : "border-[#ADADAD] bg-white text-[#333333]"
}`}
>
{KVALITETSKONTROLL_LABEL[val]}
</button>
))}
</div>
{kkResultat && (
<>
<TextFalt
label={
kkResultat === "symptomet_borta"
? "Hur verifierades det? (förhållanden, hastighet, provkörning …)"
: kkResultat === "ej_verifierbar"
? "Varför kunde det inte verifieras? (obligatorisk)"
: "Vad kvarstår? (obligatorisk)"
}
varde={kkText}
satt={setKkText}
flerRad
rost
platshallare="T.ex. Ny provkörning 80110 km/h på samma vägsträcka — ingen vibration."
/>
<StorKnapp
disabled={!kkText.trim()}
onClick={() => {
skicka({ typ: "kvalitetskontroll", resultat: kkResultat, beskrivning: kkText.trim() });
setKkResultat("");
setKkText("");
}}
>
Spara kvalitetskontroll
</StorKnapp>
</>
)}
</div>
)}
</Panel>
);
}
// Navigationsträd (vänsterkolumnen): ärendets vyer plus metodikens steg
// med status — som en mappstruktur i ett klassiskt verkstadssystem.
function VyTrad({
flik,
sattFlik,
arende,
metodik,
}: {
flik: Flik;
sattFlik: (f: Flik) => void;
arende: Arende;
metodik: Metodik;
}) {
const steg = nastaSteg(arende, metodik);
const aktuellIndex = metodik.steg.findIndex((s) => s.id === steg.steg.id);
return (
<nav className="overflow-hidden rounded border border-[#C6C6C6] bg-[#F7F7F7] shadow-[0_1px_2px_rgba(0,0,0,0.06)]">
<p className="border-b border-[#DDDDDD] bg-[#EFEFEF] px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-[#4A5560]">
Ärendevyer
</p>
{FLIKAR.map((f) => (
<button
key={f.id}
onClick={() => sattFlik(f.id)}
className={`block w-full border-l-2 px-3 py-1.5 text-left text-[13px] transition-colors ${
flik === f.id
? "border-[#00437A] bg-[#D6E4F2] font-semibold text-[#00437A]"
: "border-transparent text-[#333333] hover:bg-[#E4E9EE]"
}`}
>
{f.label}
</button>
))}
<p className="border-y border-[#DDDDDD] bg-[#EFEFEF] px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-[#4A5560]">
{metodik.namn}
</p>
{metodik.steg.map((s, i) => {
const klar = steg.klart || i < aktuellIndex;
const aktuell = !steg.klart && i === aktuellIndex;
return (
<p
key={s.id}
className={`px-3 py-1 text-[12px] ${
aktuell ? "bg-[#FFF8E1] font-semibold text-[#9A6700]" : klar ? "text-[#1E6B34]" : "text-[#707070]"
}`}
>
{klar ? "✓" : aktuell ? "▸" : "·"} {s.rubrik}
</p>
);
})}
</nav>
);
}
// Kontextpanel (högerkolumnen): teknisk information, tillförlitlighet och
// senaste tekniska rekommendation — alltid synlig på breda skärmar.
function KontextPanel({
arende,
metodik,
nu,
synkStatus,
}: {
arende: Arende;
metodik: Metodik;
nu: string;
synkStatus: SynkStatus;
}) {
const b = brief(arende, metodik, nu);
const senasteAi = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
const rad = (etikett: string, varde: string | undefined) =>
varde ? (
<div className="flex justify-between gap-2 border-b border-[#EBEBEB] py-1 text-[12px] last:border-0">
<span className="shrink-0 text-[#4A5560]">{etikett}</span>
<span className="text-right font-medium text-[#1A1A1A]">{varde}</span>
</div>
) : null;
return (
<div>
<Panel rubrik="Teknisk information">
{rad("Objekt", b.objekt?.beskrivning)}
{rad("Ident", b.objekt?.identifierare)}
{rad("Kund", b.objekt?.kund)}
{rad("Ansvarig", b.ansvarig)}
{rad("Status", b.avslutat ? "Avslutat" : "Pågår")}
{rad("Synk", SYNKSTATUS_LABEL[synkStatus])}
{rad("Arbetstid", b.totalArbetstid)}
</Panel>
<Panel rubrik="Tillförlitlighet">
{b.tillforlitlighet.map((r, i) => (
<p key={i} className="py-0.5 text-[12px]">
<NivaBadge niva={r.niva} /> {r.text}
</p>
))}
</Panel>
{!b.avslutat && b.rekommenderatNastaSteg.length > 0 && (
<Panel rubrik="Rekommenderat nästa steg">
{b.rekommenderatNastaSteg.map((s, i) => (
<p key={i} className="py-0.5 text-[12px] text-[#333333]">
{i + 1}. {s}
</p>
))}
</Panel>
)}
{senasteAi && senasteAi.handelse.typ === "ai_svar" && (
<Panel rubrik="Teknisk rekommendation">
{senasteAi.handelse.rader.map((r, i) => (
<p key={i} className="py-0.5 text-[12px]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[r.typ]}:</span> {r.text}
</p>
))}
<p className="mt-1 text-[12px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
</p>
<p className="mt-1 text-[10px] uppercase tracking-wide text-[#707070]">
Beslutsstöd · presenteras aldrig som konstaterat fel
</p>
</Panel>
)}
</div>
);
}
// Vid längre inaktivitet ber systemet om sammanhang — svaret blir en loggpost.
function InaktivitetsBanner({
arende,
nu,
skicka,
}: {
arende: Arende;
nu: string;
skicka: (h: Handelse) => void;
}) {
const [text, setText] = useState("");
const sista = sistaAktivitet(arende);
if (!sista) return null;
const minuter = Math.floor((new Date(nu).getTime() - new Date(sista.tidpunkt).getTime()) / 60000);
if (minuter < 20) return null;
return (
<div className="mb-4 rounded border border-[#00437A] bg-[#F7F7F7] p-4">
<p className="mb-2 text-[14px] font-semibold text-[#00437A]">
Ingen aktivitet har registrerats de senaste {minuter} minuterna.
</p>
<p className="mb-3 text-[#333333]">Beskriv kort vad som gjorts under denna period.</p>
<TextFalt label="Vad har gjorts?" varde={text} satt={setText} platshallare="T.ex. Demonterade instrumentpanelen för att komma åt kabelstammen." rost />
<StorKnapp
disabled={!text.trim()}
onClick={() => {
skicka({ typ: "inaktivitet_forklarad", text: text.trim(), minuter });
setText("");
}}
>
Lägg till i arbetsloggen
</StorKnapp>
</div>
);
}
function GuideFlik({
arende,
metodik,
avslutat,
skicka,
nu,
}: {
arende: Arende;
metodik: Metodik;
avslutat: boolean;
skicka: (h: Handelse) => void;
nu: string;
}) {
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
const anvandare = useFelsokning((s) => s.anvandare);
const [visaOverlamning, setVisaOverlamning] = useState(false);
const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar");
// AI-orkestern drivs av plattformen: handledningen (Sonnet 5) svarar på
// varje bekräftad dokumentation, klassificerat och loggat som händelse.
// I lokalt läge (ej inloggad) guidar den deterministiska metodiken ensam.
const fragaAiOmDokumentation = async (inmatning: string) => {
setAiStatus("arbetar");
try {
const svar = await fragaAi(brief(arende, metodik, nu), metodik.namn, inmatning);
if (svar) {
skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: svar.modell });
}
setAiStatus("vilar");
} catch {
setAiStatus("fel");
}
};
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);
const kanAvslutas =
!!reproducering(arende) &&
felorsaker(arende).length > 0 &&
atgardsposter.length > 0 &&
(!utfordAtgard || !!kvalitetskontroll(arende));
if (avslutat) {
return (
<Panel rubrik="Felsökningen är avslutad">
<p className="text-[14px] text-[#333333]">
Ärendet är låst för nya guidesteg. Loggen, briefen och rapporten finns kvar för spårbarhet och export.
</p>
</Panel>
);
}
// Pre-Diagnostic Validation: metodiken öppnas först när grund-
// kontrollerna är genomförda eller dokumenterat motiverade.
if (!preDiagnostik(arende).every((r) => r.klar)) {
return (
<>
<KategoriRad arende={arende} skicka={skicka} />
<PreDiagnostikPanel arende={arende} skicka={skicka} />
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
</>
);
}
return (
<>
<KategoriRad arende={arende} skicka={skicka} />
<Panel rubrik={`Metodik: ${metodik.namn} · Steg: ${steg.steg.rubrik}`}>
{steg.klart ? (
<>
<p className="mb-3 text-[15px] font-semibold">Samtliga steg i metodiken är dokumenterade.</p>
<p className="mb-4 text-[#333333]">
Om felorsaken inte är verifierad: dokumentera en hypotes och utöka felsökningen, eller avsluta ärendet
med rekommenderade nästa steg.
</p>
<StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}>
Avsluta felsökning
</StorKnapp>
{!kanAvslutas && (
<p className="mt-2 text-[12px] font-semibold text-[#9A6700]">
Avslut kräver symptomverifiering, felorsaksanalys, åtgärd och kvalitetskontroll se panelerna nedan.
</p>
)}
</>
) : steg.fraga ? (
<FrageKort key={`${steg.steg.id}/${steg.fraga.id}`} steg={steg} skicka={skicka} />
) : steg.kontroll ? (
<KontrollKort key={`${steg.steg.id}/${steg.kontroll.id}`} steg={steg} skicka={skicka} />
) : null}
</Panel>
{(aiStatus !== "vilar" || senasteAiSvar) && (
<Panel
rubrik={
senasteAiSvar?.handelse.typ === "ai_svar"
? `Handledning (${senasteAiSvar.handelse.modell})`
: "Handledning"
}
>
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">Systemet analyserar </p>}
{aiStatus === "fel" && (
<p className="mb-2 font-semibold text-[#8B1A1A]">Analysen kunde inte hämtas försök igen. Metodiken fortsätter som vanligt.</p>
)}
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
<>
{senasteAiSvar.handelse.rader.map((rad, i) => (
<p key={i} className="py-0.5 text-[14px]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
<p className="mt-2 text-[14px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span>{" "}
{senasteAiSvar.handelse.nastaSteg}
</p>
</>
)}
</Panel>
)}
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
{/* SVP: symptomet ska reproduceras — eller dokumenteras som ej
reproducerbart med motivering — innan ärendet kan avslutas. */}
{!reproducering(arende) && <ReproduceringPanel skicka={skicka} />}
{/* Felorsaksanalys: aldrig bara "komponent trasig, byt komponent" —
varje konstaterat fel kräver avvikelse, orsak, underlag och
säkerhetsnivå. Obligatorisk för avslut. */}
<FelorsaksPanel arende={arende} skicka={skicka} />
{/* Åtgärdsfasen: vad som gjordes — och kvalitetskontrollen som
verifierar att symptomet faktiskt är borta. */}
{felorsaker(arende).length > 0 && <AtgardsPanel arende={arende} skicka={skicka} />}
{/* Utgående mätarställning: obligatorisk för kvalitetsgrinden när
ärendet avslutas — erbjuds så fort metodiken är genomarbetad. */}
{!arende.handelser.some((p) => p.handelse.typ === "matarstallning" && p.handelse.lage === "utgaende") && (
<Panel rubrik="Utgående mätarställning — inför avslut">
<MatarstallningSteg lage="utgaende" skicka={skicka} />
</Panel>
)}
{!kanAvslutas && (
<p className="mb-2 text-[12px] font-semibold text-[#9A6700]">
Avslut kräver hela kedjan: symptomverifiering, felorsaksanalys, dokumenterad åtgärd och vid utförd åtgärd
kvalitetskontroll av att symptomet är borta.
</p>
)}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
Lämna över arbete
</StorKnapp>
<StorKnapp variant="sekundar" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}>
Avsluta felsökning
</StorKnapp>
</div>
{visaOverlamning && (
<OverlamningDialog arende={arende} metodik={metodik} nu={nu} skicka={skicka} stang={() => setVisaOverlamning(false)} />
)}
</>
);
}
function KategoriRad({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
let aktiv: TidKategori = "aktiv_felsokning";
for (const post of arende.handelser) {
if (post.handelse.typ === "kategori_byte") aktiv = post.handelse.kategori;
}
return (
<div className="mb-4 flex gap-2 overflow-x-auto pb-1">
{(Object.keys(TIDKATEGORI_LABEL) as TidKategori[]).map((k) => (
<button
key={k}
onClick={() => k !== aktiv && skicka({ typ: "kategori_byte", kategori: k })}
className={`whitespace-nowrap rounded border px-4 py-2 text-[12px] font-semibold transition-colors ${
k === aktiv
? "border-[#00437A] bg-[#00437A] text-white"
: "border-[#ADADAD] bg-[#F7F7F7] text-[#333333] hover:border-[#8FA8C0]"
}`}
>
{TIDKATEGORI_LABEL[k]}
</button>
))}
</div>
);
}
// En fråga i taget, stora svarsknappar.
function FrageKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) {
const fraga = steg.fraga!;
const [text, setText] = useState("");
const svara = (svar: string) =>
skicka({ typ: "fraga_besvarad", stegId: steg.steg.id, frageId: fraga.id, fraga: fraga.text, svar });
return (
<>
<p className="mb-4 text-[17px] font-semibold leading-snug">{fraga.text}</p>
{fraga.svarstyp === "janej" && (
<div className="grid grid-cols-2 gap-2">
<StorKnapp onClick={() => svara("Ja")}>Ja</StorKnapp>
<StorKnapp onClick={() => svara("Nej")}>Nej</StorKnapp>
</div>
)}
{fraga.svarstyp === "val" && (
<div className="grid gap-2">
{fraga.val!.map((v) => (
<StorKnapp key={v} variant="sekundar" onClick={() => svara(v)}>
{v}
</StorKnapp>
))}
</div>
)}
{fraga.svarstyp === "text" && (
<>
<TextFalt label="Svar" varde={text} satt={setText} rost />
<StorKnapp disabled={!text.trim()} onClick={() => svara(text.trim())}>
Spara svar
</StorKnapp>
</>
)}
</>
);
}
// Verifierad checklista: en kontrollpunkt är inte slutförd enbart genom en
// kryssruta — minimikravet (foto, mätvärde eller kort observation) styrs av
// kontrolltypen i metodiken.
function KontrollKort({ steg, skicka }: { steg: NastaSteg; skicka: (h: Handelse) => void }) {
const kontroll = steg.kontroll!;
const [resultat, setResultat] = useState("");
const filRef = useRef<HTMLInputElement>(null);
const krav = kontroll.krav;
const utford = () =>
skicka({
typ: "kontroll_utford",
stegId: steg.steg.id,
kontrollId: kontroll.id,
text: kontroll.text,
resultat: resultat.trim() || undefined,
});
const kravUppfyllt = krav === "foto" || !krav || resultat.trim().length > 0;
return (
<>
{steg.steg.beskrivning && <p className="mb-2 text-[#4A5560]">{steg.steg.beskrivning}</p>}
<p className="mb-4 text-[17px] font-semibold leading-snug">{kontroll.text}</p>
{krav === "foto" ? (
<>
<input
ref={filRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const fil = e.target.files?.[0];
if (!fil) return;
const dataUrl = await skalaNerFoto(fil);
skicka({ typ: "foto", beskrivning: kontroll.text, dataUrl });
utford();
}}
/>
<TextFalt label="Observation (valfritt)" varde={resultat} satt={setResultat} rost />
<StorKnapp onClick={() => filRef.current?.click()}><IkonKamera /> Ta foto verifierar kontrollen</StorKnapp>
<p className="mt-2 text-[12px] text-[#707070]">Denna kontroll verifieras med foto.</p>
</>
) : (
<>
<TextFalt
label={
krav === "matvarde"
? "Uppmätt värde (krävs för verifiering)"
: krav === "kommentar"
? "Vad observerades? (krävs för verifiering)"
: "Resultat / avläst värde (valfritt)"
}
varde={resultat}
satt={setResultat}
platshallare={krav === "matvarde" ? "T.ex. 2,4 bar samtliga hjul" : "T.ex. Säkringen är hel, spänning på båda sidor"}
rost
/>
{!kravUppfyllt && (
<p className="mb-3 text-[12px] font-semibold text-[#00437A]">
Ingen {krav === "matvarde" ? "mätning" : "observation"} har registrerats lägg till en kort{" "}
{krav === "matvarde" ? "mätuppgift" : "kommentar"} innan kontrollen kan verifieras.
</p>
)}
<StorKnapp disabled={!kravUppfyllt} onClick={utford}>
Markera verifierad
</StorKnapp>
</>
)}
<Undantag
vidUndantag={(orsak) =>
skicka({
typ: "kontroll_utford",
stegId: steg.steg.id,
kontrollId: kontroll.id,
text: kontroll.text,
undantag: orsak,
})
}
/>
</>
);
}
// ECM-regeln: en kontroll kan bara slutföras med evidens ELLER ett
// uttryckligt undantag — "underlaget kan inte tas fram" — där orsaken
// alltid är obligatorisk och loggas i händelseloggen.
function Undantag({ vidUndantag }: { vidUndantag: (orsak: string) => void }) {
const [oppen, setOppen] = useState(false);
const [orsak, setOrsak] = useState("");
if (!oppen) {
return (
<button
onClick={() => setOppen(true)}
className="mt-2 w-full py-1 text-[12px] font-medium text-[#707070] underline-offset-2 hover:text-[#8B1A1A] hover:underline print:hidden"
>
Underlag kan inte tas fram
</button>
);
}
return (
<div className="mt-3 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2">
<p className="mb-2 text-[12px] font-semibold text-[#9A6700]">
Kontrollen dokumenteras utan underlag ange orsak (obligatoriskt). Detta flaggas i brief och rapport.
</p>
<div className="mb-2 grid grid-cols-1 gap-1">
{aktivtRegelpaket().undantagsorsaker.map((val) => (
<button
key={val}
onClick={() => setOrsak(val)}
className={`min-h-8 rounded border px-2 text-left text-[12px] font-medium ${
orsak === val ? "border-[#00437A] bg-[#D6E4F2]" : "border-[#C6C6C6] bg-white"
}`}
>
{val}
</button>
))}
</div>
<TextFalt label="Eller egen orsak" varde={orsak && !UNDANTAGSORSAKER.includes(orsak) ? orsak : ""} satt={setOrsak} rost />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setOppen(false)}>
Avbryt
</StorKnapp>
<StorKnapp variant="fara" disabled={!orsak.trim()} onClick={() => vidUndantag(orsak.trim())}>
Dokumentera undantag
</StorKnapp>
</div>
</div>
);
}
const DOKTYPER = [
{ id: "observation", label: "Observation" },
{ id: "matvarde", label: "Mätvärde" },
{ id: "foto", label: "Foto" },
{ id: "video", label: "Video" },
{ id: "instrument", label: "Instrument" },
{ id: "hypotes", label: "Hypotes" },
{ id: "kommentar", label: "Kommentar" },
] as const;
// Fri dokumentation vid sidan av guiden. Hypoteser märks alltid som
// ej verifierade — de kan aldrig loggas som konstaterade fel.
// paSparad anropas med en textsammanfattning EFTER att användaren bekräftat
// med Spara — det är den texten AI:n får (aldrig obekräftad inmatning).
function SnabbDokumentation({
skicka,
paSparad,
}: {
skicka: (h: Handelse) => void;
paSparad?: (sammanfattning: string) => void;
}) {
const [typ, setTyp] = useState<(typeof DOKTYPER)[number]["id"] | null>(null);
const [text, setText] = useState("");
const [varde, setVarde] = useState("");
const filRef = useRef<HTMLInputElement>(null);
const instRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLInputElement>(null);
const [video, setVideo] = useState<{ dataUrl: string; beskrivning: string } | null>(null);
const [videoFel, setVideoFel] = useState("");
const [avlasning, setAvlasning] = useState<{ foto: string; tolkning: InstrumentTolkning; demo?: boolean } | null>(null);
const [laserAv, setLaserAv] = useState(false);
const aterstall = () => {
setTyp(null);
setText("");
setVarde("");
};
// Visual-first: instrumentet/skärmen fotograferas, bildtolkningen
// extraherar värdena och teknikern bekräftar innan något loggas.
// Originalbilden bevaras alltid som evidens bredvid de strukturerade
// värdena. I lokalt läge visas en tydligt märkt demo-avläsning.
const lasAv = async (fil: File) => {
setLaserAv(true);
const foto = await skalaNerFoto(fil);
try {
const resultat = await lasAvInstrument(foto);
if (resultat && resultat.tolkning.varden.length > 0) {
setAvlasning({ foto, tolkning: resultat.tolkning });
} else if (!resultat) {
setAvlasning({ foto, tolkning: byggDemoInstrument(), demo: true });
} else {
skicka({ typ: "foto", beskrivning: "Instrumentfoto — inga värden kunde läsas", dataUrl: foto });
}
} catch {
skicka({ typ: "foto", beskrivning: "Instrumentfoto — avläsningen kunde inte nås", dataUrl: foto });
}
setLaserAv(false);
};
const sparaAvlasning = () => {
if (!avlasning) return;
// Originalet först, sedan varje bekräftat värde som eget mätvärde —
// strukturerad data ersätter aldrig originalevidensen.
skicka({ typ: "foto", beskrivning: `Instrumentavläsning (${avlasning.tolkning.instrumenttyp})`, dataUrl: avlasning.foto });
for (const v of avlasning.tolkning.varden) {
skicka({ typ: "matvarde", beskrivning: v.beskrivning, varde: v.varde, enhet: v.enhet });
}
paSparad?.(
`Instrumentavläsning (${avlasning.tolkning.instrumenttyp}): ${avlasning.tolkning.varden
.map((v) => `${v.beskrivning} = ${v.varde}${v.enhet ? ` ${v.enhet}` : ""}`)
.join("; ")}`,
);
setAvlasning(null);
};
const spara = () => {
const t = text.trim();
if (typ === "observation" && t) {
skicka({ typ: "observation", text: t });
paSparad?.(`Observation: ${t}`);
}
if (typ === "kommentar" && t) {
skicka({ typ: "kommentar", text: t });
paSparad?.(t);
}
if (typ === "hypotes" && t) {
skicka({ typ: "hypotes", text: t, niva: "lag" });
paSparad?.(`Teknikerns hypotes (ej verifierad): ${t}`);
}
if (typ === "matvarde" && t && varde.trim()) {
skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
paSparad?.(`Mätvärde: ${t} = ${varde.trim()}`);
}
aterstall();
};
return (
<Panel rubrik="Dokumentera">
<div className="grid grid-cols-3 gap-2">
{DOKTYPER.map((d) => (
<button
key={d.id}
onClick={() =>
d.id === "foto"
? filRef.current?.click()
: d.id === "video"
? videoRef.current?.click()
: d.id === "instrument"
? instRef.current?.click()
: setTyp(typ === d.id ? null : d.id)
}
className={`min-h-9 rounded border text-[12px] font-semibold transition-colors ${
typ === d.id
? "border-[#00437A] bg-[#00437A] text-white"
: "border-[#ADADAD] bg-[#F7F7F7] text-[#333333] hover:border-[#8FA8C0]"
}`}
>
+ {d.label}
</button>
))}
</div>
<input
ref={filRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const fil = e.target.files?.[0];
if (!fil) return;
const dataUrl = await skalaNerFoto(fil);
skicka({ typ: "foto", beskrivning: "Foto", dataUrl });
e.target.value = "";
}}
/>
<input
ref={instRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const fil = e.target.files?.[0];
if (fil) lasAv(fil);
e.target.value = "";
}}
/>
<input
ref={videoRef}
type="file"
accept="video/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const fil = e.target.files?.[0];
e.target.value = "";
if (!fil) return;
setVideoFel("");
try {
setVideo({ dataUrl: await lasVideo(fil), beskrivning: "" });
} catch (misslyckande) {
setVideoFel(misslyckande instanceof Error ? misslyckande.message : "Videon kunde inte läsas.");
}
}}
/>
{videoFel && <p className="mt-2 text-[12px] font-semibold text-[#8B1A1A]">{videoFel}</p>}
{video && (
<div className="mt-3 rounded border border-[#C6C6C6] bg-white p-2">
<video src={video.dataUrl} controls className="mb-2 max-h-48 w-full rounded border border-[#C6C6C6]" />
<TextFalt
label="Vad visar videon? (obligatoriskt — det som låter eller rör sig)"
varde={video.beskrivning}
satt={(beskrivning) => setVideo((v) => (v ? { ...v, beskrivning } : v))}
platshallare="T.ex. Motorljud vid tomgång — tickande från topplocket"
rost
/>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setVideo(null)}>
Förkasta
</StorKnapp>
<StorKnapp
disabled={!video.beskrivning.trim()}
onClick={() => {
skicka({ typ: "video", beskrivning: video.beskrivning.trim(), dataUrl: video.dataUrl });
paSparad?.(`Video dokumenterad: ${video.beskrivning.trim()}`);
setVideo(null);
}}
>
Spara video
</StorKnapp>
</div>
</div>
)}
{laserAv && <p className="mt-2 animate-pulse text-center text-[12px] font-semibold text-[#00437A]">Systemet läser av instrumentet </p>}
{avlasning && (
<div className="mt-3 rounded border border-[#C6C6C6] bg-white p-2">
{avlasning.demo && (
<p className="mb-2 rounded border border-[#E0C36A] bg-[#FFF8E1] p-1.5 text-[11px] font-semibold text-[#9A6700]">
Demo-avläsning bildtolkningen kräver inloggning. Värdena är exempeldata.
</p>
)}
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
Avläst: {avlasning.tolkning.instrumenttyp} bekräfta innan något loggas
</p>
{avlasning.tolkning.varden.map((v, i) => (
<div key={i} className="flex items-center justify-between gap-2 border-b border-[#EBEBEB] py-1 text-[13px] last:border-0">
<span>
{v.beskrivning}: <span className="font-semibold">{v.varde}{v.enhet ? ` ${v.enhet}` : ""}</span>
</span>
<span className={`text-[11px] font-semibold ${v.konfidens >= 0.95 ? "text-[#1E6B34]" : v.konfidens >= 0.8 ? "text-[#9A6700]" : "text-[#8B1A1A]"}`}>
{v.konfidens >= 0.95 ? "✓" : `${Math.round(v.konfidens * 100)} %`}
</span>
</div>
))}
<div className="mt-2 grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setAvlasning(null)}>
Förkasta
</StorKnapp>
<StorKnapp onClick={sparaAvlasning}>Spara foto + värden</StorKnapp>
</div>
</div>
)}
{typ && typ !== "foto" && typ !== "instrument" && (
<div className="mt-3">
{typ === "hypotes" && (
<p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">
<IkonPunkt farg="#8B1A1A" /> En hypotes är en möjlig felorsak som kräver verifiering den loggas aldrig som ett konstaterat fel.
</p>
)}
<TextFalt
label={typ === "matvarde" ? "Vad mättes?" : DOKTYPER.find((d) => d.id === typ)!.label}
varde={text}
satt={setText}
platshallare={typ === "matvarde" ? "T.ex. Matningsspänning stift 30" : ""}
rost
/>
{typ === "matvarde" && <TextFalt label="Värde (med enhet)" varde={varde} satt={setVarde} platshallare="T.ex. 13,9 V" />}
<StorKnapp disabled={!text.trim() || (typ === "matvarde" && !varde.trim())} onClick={spara}>
Spara
</StorKnapp>
</div>
)}
</Panel>
);
}
function OverlamningDialog({
arende,
metodik,
nu,
skicka,
stang,
}: {
arende: Arende;
metodik: Metodik;
nu: string;
skicka: (h: Handelse) => void;
stang: () => void;
}) {
const anvandare = useFelsokning((s) => s.anvandare);
const [till, setTill] = useState("");
const [aiLage, setAiLage] = useState<"vilar" | "arbetar" | "fel" | "klar">("vilar");
const [aiRader, setAiRader] = useState<{ typ: keyof typeof AI_RADTYP_LABEL; text: string }[]>([]);
const text = overlamningstext(arende, metodik, nu);
// Orkesterns sammanfattningsuppgift: risker och osäkerheter som inte
// syns i checklistorna — loggas som AI-svar och visas i överlämningen.
const komplettera = async () => {
setAiLage("arbetar");
try {
const svar = await sammanfattaOverlamning(arende, metodik);
if (svar) {
skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: svar.modell });
setAiRader(svar.rader);
setAiLage("klar");
} else {
setAiLage("vilar");
}
} catch {
setAiLage("fel");
}
};
return (
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
<div className="max-h-full w-full max-w-2xl overflow-y-auto rounded border border-[#ADADAD] bg-[#F7F7F7] p-4">
<h2 className="mb-3 text-[15px] font-semibold">Överlämningsrapport</h2>
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded bg-white p-4 text-[12px] text-[#333333]">
{text}
</pre>
{aiLage === "vilar" && (
<StorKnapp variant="sekundar" className="mb-3" onClick={komplettera}>
Komplettera analysen: risker &amp; osäkerheter
</StorKnapp>
)}
{aiLage === "arbetar" && <p className="mb-3 animate-pulse font-semibold text-[#00437A]">Systemet sammanfattar </p>}
{aiLage === "fel" && (
<p className="mb-3 font-semibold text-[#8B1A1A]">Kunde inte hämtas kräver inloggning. Överlämningen fungerar ändå.</p>
)}
{aiLage === "klar" && aiRader.length > 0 && (
<div className="mb-3 rounded bg-white p-4">
{aiRader.map((rad, i) => (
<p key={i} className="py-0.5 text-[12px] text-[#333333]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
</div>
)}
<TextFalt label="Lämnas till (valfritt)" varde={till} satt={setTill} platshallare="T.ex. Johan" />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={stang}>
Avbryt
</StorKnapp>
<StorKnapp
onClick={() => {
skicka({ typ: "overlamning", fran: anvandare, till: till.trim() || undefined });
stang();
}}
>
Bekräfta överlämning
</StorKnapp>
</div>
</div>
</div>
);
}
const NIVA_LABEL: Record<DelningsNiva, string> = {
kund: "Kund",
partner: "Extern partner",
intern: "Intern",
};
// Live Share-behörighetsnivåer: skapa och återkalla delningslänkar.
// Nivåfiltreringen sker alltid på serversidan; verkstaden kontrollerar
// delningen och varje skapad/återkallad länk loggas i ärendet.
function DelningsHanterare({ arende, skicka }: { arende: Arende; skicka: (h: Handelse) => void }) {
const [oppen, setOppen] = useState(false);
const [delningar, setDelningar] = useState<Delning[]>([]);
const [fel, setFel] = useState("");
const uppdatera = async () => {
try {
setDelningar(await hamtaDelningar(arende.id));
setFel("");
} catch {
setFel("Kunde inte hämta delningar — kräver inloggning och att ärendet är synkat.");
}
};
if (!oppen) {
return (
<StorKnapp
variant="sekundar"
className="mt-2"
onClick={() => {
setOppen(true);
uppdatera();
}}
>
<IkonLank /> Delningslänkar (kund/partner/intern)
</StorKnapp>
);
}
return (
<div className="mt-3 rounded border border-[#C6C6C6] bg-white p-3">
{fel && <p className="mb-2 font-semibold text-[#8B1A1A]">{fel}</p>}
{delningar.map((delning) => (
<div key={delning.kod} className="flex items-center justify-between gap-2 border-b border-[#DDDDDD] py-2 last:border-0">
<span className="text-[14px]">
{NIVA_LABEL[delning.niva]}{" "}
{delning.aterkallad && <span className="text-[12px] font-semibold text-[#8B1A1A]">(återkallad)</span>}
</span>
{!delning.aterkallad && (
<span className="flex gap-2">
<button
className="rounded border border-[#ADADAD] px-3 py-1 font-semibold text-[#333333] hover:border-[#00437A]"
onClick={() =>
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${delning.kod}`)
}
>
Kopiera
</button>
<button
className="rounded border border-[#ADADAD] px-3 py-1 font-semibold text-[#8B1A1A] hover:border-[#8B1A1A]"
onClick={async () => {
await aterkallaDelning(delning.kod);
skicka({ typ: "kommentar", text: `Delningslänk (${NIVA_LABEL[delning.niva]}) återkallad.` });
uppdatera();
}}
>
Återkalla
</button>
</span>
)}
</div>
))}
<div className="mt-2 grid grid-cols-3 gap-2">
{(Object.keys(NIVA_LABEL) as DelningsNiva[]).map((niva) => (
<button
key={niva}
className="min-h-9 rounded border border-[#ADADAD] text-[12px] font-semibold text-[#333333] hover:border-[#00437A]"
onClick={async () => {
try {
await skapaDelning(arende.id, niva);
skicka({ typ: "kommentar", text: `Delningslänk skapad (${NIVA_LABEL[niva]}).` });
uppdatera();
} catch {
setFel("Kunde inte skapa delning — kräver inloggning och att ärendet är synkat.");
}
}}
>
+ {NIVA_LABEL[niva]}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-[#707070]">
Kund: det kunddelbara · Extern partner: även hypoteser (märkta ej verifierade) · Intern: full insyn.
Filtreringen sker servern.
</p>
</div>
);
}
function LoggFlik({ arende }: { arende: Arende }) {
return (
<Panel rubrik="Arbetslogg — append-only, ingenting skrivs över">
<ol>
{arende.handelser.map((post) => (
<li key={post.id} className="flex gap-3 border-b border-[#DDDDDD] py-2 last:border-0">
<span className="w-14 shrink-0 font-mono text-[12px] font-semibold text-[#00437A]">{tidKlockslag(post.tidpunkt)}</span>
<div className="min-w-0">
<p className="text-[13px] text-[#1A1A1A]">{handelseRubrik(post)}</p>
<p className="text-[11px] text-[#707070]">{post.anvandare}</p>
{post.handelse.typ === "foto" && (
<Bild bilaga={post.handelse} alt={post.handelse.beskrivning} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
)}
{post.handelse.typ === "video" && (
<Klipp bilaga={post.handelse} className="mt-1 max-h-40 rounded border border-[#C6C6C6]" />
)}
</div>
</li>
))}
</ol>
</Panel>
);
}
function BriefFlik({
arende,
metodik,
nu,
skicka,
}: {
arende: Arende;
metodik: Metodik;
nu: string;
skicka: (h: Handelse) => void;
}) {
const b = brief(arende, metodik, nu);
const [granskning, setGranskning] = useState<"vilar" | "arbetar" | "fel">("vilar");
const senasteAi = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
// Djupgranskningen går till orkesterns tyngsta modell (Opus 5, hög
// effort): motsägelser, luckor och förhastade slutsatser i hela loggen.
const granska = async () => {
setGranskning("arbetar");
try {
const svar = await granskaUnderlag(arende, metodik);
if (svar) skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: svar.modell });
setGranskning("vilar");
} catch {
setGranskning("fel");
}
};
return (
<>
{!b.avslutat && (
<Panel rubrik="Granskning av underlaget">
<p className="mb-2 text-[#333333]">
Låt systemet igenom hela underlaget innan du lämnar det vidare: motsägelser mellan
observationer, luckor i dokumentationen och slutsatser som saknar stöd.
</p>
{granskning === "arbetar" && <p className="mb-2 animate-pulse font-semibold text-[#00437A]">Systemet granskar underlaget </p>}
{granskning === "fel" && (
<p className="mb-2 font-semibold text-[#8B1A1A]">Granskningen kunde inte hämtas kräver inloggning. Försök igen.</p>
)}
{senasteAi && senasteAi.handelse.typ === "ai_svar" && granskning === "vilar" && (
<div className="mb-2">
{senasteAi.handelse.rader.map((rad, i) => (
<p key={i} className="py-0.5 text-[14px]">
<span className="font-semibold text-[#4A5560]">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
<p className="mt-1 text-[14px]">
<span className="font-semibold text-[#00437A]">Nästa steg:</span> {senasteAi.handelse.nastaSteg}
</p>
<p className="mt-1 text-[11px] text-[#707070]">Senaste analys ({senasteAi.handelse.modell})</p>
</div>
)}
<StorKnapp variant="sekundar" disabled={granskning === "arbetar"} onClick={granska}>
<IkonSok /> Granska underlaget
</StorKnapp>
</Panel>
)}
<Panel rubrik="Objekt">
{b.objekt ? (
<>
<p className="text-[15px] font-semibold">{b.objekt.beskrivning}</p>
<p className="font-semibold text-[#00437A]">{b.objekt.identifierare}</p>
{b.objekt.kund && <p className="text-[#333333]">Kund: {b.objekt.kund}</p>}
{b.ansvarig && <p className="text-[#333333]">Ansvarig tekniker: {b.ansvarig}</p>}
</>
) : (
<p className="text-[#4A5560]">Ej identifierat</p>
)}
</Panel>
<Panel rubrik="Kundens beskrivning">
<p className="text-[14px]">{b.felbeskrivning ? `${b.felbeskrivning}` : "—"}</p>
</Panel>
<Panel rubrik="Utförda kontroller">
{b.utfordaKontroller.length === 0 && <p className="text-[#4A5560]">Inga ännu.</p>}
{b.utfordaKontroller.map((k, i) =>
k.undantag ? (
<p key={i} className="py-0.5 text-[14px] text-[#9A6700]">
<IkonVarning /> {k.text} underlag saknas: {k.undantag}
</p>
) : (
<p key={i} className="py-0.5 text-[14px]">
{k.text}
{k.resultat && <span className="text-[#4A5560]"> {k.resultat}</span>}
</p>
),
)}
</Panel>
<Panel rubrik="Observationer">
{b.observationer.length === 0 && <p className="text-[#4A5560]">Inga ännu.</p>}
{b.observationer.map((o, i) => (
<p key={i} className="py-0.5 text-[14px]"> {o}</p>
))}
</Panel>
{b.hypoteser.length > 0 && (
<Panel rubrik="Hypoteser — kräver verifiering">
{b.hypoteser.map((h, i) => (
<p key={i} className="py-0.5 text-[14px]">
<NivaBadge niva={h.niva} /> {h.text}
</p>
))}
</Panel>
)}
<Panel rubrik="Ej kontrollerat">
{b.ejKontrollerat.length === 0 && <p className="text-[#4A5560]">Allt i metodiken är utfört.</p>}
{b.ejKontrollerat.map((e, i) => (
<p key={i} className="py-0.5 text-[14px] text-[#333333]"> {e}</p>
))}
</Panel>
{!b.avslutat && (
<Panel rubrik="Rekommenderat nästa steg">
{b.rekommenderatNastaSteg.map((s, i) => (
<p key={i} className="py-0.5 text-[14px]">{i + 1}. {s}</p>
))}
</Panel>
)}
<Panel rubrik="Tillförlitlighet">
{b.tillforlitlighet.map((r, i) => (
<p key={i} className="py-0.5 text-[14px]">
<NivaBadge niva={r.niva} /> {r.text}
</p>
))}
</Panel>
<Panel rubrik="Total arbetstid">
<p className="text-[17px] font-semibold">{b.totalArbetstid}</p>
</Panel>
</>
);
}
// Kundrapporten: en tydlig tidslinje i stället för "Felsökning 2,5 timmar".
// Interna poster (kategoribyten) visas inte för kund.
function RapportFlik({
arende,
metodik,
nu,
skicka,
}: {
arende: Arende;
metodik: Metodik;
nu: string;
skicka: (h: Handelse) => void;
}) {
const anvandare = useFelsokning((s) => s.anvandare);
const b = brief(arende, metodik, nu);
const bilder = foton(arende);
const klipp = videor(arende);
const fordelning = tidsfordelningsRader(arende, nu);
const idn = arendeidentitet(arende);
const typ = arendetyp(arende);
const repro = reproducering(arende);
const orsakerLista = felorsaker(arende);
const atgardsRader = atgarder(arende);
const kkRad = kvalitetskontroll(arende);
const forslagRader = atgardsforslag(arende);
const beslutRad = kundbeslut(arende);
let matIn: string | undefined;
let matUt: string | undefined;
for (const p of arende.handelser) {
const h = p.handelse;
if (h.typ === "matarstallning" && !h.undantag) {
if (h.lage === "ingaende") matIn = h.varde;
else matUt = h.varde;
}
}
// Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial
// och ingår inte i det som delas med kund.
const kundposter = arende.handelser.filter(
(p) => !["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"].includes(p.handelse.typ),
);
// Alla exporter bygger på samma händelselogg och versionsmärks:
// version = antal händelser vid exporttillfället. Spårbarhetspaketet
// (ECM-version, evidensposter med hash, grindstatus) följer med.
const exporteraJson = () => {
const version = arende.handelser.length;
const data = {
export: { format: "JSON", version, exporteradAv: anvandare, tidpunkt: new Date().toISOString() },
ecm: sparbarhetspaket(arende, metodik),
arende,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const lank = document.createElement("a");
lank.href = url;
lank.download = `arende-${arende.nummer}-v${version}.json`;
lank.click();
URL.revokeObjectURL(url);
skicka({ typ: "export_skapad", format: "JSON", version });
};
const grind = kvalitetsgrind(arende, metodik);
const godkand = grindGodkand(arende, metodik);
return (
<>
{/* ECM-kvalitetsgrinden: slutrapporten kan inte genereras förrän
varje obligatoriskt påstående har evidens eller dokumenterat
undantag i händelseloggen. */}
<Panel rubrik={`Kvalitetsgrind — ECM v${ECM_VERSION}`}>
{grind.map((rad) => (
<div key={rad.id} className="border-b border-[#EBEBEB] py-1 last:border-0">
<p className="text-[13px]">
<span className={rad.ok ? "text-[#1E6B34]" : rad.kravs ? "text-[#8B1A1A]" : "text-[#9A6700]"}>
{rad.ok ? <IkonCheck /> : rad.kravs ? <IkonKryss /> : <IkonVarning />}
</span>{" "}
{rad.rubrik}
{!rad.kravs && <span className="text-[11px] text-[#707070]"> (rekommenderas)</span>}
</p>
{rad.detalj && <p className="pl-6 text-[12px] text-[#707070]">{rad.detalj}</p>}
</div>
))}
{!godkand && (
<p className="mt-2 text-[12px] font-semibold text-[#8B1A1A]">
Rapporten kan inte genereras förrän varje underkänd rad har evidens eller ett dokumenterat undantag
(Underlag kan inte tas fram i guiden).
</p>
)}
</Panel>
<Panel rubrik="Kundrapport">
<p className="mb-2 text-[#333333]">
Delningsbar sammanställning av utfört arbete. Granska innehållet innan rapporten delas bilder kan
innehålla uppgifter om andra kunder.
</p>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" disabled={!godkand} onClick={() => window.print()}>
Skriv ut / PDF
</StorKnapp>
<StorKnapp variant="sekundar" onClick={exporteraJson}>
Exportera JSON
</StorKnapp>
</div>
<Link to={`/felsokning/dela/${arende.id}`} className="mt-2 block">
<StorKnapp variant="sekundar"><IkonPunkt farg="#1E6B34" /> Öppna Live Share-vy</StorKnapp>
</Link>
{plattformAktiv() ? (
<DelningsHanterare arende={arende} skicka={skicka} />
) : (
arende.delningskod && (
<StorKnapp
variant="sekundar"
className="mt-2"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/felsokning/delad/${arende.delningskod}`);
skicka({ typ: "kommentar", text: "Delningslänk kopierad för extern mottagare." });
}}
>
<IkonLank /> Kopiera delningslänk
</StorKnapp>
)
)}
<p className="mt-2 text-[12px] text-[#707070]">
Delningslänken kräver att ärendet är synkat mot molnet (inloggad användare).
</p>
</Panel>
{/* Rapportens första sida: ärendeidentiteten — registrerad en gång,
återanvänd här automatiskt. */}
<Panel rubrik="Ärendeinformation">
{identitetsRader([
["Ärende", `#${arende.nummer}`],
["Arbetsorder", idn.arbetsorder],
["Claim-/garantinr", idn.claim],
["Skadenummer", idn.skadenummer],
["Ärendetyp", typ],
["Ansvarig tekniker", idn.ansvarig],
])}
</Panel>
<Panel rubrik="Fordonsinformation">
{identitetsRader([
["Personbil", idn.beskrivning],
["Regnr", idn.identifierare],
["VIN", idn.vin],
["Mätarställning in", matIn],
["Mätarställning ut", matUt],
["Kund", idn.kund],
])}
</Panel>
{/* Beviskedjan: kundens upplevelse, teknikerns verifiering och den
tekniska slutsatsen hålls strikt åtskilda och spårbara. */}
<Panel rubrik="Kundens beskrivning">
<p className="text-[14px]">{b.felbeskrivning ? `${b.felbeskrivning}` : "—"}</p>
</Panel>
<Panel rubrik="Verifierad observation">
{repro ? (
<>
<p className="text-[14px] font-semibold">{reproduceringsText(repro.status)}</p>
<p className="text-[13px] text-[#4A5560]">{repro.beskrivning}</p>
</>
) : (
<p className="text-[14px] text-[#4A5560]">Reproducering ej dokumenterad ännu.</p>
)}
</Panel>
<Panel rubrik="Felorsaksanalys">
{orsakerLista.length === 0 && <p className="text-[14px] text-[#4A5560]">Felorsaksanalys ej dokumenterad ännu.</p>}
{orsakerLista.map((p) => {
const h = p.handelse;
if (h.typ !== "felorsak") return null;
return (
<div key={p.id} className="mb-3 border-b border-[#EBEBEB] pb-2 last:mb-0 last:border-0">
<p className="text-[14px] font-semibold">{h.avvikelse}</p>
{identitetsRader([
["Bedömd grundorsak", h.orsaker.join(", ")],
["Underlag för bedömningen", h.underlag.join(", ")],
["Säkerhetsnivå", TILLFORLITLIGHET_LABEL[h.sakerhet]],
["Rekommenderad åtgärd", h.atgard],
["Motivering", h.motivering],
["Stärkande kontroller", h.ytterligareKontroller],
])}
</div>
);
})}
</Panel>
{(forslagRader.length > 0 || beslutRad) && (
<Panel rubrik="Åtgärdsförslag och kundens besked">
{forslagRader.map((p) => {
const h = p.handelse;
if (h.typ !== "atgardsforslag") return null;
return (
<div key={p.id}>
{identitetsRader([
["Föreslagen åtgärd", h.beskrivning],
["Uppskattad kostnad", h.uppskattadKostnad],
["Uppskattad tid", h.uppskattadTid],
])}
</div>
);
})}
{beslutRad &&
identitetsRader([
["Kundens besked", KUNDBESLUT_LABEL[beslutRad.beslut]],
["Lämnat via", beslutRad.kanal],
["Kommentar", beslutRad.kommentar],
])}
</Panel>
)}
<Panel rubrik="Utförd åtgärd och verifiering">
{atgardsRader.length === 0 && <p className="text-[14px] text-[#4A5560]">Ingen åtgärd dokumenterad ännu.</p>}
{atgardsRader.map((p) => {
const h = p.handelse;
if (h.typ !== "atgard_utford") return null;
return (
<div key={p.id} className="mb-2">
{h.utford ? (
identitetsRader([
["Utförd åtgärd", h.beskrivning],
["Delar", h.delar],
])
) : (
<p className="text-[14px] text-[#9A6700]">Ingen åtgärd utförd {h.motivering}</p>
)}
</div>
);
})}
{kkRad &&
identitetsRader([
["Kvalitetskontroll", KVALITETSKONTROLL_LABEL[kkRad.resultat]],
["Verifiering", kkRad.beskrivning],
])}
</Panel>
<Panel rubrik="Sammanfattning">
<p className="text-[14px]">Total arbetstid: <span className="font-semibold">{b.totalArbetstid}</span></p>
{fordelning.length > 0 && (
<div className="mt-2">
{fordelning.map((r) => (
<p key={r.label} className="text-[#333333]">{r.label}: {r.tid}</p>
))}
</div>
)}
</Panel>
<Panel rubrik="Tidslinje">
{kundposter.map((post) => (
<p key={post.id} className="py-0.5 text-[13px]">
<span className="font-mono font-semibold text-[#00437A]">{tidKlockslag(post.tidpunkt)}</span>{" "}
{handelseRubrik(post)}
</p>
))}
</Panel>
{klipp.length > 0 && (
<Panel rubrik="Dokumenterad video">
{klipp.map((v, i) => (
<figure key={i} className="mb-2">
<Klipp bilaga={v.bilaga} className="w-full rounded border border-[#C6C6C6]" />
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{v.beskrivning}</figcaption>
</figure>
))}
</Panel>
)}
{bilder.length > 0 && (
<Panel rubrik="Dokumenterade bilder">
<div className="grid grid-cols-2 gap-2">
{bilder.map((bild, i) => (
<figure key={i}>
<Bild bilaga={bild.bilaga} alt={bild.beskrivning} className="rounded border border-[#C6C6C6]" />
<figcaption className="mt-1 text-[11px] text-[#4A5560]">{bild.beskrivning}</figcaption>
</figure>
))}
</div>
</Panel>
)}
{!b.avslutat || b.ejKontrollerat.length > 0 ? (
<Panel rubrik="Rekommenderade nästa steg">
{(b.avslutat ? b.ejKontrollerat : b.rekommenderatNastaSteg).map((s, i) => (
<p key={i} className="py-0.5 text-[14px]">{i + 1}. {s}</p>
))}
</Panel>
) : null}
<p className="text-center text-[11px] text-[#8A8A8A]">
Genererad ur ärendets händelselogg · Observationer och mätvärden redovisas utan slutsatser som saknar stöd.
</p>
</>
);
}
@@ -0,0 +1,399 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useFelsokning, metodikForArende } from "@/felsokning/store";
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
import { byggDemoArende } from "@/felsokning/demo";
import {
hamtaAnvandare,
loggaInPlattform,
loggaUtPlattform,
plattformAktiv,
plattformKonto,
registreraPlattform,
sattKontoAktiv,
skapaAnvandare,
type PlattformAnvandare,
type PlattformKonto,
type PlattformRoll,
} from "@/felsokning/plattform";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { IkonDiagram, IkonKugghjul } from "@/felsokning/ikoner";
import { tidDatum, tidKlockslag } from "@/felsokning/format";
type Filter = "alla" | "pagaende" | "klara";
const ROLL_LABEL: Record<PlattformRoll, string> = {
tekniker: "Tekniker",
arbetsledare: "Arbetsledare",
admin: "Systemadministratör",
};
// Självhostat läge: inloggning mot plattformstjänsten i klustret.
// Registrering skapar en ny organisation (tenant) med användaren som
// systemadministratör; övriga användare skapas av admin.
function PlattformInloggning() {
const [konto, setKonto] = useState<PlattformKonto | null>(plattformKonto());
const [lage, setLage] = useState<"stangd" | "loggaIn" | "registrera">("stangd");
const [epost, setEpost] = useState("");
const [losenord, setLosenord] = useState("");
const [nyttNamn, setNyttNamn] = useState("");
const [organisation, setOrganisation] = useState("");
const [fel, setFel] = useState("");
if (konto) {
return (
<>
<Panel rubrik="Plattformskonto">
<div className="flex items-center justify-between gap-3">
<p className="text-[14px]">
<span className="font-semibold">{konto.namn}</span> · {ROLL_LABEL[konto.roll]} ·{" "}
{konto.organisation} synk och beslutsstöd aktiva.
</p>
<button
className="whitespace-nowrap rounded border border-[#ADADAD] px-4 py-2 font-semibold text-[#333333] hover:border-[#8FA8C0]"
onClick={() => {
loggaUtPlattform();
setKonto(null);
}}
>
Logga ut
</button>
</div>
{(konto.roll === "arbetsledare" || konto.roll === "admin") && (
<Link to="/felsokning/oversikt" className="mt-3 block">
<StorKnapp variant="sekundar"><IkonDiagram /> Organisationsöversikt</StorKnapp>
</Link>
)}
{konto.roll === "admin" && (
<Link to="/felsokning/installningar" className="mt-2 block">
<StorKnapp variant="sekundar"><IkonKugghjul /> Inställningar för organisationen</StorKnapp>
</Link>
)}
</Panel>
{konto.roll === "admin" && <AnvandarAdmin />}
</>
);
}
return (
<Panel rubrik="Plattformskonto">
{lage === "stangd" ? (
<>
<p className="mb-2 text-[#333333]">
Logga in för synk mellan enheter, samarbete i ärenden och beslutsstöd. Utan inloggning
arbetar appen i lokalt läge. Ny organisation? Skapa konto du blir systemadministratör.
</p>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("registrera")}>
Skapa organisation
</StorKnapp>
<StorKnapp onClick={() => setLage("loggaIn")}>Logga in</StorKnapp>
</div>
</>
) : (
<form
onSubmit={async (e) => {
e.preventDefault();
setFel("");
try {
setKonto(
lage === "loggaIn"
? await loggaInPlattform(epost, losenord)
: await registreraPlattform(epost, losenord, nyttNamn, organisation),
);
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
}
}}
>
{lage === "registrera" && (
<>
<TextFalt label="Organisation" varde={organisation} satt={setOrganisation} platshallare="T.ex. Verkstad Syd AB" />
<TextFalt label="Ditt namn" varde={nyttNamn} satt={setNyttNamn} platshallare="T.ex. Anna" />
</>
)}
<TextFalt label="E-post" varde={epost} satt={setEpost} platshallare="anna@verkstaden.se" />
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
{fel && <p className="mb-3 font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("stangd")}>
Avbryt
</StorKnapp>
<StorKnapp
type="submit"
disabled={
!epost.trim() ||
losenord.length < 8 ||
(lage === "registrera" && (!nyttNamn.trim() || !organisation.trim()))
}
>
{lage === "loggaIn" ? "Logga in" : "Skapa organisation"}
</StorKnapp>
</div>
</form>
)}
</Panel>
);
}
// Användarhantering för systemadministratören: lista och skapa användare
// i den egna organisationen (servern verifierar behörigheten).
function AnvandarAdmin() {
const [oppen, setOppen] = useState(false);
const [lista, setLista] = useState<PlattformAnvandare[]>([]);
const [epost, setEpost] = useState("");
const [namn, setNamn] = useState("");
const [losenord, setLosenord] = useState("");
const [roll, setRoll] = useState<PlattformRoll>("tekniker");
const [fel, setFel] = useState("");
const uppdatera = async () => {
try {
setLista(await hamtaAnvandare());
} catch {
setFel("Kunde inte hämta användare.");
}
};
if (!oppen) {
return (
<Panel rubrik="Användare">
<StorKnapp
variant="sekundar"
onClick={() => {
setOppen(true);
uppdatera();
}}
>
Hantera användare i organisationen
</StorKnapp>
</Panel>
);
}
return (
<Panel rubrik="Användare">
{lista.map((anv) => (
<div
key={anv.id}
className="flex items-center justify-between gap-3 border-b border-[#DDDDDD] py-1.5 last:border-0"
>
<p className="min-w-0 text-[14px]">
<span className={`font-semibold ${anv.aktiv === false ? "text-[#8A8A8A] line-through" : ""}`}>
{anv.namn}
</span>{" "}
· {ROLL_LABEL[anv.roll]} <span className="text-[#707070]">{anv.epost}</span>
{anv.aktiv === false && (
<span className="ml-1 text-[12px] font-semibold text-[#8B1A1A]">Avstängd</span>
)}
</p>
<button
type="button"
className={`shrink-0 rounded border px-2 py-1 text-[12px] font-semibold ${
anv.aktiv === false
? "border-[#1E6B34] bg-white text-[#1E6B34]"
: "border-[#6E1414] bg-white text-[#8B1A1A]"
}`}
onClick={async () => {
setFel("");
try {
await sattKontoAktiv(anv.id, anv.aktiv === false);
await uppdatera();
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
}
}}
>
{anv.aktiv === false ? "Öppna igen" : "Stäng av"}
</button>
</div>
))}
<p className="mt-1 text-[12px] text-[#707070]">
En avstängning gäller omedelbart pågående sessioner personens enheter upphör direkt.
</p>
<form
className="mt-3"
onSubmit={async (e) => {
e.preventDefault();
setFel("");
try {
await skapaAnvandare(epost, losenord, namn, roll);
setEpost("");
setNamn("");
setLosenord("");
await uppdatera();
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
}
}}
>
<TextFalt label="Namn" varde={namn} satt={setNamn} />
<TextFalt label="E-post" varde={epost} satt={setEpost} />
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
<div className="mb-3 grid grid-cols-3 gap-2">
{(Object.keys(ROLL_LABEL) as PlattformRoll[]).map((valbar) => (
<button
key={valbar}
type="button"
onClick={() => setRoll(valbar)}
className={`min-h-9 rounded border text-[12px] font-semibold ${
roll === valbar
? "border-[#00437A] bg-[#00437A] text-white"
: "border-[#ADADAD] bg-[#F7F7F7] text-[#333333]"
}`}
>
{ROLL_LABEL[valbar]}
</button>
))}
</div>
{fel && <p className="mb-3 font-semibold text-[#8B1A1A]">{fel}</p>}
<StorKnapp type="submit" disabled={!epost.trim() || !namn.trim() || losenord.length < 8}>
Skapa användare
</StorKnapp>
</form>
</Panel>
);
}
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
// pågående, klara och starta nytt ärende.
export default function Arendelista() {
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer } = useFelsokning();
const [namn, setNamn] = useState("");
const [filter, setFilter] = useState<Filter>("alla");
// Inloggad användare tillfrågas aldrig om namn — kontot vet redan.
const kontoNamn = plattformKonto()?.namn;
useEffect(() => {
if (!anvandare && kontoNamn) sattAnvandare(kontoNamn);
}, [anvandare, kontoNamn, sattAnvandare]);
if (!anvandare) {
return (
<FelsokningSkal rubrik="Guidad Felsökning">
<Panel rubrik="Vem arbetar?">
<p className="mb-3 text-[14px] text-[#333333]">
Allt arbete loggas med användare och tidpunkt. Ange ditt namn för att börja.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
if (namn.trim()) sattAnvandare(namn);
}}
>
<TextFalt label="Namn" varde={namn} satt={setNamn} platshallare="T.ex. Anna" />
<StorKnapp type="submit" disabled={!namn.trim()}>
Fortsätt
</StorKnapp>
</form>
</Panel>
</FelsokningSkal>
);
}
const alla = Object.values(arenden).sort((a, b) => b.skapad.localeCompare(a.skapad));
const pagaende = alla.filter((a) => !arAvslutat(a));
const klara = alla.filter((a) => arAvslutat(a));
const lista = filter === "pagaende" ? pagaende : filter === "klara" ? klara : alla;
const FILTER: { id: Filter; label: string; antal: number }[] = [
{ id: "alla", label: "Alla", antal: alla.length },
{ id: "pagaende", label: "Pågående", antal: pagaende.length },
{ id: "klara", label: "Klara", antal: klara.length },
];
return (
<FelsokningSkal
rubrik="Guidad Felsökning"
hoger={<span className="text-[12px] font-semibold text-[#A9C3DE]">{anvandare}</span>}
>
<Link to="/felsokning/nytt">
<StorKnapp className="mb-4">+ Nytt ärende</StorKnapp>
</Link>
{alla.length > 0 && (
<div className="mb-4 grid grid-cols-3 gap-1 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-1">
{FILTER.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`min-h-9 rounded text-[13px] font-semibold transition-colors ${
filter === f.id ? "bg-[#00437A] text-white" : "text-[#333333] hover:bg-[#E4E9EE]"
}`}
>
{f.label} · {f.antal}
</button>
))}
</div>
)}
{alla.length === 0 && (
<Panel rubrik="Kom igång">
<p className="mb-3 text-[14px] text-[#333333]">
Inga ärenden ännu. Starta med att identifiera ett objekt eller utforska ett färdigt demoärende
med komplett arbetslogg, brief och kundrapport.
</p>
<StorKnapp variant="sekundar" onClick={() => laggInArende(byggDemoArende(nastaNummer, anvandare))}>
Skapa demoärende (Volvo XC60, vibration)
</StorKnapp>
</Panel>
)}
{lista.length === 0 && alla.length > 0 && (
<p className="text-center text-[14px] text-[#4A5560]">Inga ärenden i det här filtret.</p>
)}
{plattformAktiv() && <PlattformInloggning />}
{!plattformKonto() && (
<Link to="/felsokning/installningar" className="mb-4 block">
<StorKnapp variant="sekundar"><IkonKugghjul /> Inställningar</StorKnapp>
</Link>
)}
<Panel rubrik="Beslutsstöd">
<p className="text-[#333333]">
När du är inloggad hjälper systemet dig medan du arbetar: föreslår nästa steg utifrån det du
dokumenterar, granskar hela ärendet begäran och skriver utkast till överlämningen. Den
skiljer alltid verifierat och hypotes och slår aldrig fast en felorsak som inte är
bekräftad. Inget att installera eller ställa in; allt ingår i tjänsten. Utan inloggning
guidar checklistan dig steg för steg.
</p>
</Panel>
{lista.map((arende) => {
const o = objekt(arende);
const fel = felbeskrivning(arende);
const sista = sistaAktivitet(arende);
const avslutat = arAvslutat(arende);
const b = brief(arende, metodikForArende(arende), new Date().toISOString());
return (
<Link key={arende.id} to={`/felsokning/arende/${arende.id}`} className="block">
<div className="mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-4 transition-colors hover:border-[#00437A]">
<div className="flex items-center justify-between gap-2">
<span className="text-[12px] font-semibold uppercase tracking-widest text-[#4A5560]">
Ärende #{arende.nummer}
</span>
<span
className={`rounded px-2 py-0.5 text-[11px] font-semibold uppercase ${
avslutat ? "bg-[#8A94A0] text-white" : "bg-[#00437A] text-white"
}`}
>
{avslutat ? "Avslutat" : "Pågående"}
</span>
</div>
<p className="mt-1 text-[15px] font-semibold">{o ? o.beskrivning : "Okänt objekt"}</p>
{o && <p className="text-[12px] font-semibold text-[#4A5560]">{o.identifierare}{o.kund ? ` · ${o.kund}` : ""}</p>}
{fel && <p className="mt-2 text-[14px] text-[#333333]">{fel}</p>}
<p className="mt-2 text-[12px] text-[#707070]">
{sista ? `Senaste aktivitet ${tidDatum(sista.tidpunkt)} ${tidKlockslag(sista.tidpunkt)}` : ""}
{" · "}
{b.totalArbetstid}
</p>
</div>
</Link>
);
})}
</FelsokningSkal>
);
}
@@ -0,0 +1,36 @@
// Intern förhandsvisning av Live Share-vyn: läser ur lokala storen och
// uppdateras live medan teknikern arbetar. Den publika delningslänken
// (via delningskod) renderar exakt samma vy.
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { DelatArendeVy } from "@/felsokning/DelatArendeVy";
import { useFelsokning } from "@/felsokning/store";
import { FelsokningSkal } from "@/felsokning/ui";
export default function DelatArende() {
const { id } = useParams<{ id: string }>();
const arende = useFelsokning((s) => (id ? s.arenden[id] : undefined));
const [nu, setNu] = useState(() => new Date().toISOString());
useEffect(() => {
const timer = setInterval(() => setNu(new Date().toISOString()), 30000);
return () => clearInterval(timer);
}, []);
if (!arende) {
return (
<FelsokningSkal rubrik="Delat ärende">
<p className="text-[14px] text-[#333333]">Ärendet är inte tillgängligt.</p>
</FelsokningSkal>
);
}
return (
<DelatArendeVy
arende={arende}
nu={nu}
notis="Skrivskyddad livevy — uppdateras automatiskt när verkstaden registrerar ny information. Detta är förhandsvisningen; den externa delningslänken visar samma vy."
/>
);
}
@@ -0,0 +1,281 @@
// Inställningar: vilka objekttyper och identifieringsmetoder som visas
// när ett ärende startas. På plattformen får bara systemadministratören
// ändra (valet gäller hela organisationen); i lokalt läge gäller valet
// den här enheten.
import { useEffect, useState } from "react";
import {
ALLA_IDENTIFIERINGSMETODER,
ALLA_OBJEKTTYPER,
hamtaInstallningar,
lastaInstallningar,
sparaInstallningar,
type Installningar as Inst,
} from "@/felsokning/installningar";
import {
hamtaIntegrationer,
hamtaLeverantorer,
plattformAktiv,
plattformKonto,
sparaIntegration,
taBortIntegration,
type Integration,
type Leverantor,
} from "@/felsokning/plattform";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
function Vallista({
alla,
valda,
vidByte,
}: {
alla: string[];
valda: string[];
vidByte: (nya: string[]) => void;
}) {
return (
<div className="grid grid-cols-2 gap-2">
{alla.map((val) => {
const aktiv = valda.includes(val);
return (
<button
key={val}
onClick={() => vidByte(aktiv ? valda.filter((v) => v !== val) : [...valda, val])}
className={`min-h-10 rounded border px-2 font-semibold transition-colors ${
aktiv
? "border-[#00437A] bg-[#00437A] text-white"
: "border-[#ADADAD] bg-[#F7F7F7] text-[#4A5560]"
}`}
>
{aktiv ? "✓ " : ""}
{val}
</button>
);
})}
</div>
);
}
// Märkesspecifika kopplingar: systemadministratören lägger in
// organisationens egna leverantörsuppgifter. Värdena skickas till
// servern, lagras krypterade och kommer aldrig tillbaka i klartext —
// hemliga fält visas alltid maskerade. Alla uppslag görs av servern.
function Integrationer() {
const [leverantorer, setLeverantorer] = useState<Leverantor[]>([]);
const [befintliga, setBefintliga] = useState<Integration[]>([]);
const [krypteringKlar, setKrypteringKlar] = useState(true);
const [oppen, setOppen] = useState<string | null>(null);
const [varden, setVarden] = useState<Record<string, string>>({});
const [fel, setFel] = useState("");
const [sparat, setSparat] = useState("");
const ladda = () => {
hamtaLeverantorer().then(setLeverantorer).catch(() => setLeverantorer([]));
hamtaIntegrationer()
.then((svar) => {
setBefintliga(svar.integrationer);
setKrypteringKlar(svar.krypteringKonfigurerad);
})
.catch(() => setBefintliga([]));
};
useEffect(ladda, []);
if (leverantorer.length === 0) return null;
return (
<Panel rubrik="Märkesspecifika kopplingar">
<p className="mb-2 text-[12px] text-[#707070]">
Organisationens egna avtal hos tillverkare och dataleverantörer. Uppgifterna lagras krypterat
plattformen och används bara av servern de skickas aldrig till teknikernas enheter.
</p>
{!krypteringKlar && (
<p className="mb-2 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2 text-[12px] font-semibold text-[#9A6700]">
Kryptering är inte konfigurerad i driften (INTEGRATION_NYCKEL) uppgifter kan inte sparas förrän
nyckeln finns.
</p>
)}
{leverantorer.map((lev) => {
const finns = befintliga.find((i) => i.leverantor === lev.id);
return (
<div key={lev.id} className="border-b border-[#EBEBEB] py-2 last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[13px] font-semibold">{lev.namn}</p>
{lev.beskrivning && <p className="text-[12px] text-[#707070]">{lev.beskrivning}</p>}
{finns && (
<p className="mt-1 text-[12px] text-[#4A5560]">
{Object.entries(finns.uppgifter)
.map(([nyckel, varde]) => `${nyckel}: ${varde || "—"}`)
.join(" · ")}
{finns.senaste_status && ` · senaste uppslag: ${finns.senaste_status}`}
</p>
)}
</div>
<span className={`shrink-0 text-[11px] font-semibold ${finns ? "text-[#1E6B34]" : "text-[#707070]"}`}>
{finns ? "Konfigurerad" : "Ej konfigurerad"}
</span>
</div>
{oppen === lev.id ? (
<div className="mt-2 rounded border border-[#C6C6C6] bg-white p-2">
{lev.falt.map((falt) => (
<TextFalt
key={falt.nyckel}
label={`${falt.etikett}${falt.hemlig ? " (lagras krypterat)" : ""}`}
varde={varden[falt.nyckel] ?? ""}
satt={(v) => setVarden((f) => ({ ...f, [falt.nyckel]: v }))}
losenord={falt.hemlig}
/>
))}
{fel && <p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => { setOppen(null); setFel(""); }}>
Avbryt
</StorKnapp>
<StorKnapp
disabled={!krypteringKlar}
onClick={async () => {
setFel("");
try {
await sparaIntegration(lev.id, varden);
setVarden({});
setOppen(null);
setSparat(lev.namn);
ladda();
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Kunde inte spara.");
}
}}
>
Spara uppgifter
</StorKnapp>
</div>
<p className="mt-1 text-[11px] text-[#707070]">
Hemliga fält visas aldrig igen efter sparande fyll i nytt för att byta värde.
</p>
</div>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
<StorKnapp
variant="sekundar"
onClick={() => {
setOppen(lev.id);
setFel("");
// Icke-hemliga värden förifylls så bara hemligheten
// behöver skrivas om.
const start: Record<string, string> = {};
for (const falt of lev.falt) {
if (!falt.hemlig && finns?.uppgifter[falt.nyckel]) start[falt.nyckel] = finns.uppgifter[falt.nyckel];
}
setVarden(start);
}}
>
{finns ? "Uppdatera uppgifter" : "Lägg till uppgifter"}
</StorKnapp>
{finns && (
<StorKnapp
variant="fara"
onClick={async () => {
await taBortIntegration(lev.id);
ladda();
}}
>
Ta bort
</StorKnapp>
)}
</div>
)}
</div>
);
})}
{sparat && <p className="mt-2 text-[12px] font-semibold text-[#1E6B34]">Uppgifter sparade för {sparat}.</p>}
</Panel>
);
}
export default function Installningar() {
const konto = plattformKonto();
const inloggad = plattformAktiv() && !!konto;
const farAndra = !inloggad || konto?.roll === "admin";
const [inst, setInst] = useState<Inst>(lastaInstallningar());
const [status, setStatus] = useState<"" | "sparar" | "sparat" | "fel">("");
const [felText, setFelText] = useState("");
useEffect(() => {
hamtaInstallningar().then(setInst);
}, []);
if (!farAndra) {
return (
<FelsokningSkal rubrik="Inställningar" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-[14px] text-[#333333]">
Organisationens inställningar hanteras av er systemadministratör.
</p>
</Panel>
</FelsokningSkal>
);
}
const spara = async () => {
if (inst.objekttyper.length === 0 || inst.identifieringsmetoder.length === 0) {
setStatus("fel");
setFelText("Minst ett alternativ måste vara valt i varje lista.");
return;
}
setStatus("sparar");
setFelText("");
try {
await sparaInstallningar(inst);
setStatus("sparat");
} catch (misslyckande) {
setStatus("fel");
setFelText(misslyckande instanceof Error ? misslyckande.message : "Kunde inte spara.");
}
};
const byt = (nya: Partial<Inst>) => {
setInst((f) => ({ ...f, ...nya }));
setStatus("");
};
return (
<FelsokningSkal rubrik="Inställningar" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-[#333333]">
Välj vad som visas när ett nytt ärende startas.{" "}
{inloggad
? "Valet gäller alla i organisationen."
: "I lokalt läge gäller valet den här enheten; inloggad systemadministratör styr hela organisationen."}
</p>
</Panel>
<Panel rubrik="Objekttyper">
<Vallista
alla={ALLA_OBJEKTTYPER}
valda={inst.objekttyper}
vidByte={(objekttyper) => byt({ objekttyper })}
/>
</Panel>
<Panel rubrik="Identifieringsmetoder">
<Vallista
alla={ALLA_IDENTIFIERINGSMETODER}
valda={inst.identifieringsmetoder}
vidByte={(identifieringsmetoder) => byt({ identifieringsmetoder })}
/>
</Panel>
{inloggad && <Integrationer />}
{status === "fel" && <p className="mb-3 font-semibold text-[#8B1A1A]">{felText}</p>}
{status === "sparat" && <p className="mb-3 font-semibold text-[#1E6B34]"> Sparat</p>}
<StorKnapp disabled={status === "sparar"} onClick={spara}>
{status === "sparar" ? "Sparar …" : "Spara inställningar"}
</StorKnapp>
</FelsokningSkal>
);
}
@@ -0,0 +1,497 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Objekt } from "@/felsokning/domain";
import { useFelsokning } from "@/felsokning/store";
import { valjMetodik } from "@/felsokning/metodik";
import { lasAvInstrument, tolkaArbetsorder, valjMetodikMedAi, type TolkatFalt, type ArbetsorderGrupp } from "@/felsokning/ai";
import { byggDemoTolkning } from "@/felsokning/demo";
import { hamtaInstallningar, lastaInstallningar } from "@/felsokning/installningar";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto } from "@/felsokning/format";
import { IkonKamera } from "@/felsokning/ikoner";
import { startaSkanning, stodStreckkod, tolkaKod, type AvlastKod, type Skanning } from "@/felsokning/streckkod";
// Ärendestart: teknikern är redan känd (inloggning/namn) och ska kunna
// starta ett ärende på under 15 sekunder. Primärvägen är att fotografera
// arbetsordern — systemet läser dokumentet och fyller i fälten; teknikern
// granskar bara osäkra värden. Manuell inmatning finns som andrahandsväg,
// styrd av organisationens inställningar.
export default function NyttArende() {
const navigate = useNavigate();
const skapaArende = useFelsokning((s) => s.skapaArende);
const laggTill = useFelsokning((s) => s.laggTill);
const [inst, setInst] = useState(lastaInstallningar());
const [steg, setSteg] = useState<"start" | "granska" | "identifiera" | "bekrafta" | "felbeskrivning">("start");
const [metod, setMetod] = useState(inst.identifieringsmetoder[0]);
const [typ, setTyp] = useState(inst.objekttyper[0]);
useEffect(() => {
hamtaInstallningar().then((farsk) => {
setInst(farsk);
setTyp((t) => (farsk.objekttyper.includes(t) ? t : farsk.objekttyper[0]));
setMetod((m) => (farsk.identifieringsmetoder.includes(m) ? m : farsk.identifieringsmetoder[0]));
});
}, []);
const [identifierare, setIdentifierare] = useState("");
const [beskrivning, setBeskrivning] = useState("");
const [kund, setKund] = useState("");
const [fel, setFel] = useState("");
const [startar, setStartar] = useState(false);
// QR/streckkod: läses direkt ur kameraströmmen när webbläsaren stöder
// det, annars fotograferas typskylten och bildtolkningen läser av den.
const [skannarKod, setSkannarKod] = useState(false);
const [kodFel, setKodFel] = useState("");
const [avlastKod, setAvlastKod] = useState<AvlastKod | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const skanningRef = useRef<Skanning | null>(null);
const typskyltRef = useRef<HTMLInputElement>(null);
const [laserTypskylt, setLaserTypskylt] = useState(false);
useEffect(() => () => skanningRef.current?.stoppa(), []);
const anvandKod = (kod: AvlastKod) => {
setAvlastKod(kod);
setIdentifierare(kod.varde);
if (inst.identifieringsmetoder.includes(kod.typ)) setMetod(kod.typ);
setSkannarKod(false);
};
const oppnaSkanning = async () => {
setKodFel("");
setSkannarKod(true);
// Videoelementet monteras i samma render — vänta in det.
setTimeout(async () => {
if (!videoRef.current) return;
skanningRef.current = await startaSkanning(videoRef.current, anvandKod, (m) => {
setKodFel(m);
setSkannarKod(false);
});
}, 50);
};
const stangSkanning = () => {
skanningRef.current?.stoppa();
skanningRef.current = null;
setSkannarKod(false);
};
// Fallback: fota typskylten — bildtolkningen läser VIN/regnr ur bilden.
const lasTypskylt = async (fil: File) => {
setLaserTypskylt(true);
setKodFel("");
const foto = await skalaNerFoto(fil);
try {
const resultat = await lasAvInstrument(foto);
const kandidater = resultat?.tolkning.varden.map((v) => v.varde) ?? [];
const traff = kandidater.map((v) => tolkaKod(v)).find((k): k is AvlastKod => !!k);
if (traff) anvandKod(traff);
else if (!resultat) setKodFel("Avläsning av typskylt kräver inloggning — ange identiteten manuellt.");
else setKodFel("Ingen identifierare kunde läsas ur bilden — försök igen eller ange manuellt.");
} catch {
setKodFel("Avläsningen kunde inte nås — ange identiteten manuellt.");
}
setLaserTypskylt(false);
};
const [tolkning, setTolkning] = useState<{ foto: string; falt: TolkatFalt[]; modell?: string; demo?: boolean } | null>(null);
const [skannar, setSkannar] = useState(false);
const [skanningsFel, setSkanningsFel] = useState("");
const skanRef = useRef<HTMLInputElement>(null);
// Foto → dokumenttolkning i plattformens orkester. I lokalt läge (ingen
// inloggning) visas en tydligt märkt demo-tolkning så flödet kan provas.
const skanna = async (fil: File) => {
setSkannar(true);
setSkanningsFel("");
const foto = await skalaNerFoto(fil);
try {
const resultat = await tolkaArbetsorder(foto);
if (resultat) {
if (resultat.falt.length === 0) {
setSkanningsFel("Inga fält kunde läsas ur bilden — ta ett tydligare foto eller fyll i manuellt.");
} else {
setTolkning({ foto, falt: resultat.falt, modell: resultat.modell });
setSteg("granska");
}
} else {
setTolkning({ foto, falt: byggDemoTolkning(), demo: true });
setSteg("granska");
}
} catch {
setSkanningsFel("Dokumenttolkningen kunde inte nås — försök igen eller fyll i manuellt.");
}
setSkannar(false);
};
// Granskade fält → ärende: objektet byggs ur fordonsfälten, kundens
// felbeskrivning startar metodiken och hela tolkningen loggas som en
// organisationsintern händelse (delas aldrig i kund-/partnervyer).
const startaDiagnos = async (falt: TolkatFalt[]) => {
if (!tolkning) return;
setStartar(true);
const v = (id: string) => falt.find((f) => f.id === id && f.varde.trim())?.varde.trim();
const regnr = v("fordon_regnr");
const vin = v("fordon_vin");
const beskr = [v("fordon_marke"), v("fordon_modell"), v("fordon_arsmodell")].filter(Boolean).join(" ");
const objektet: Objekt = {
typ: inst.objekttyper.includes("Personbil") ? "Personbil" : inst.objekttyper[0],
identifierare: (regnr ?? vin ?? "OKÄND").toUpperCase(),
identifieringsmetod: regnr ? "Regnr" : vin ? "VIN" : "Manuell inmatning",
beskrivning: beskr || "Se arbetsorder",
kund: v("kund_namn"),
// Ärendeidentiteten: registreras en gång här, återanvänds i alla vyer.
vin: vin?.toUpperCase(),
miltal: v("fordon_matarstallning"),
arbetsorder: v("ao_nummer"),
claim: v("ao_claim"),
skadenummer: v("ao_skadenummer"),
};
const felText = v("felbeskrivning") ?? "Se skannad arbetsorder";
let metodikId: string | undefined;
if (valjMetodik(felText).id === "generisk") {
metodikId = (await valjMetodikMedAi(felText)) ?? undefined;
}
const id = skapaArende(objektet, felText, metodikId);
laggTill(id, {
typ: "arbetsorder_skannad",
falt: falt
.filter((f) => f.varde.trim())
.map(({ id: faltId, etikett, varde, konfidens }) => ({ id: faltId, etikett, varde, konfidens })),
dataUrl: tolkning.foto,
});
navigate(`/felsokning/arende/${id}`);
};
const objektet: Objekt = {
typ,
identifierare: identifierare.trim().toUpperCase(),
identifieringsmetod: metod,
beskrivning: beskrivning.trim() || identifierare.trim().toUpperCase(),
kund: kund.trim() || undefined,
};
if (steg === "start") {
return (
<FelsokningSkal rubrik="Starta ärende" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik="Skanna arbetsorder">
<p className="mb-3 text-[#333333]">
Fota arbetsorderns framsida. Systemet läser kund-, fordons- och ärendeuppgifter automatiskt
du granskar bara osäkra fält och trycker Starta diagnos.
</p>
<input
ref={skanRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const fil = e.target.files?.[0];
if (fil) skanna(fil);
e.target.value = "";
}}
/>
{skannar ? (
<p className="animate-pulse py-2 text-center font-semibold text-[#00437A]">Tolkar dokumentet </p>
) : (
<StorKnapp onClick={() => skanRef.current?.click()}><IkonKamera /> Skanna arbetsorder</StorKnapp>
)}
{skanningsFel && <p className="mt-2 font-semibold text-[#8B1A1A]">{skanningsFel}</p>}
</Panel>
<Panel>
<StorKnapp variant="sekundar" onClick={() => setSteg("identifiera")}>
Fyll i manuellt
</StorKnapp>
</Panel>
</FelsokningSkal>
);
}
if (steg === "granska" && tolkning) {
return (
<FelsokningSkal
bred
rubrik="Granska tolkad arbetsorder"
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
>
{tolkning.demo && (
<p className="mb-3 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2 text-[12px] font-semibold text-[#9A6700]">
Demo-tolkning plattformens dokumenttolkning kräver inloggning. Värdena nedan är exempeldata.
</p>
)}
<GranskaTolkning
foto={tolkning.foto}
falt={tolkning.falt}
startar={startar}
vidStart={startaDiagnos}
avbryt={() => {
setTolkning(null);
setSteg("start");
}}
/>
</FelsokningSkal>
);
}
if (steg === "identifiera") {
return (
<FelsokningSkal rubrik="Identifiera objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik="Objekttyp">
<div className="grid grid-cols-2 gap-2">
{inst.objekttyper.map((t) => (
<StorKnapp key={t} variant={t === typ ? "primar" : "sekundar"} onClick={() => setTyp(t)}>
{t}
</StorKnapp>
))}
</div>
</Panel>
<Panel rubrik="Identifieringsmetod">
<div className="grid grid-cols-2 gap-2">
{inst.identifieringsmetoder.map((m) => (
<StorKnapp key={m} variant={m === metod ? "primar" : "sekundar"} onClick={() => setMetod(m)}>
{m}
</StorKnapp>
))}
</div>
<p className="mt-3 text-[12px] text-[#707070]">
QR-kod, streckkod och typskylt kan läsas av i nästa steg eller ange identiteten manuellt.
</p>
</Panel>
<Panel rubrik="Läs av identiteten">
<input
ref={typskyltRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const fil = e.target.files?.[0];
e.target.value = "";
if (fil) lasTypskylt(fil);
}}
/>
{skannarKod ? (
<div>
<video ref={videoRef} playsInline muted className="mb-2 w-full rounded border border-[#C6C6C6]" />
<p className="mb-2 text-[12px] text-[#707070]">
Rikta kameran mot QR-koden, streckkoden eller VIN-etiketten.
</p>
<StorKnapp variant="sekundar" onClick={stangSkanning}>
Avbryt skanning
</StorKnapp>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{stodStreckkod() && (
<StorKnapp variant="sekundar" onClick={oppnaSkanning}>
Skanna QR/streckkod
</StorKnapp>
)}
<StorKnapp variant="sekundar" disabled={laserTypskylt} onClick={() => typskyltRef.current?.click()}>
<IkonKamera /> {laserTypskylt ? "Läser av …" : "Fotografera typskylt"}
</StorKnapp>
</div>
)}
{avlastKod && (
<p className="mt-2 text-[12px] font-semibold text-[#1E6B34]">
Avläst {avlastKod.typ}: {avlastKod.varde}
{avlastKod.format ? ` (${avlastKod.format})` : ""} kontrollera värdet nedan.
</p>
)}
{kodFel && <p className="mt-2 text-[12px] font-semibold text-[#8B1A1A]">{kodFel}</p>}
</Panel>
<Panel rubrik="Objektets identitet">
<TextFalt label={metod} varde={identifierare} satt={setIdentifierare} platshallare="T.ex. ABC123" />
<TextFalt label="Beskrivning (märke, modell, år)" varde={beskrivning} satt={setBeskrivning} platshallare="T.ex. Volvo XC60 D4 2019" />
<TextFalt label="Kund (valfritt)" varde={kund} satt={setKund} platshallare="T.ex. Anders Svensson" />
<StorKnapp disabled={!identifierare.trim()} onClick={() => setSteg("bekrafta")}>
Fortsätt
</StorKnapp>
</Panel>
</FelsokningSkal>
);
}
if (steg === "bekrafta") {
return (
<FelsokningSkal rubrik="Bekräfta objekt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-[12px] font-semibold uppercase tracking-widest text-[#4A5560]">{objektet.typ}</p>
<p className="mt-1 text-[20px] font-semibold">{objektet.beskrivning}</p>
<p className="mt-1 text-[15px] font-semibold text-[#00437A]">{objektet.identifierare}</p>
<p className="mt-1 text-[12px] text-[#4A5560]">Identifierad via: {objektet.identifieringsmetod}</p>
{objektet.kund && <p className="mt-1 text-[14px] text-[#333333]">Kund: {objektet.kund}</p>}
</Panel>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setSteg("identifiera")}>
Ändra
</StorKnapp>
<StorKnapp onClick={() => setSteg("felbeskrivning")}>Rätt objekt fortsätt</StorKnapp>
</div>
</FelsokningSkal>
);
}
const metodik = fel.trim() ? valjMetodik(fel) : undefined;
return (
<FelsokningSkal rubrik="Beskriv felet" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel rubrik={`${objektet.beskrivning} · ${objektet.identifierare}`}>
<TextFalt
label="Kundens/användarens felbeskrivning"
varde={fel}
satt={setFel}
platshallare="T.ex. Bilen vibrerar runt 88 km/h"
flerRad
rost
/>
{metodik && (
<p className="mb-3 text-[12px] font-semibold text-[#4A5560]">
Vald metodik: <span className="text-[#00437A]">{metodik.namn}</span>
</p>
)}
<StorKnapp
disabled={!fel.trim() || startar}
onClick={async () => {
setStartar(true);
// Träffar nyckelordsvalet ingen specifik metodik får orkesterns
// klassificerare (Haiku 4.5) välja; i lokalt läge blir det generisk.
let metodikId: string | undefined;
if (valjMetodik(fel).id === "generisk") {
metodikId = (await valjMetodikMedAi(fel.trim())) ?? undefined;
}
const id = skapaArende(objektet, fel.trim(), metodikId);
navigate(`/felsokning/arende/${id}`);
}}
>
{startar ? "Startar …" : "Starta arbetslogg"}
</StorKnapp>
</Panel>
</FelsokningSkal>
);
}
// Visuell granskning: den skannade arbetsordern till vänster, tolkade
// fält till höger. Konfidensen styr vad teknikern behöver göra:
// Grön ≥ 95 % godkänns automatiskt
// Gul 8095 % markerad för genomläsning
// Röd < 80 % kräver aktiv bekräftelse (eller redigering)
// Klick på ett fält markerar var i dokumentet värdet hittades.
function GranskaTolkning({
foto,
falt,
startar,
vidStart,
avbryt,
}: {
foto: string;
falt: TolkatFalt[];
startar: boolean;
vidStart: (falt: TolkatFalt[]) => void;
avbryt: () => void;
}) {
const [rader, setRader] = useState(falt.map((f) => ({ ...f, bekraftad: f.konfidens >= 0.8 })));
const [markerad, setMarkerad] = useState<string | null>(null);
const obekraftade = rader.filter((r) => !r.bekraftad);
const vald = rader.find((r) => r.id === markerad);
const grupper: ArbetsorderGrupp[] = ["Kund", "Personbil", "Verkstad", "Ärende"];
const uppdatera = (id: string, andring: Partial<(typeof rader)[number]>) =>
setRader((r) => r.map((rad) => (rad.id === id ? { ...rad, ...andring } : rad)));
return (
<div className="lg:grid lg:grid-cols-2 lg:items-start lg:gap-3">
<Panel rubrik="Skannad arbetsorder">
<div className="relative">
<img src={foto} alt="Skannad arbetsorder" className="w-full rounded border border-[#C6C6C6]" />
{vald?.omrade && (
<div
className="pointer-events-none absolute rounded-sm border-2 border-[#00437A] bg-[#00437A]/15"
style={{
left: `${vald.omrade.x * 100}%`,
top: `${vald.omrade.y * 100}%`,
width: `${vald.omrade.bredd * 100}%`,
height: `${vald.omrade.hojd * 100}%`,
}}
/>
)}
</div>
<p className="mt-1 text-[11px] text-[#707070]">
{vald?.omrade
? `Markerat: ${vald.etikett} (ungefärlig position)`
: "Klicka på ett fält för att se var i dokumentet värdet hittades."}
</p>
</Panel>
<div>
{grupper.map((grupp) => {
const iGrupp = rader.filter((r) => r.grupp === grupp);
if (iGrupp.length === 0) return null;
return (
<Panel key={grupp} rubrik={grupp}>
{iGrupp.map((rad) => {
const niva = rad.konfidens >= 0.95 ? "gron" : rad.konfidens >= 0.8 ? "gul" : "rod";
return (
<div
key={rad.id}
onClick={() => setMarkerad(rad.id)}
className={`mb-1.5 cursor-pointer rounded border p-2 ${
markerad === rad.id ? "border-[#00437A]" : "border-transparent"
} ${niva === "gul" ? "bg-[#FFF8E1]" : niva === "rod" ? "bg-[#FBE9E9]" : "bg-white"}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#4A5560]">
{rad.etikett}
</span>
<span
className={`text-[11px] font-semibold ${
niva === "gron" ? "text-[#1E6B34]" : niva === "gul" ? "text-[#9A6700]" : "text-[#8B1A1A]"
}`}
>
{niva === "gron" ? "✓" : `${Math.round(rad.konfidens * 100)} %`}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<input
value={rad.varde}
onChange={(e) => uppdatera(rad.id, { varde: e.target.value, bekraftad: true })}
className="w-full rounded border border-[#ADADAD] bg-white px-2 py-1 text-[13px] focus:border-[#00437A] focus:outline-none"
/>
{niva === "rod" && !rad.bekraftad && (
<button
onClick={(e) => {
e.stopPropagation();
uppdatera(rad.id, { bekraftad: true });
}}
className="whitespace-nowrap rounded border border-[#6E1414] bg-[#8B1A1A] px-3 py-1 text-[12px] font-semibold text-white"
>
Bekräfta
</button>
)}
</div>
</div>
);
})}
</Panel>
);
})}
{obekraftade.length > 0 && (
<p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">
{obekraftade.length} osäkra fält kräver bekräftelse eller korrigering innan diagnosen startar.
</p>
)}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={avbryt}>
Skanna om
</StorKnapp>
<StorKnapp disabled={obekraftade.length > 0 || startar} onClick={() => vidStart(rader)}>
{startar ? "Startar …" : "Starta diagnos"}
</StorKnapp>
</div>
</div>
</div>
);
}
@@ -0,0 +1,238 @@
// Arbetsledarvyn: organisationens alla ärenden med status och statistik.
// Endast arbetsledare/admin (servern verifierar behörigheten). Ett ärende
// kan hämtas till den här enheten — händelserna flätas ihop konfliktfritt
// med eventuell lokal kopia.
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Arende } from "@/felsokning/domain";
import {
hamtaAnvandare,
hamtaFelorsaksstatistik,
hamtaOversikt,
plattformAktiv,
plattformFetch,
plattformKonto,
type OversiktsRad,
type PlattformAnvandare,
} from "@/felsokning/plattform";
import { nyLoggPost } from "@/felsokning/domain";
import { flataIhop } from "@/felsokning/synk";
import { useFelsokning } from "@/felsokning/store";
import { formateraTid } from "@/felsokning/projektioner";
import { FelsokningSkal, Panel, StorKnapp } from "@/felsokning/ui";
import { tidDatum, tidKlockslag } from "@/felsokning/format";
// Omfördelning: arbetsledaren pekar ut ny ansvarig tekniker — loggas som
// append-only-händelsen ansvarig_satt i ärendets logg (organisationsintern,
// delas aldrig i kund-/partnervyer).
function Omfordela({ rad, vidKlar }: { rad: OversiktsRad; vidKlar: () => void }) {
const [oppen, setOppen] = useState(false);
const [personer, setPersoner] = useState<PlattformAnvandare[]>([]);
const konto = plattformKonto();
if (!oppen) {
return (
<StorKnapp
variant="sekundar"
onClick={() => {
setOppen(true);
hamtaAnvandare().then(setPersoner).catch(() => setPersoner([]));
}}
>
Omfördela
</StorKnapp>
);
}
return (
<div className="col-span-2 rounded border border-[#C6C6C6] bg-white p-2">
<p className="mb-2 text-[12px] font-semibold uppercase text-[#4A5560]">Ny ansvarig tekniker</p>
<div className="grid grid-cols-2 gap-2">
{personer.map((person) => (
<button
key={person.id}
className="min-h-9 rounded border border-[#ADADAD] font-semibold text-[#333333] hover:border-[#00437A]"
onClick={async () => {
const post = nyLoggPost(konto?.namn ?? "arbetsledare", {
typ: "ansvarig_satt",
ansvarig: person.namn,
});
await plattformFetch(`/api/arenden/${rad.id}/handelser`, {
method: "POST",
body: JSON.stringify({ handelser: [post] }),
});
setOppen(false);
vidKlar();
}}
>
{person.namn}
</button>
))}
<button
className="min-h-9 rounded border border-[#ADADAD] font-semibold text-[#4A5560]"
onClick={() => setOppen(false)}
>
Avbryt
</button>
</div>
</div>
);
}
export default function Oversikt() {
const navigate = useNavigate();
const { arenden, laggInArende, sammanfoga } = useFelsokning();
const [rader, setRader] = useState<OversiktsRad[] | null>(null);
const [felorsaker, setFelorsaker] = useState<{ orsak: string; antal: number }[]>([]);
const [fel, setFel] = useState("");
const konto = plattformKonto();
const behorig = plattformAktiv() && (konto?.roll === "arbetsledare" || konto?.roll === "admin");
useEffect(() => {
if (!behorig) return;
hamtaOversikt()
.then(setRader)
.catch(() => setFel("Kunde inte hämta översikten — kontrollera inloggningen."));
// Flottdata: återkommande felorsaker över organisationen.
hamtaFelorsaksstatistik().then(setFelorsaker).catch(() => setFelorsaker([]));
}, [behorig]);
if (!behorig) {
return (
<FelsokningSkal rubrik="Organisationsöversikt" tillbaka={{ till: "/felsokning", text: "Ärenden" }}>
<Panel>
<p className="text-[14px] text-[#333333]">
Översikten kräver arbetsledar- eller administratörsbehörighet plattformen.
</p>
</Panel>
</FelsokningSkal>
);
}
// Hämtar ärendets händelser och materialiserar det lokalt (flätning om
// en lokal kopia redan finns), sedan öppnas arbetsytan.
const oppna = async (rad: OversiktsRad) => {
try {
const res = await plattformFetch(`/api/arenden/${rad.id}/handelser`);
if (!res.ok) throw new Error(`Fel ${res.status}`);
const { handelser } = (await res.json()) as {
handelser: { id: string; tidpunkt: string; anvandare: string; handelse: Arende["handelser"][number]["handelse"] }[];
};
const fjarr = handelser.map((h) => ({ ...h, tidpunkt: new Date(h.tidpunkt).toISOString() }));
const lokal = arenden[rad.id];
if (lokal) {
sammanfoga(rad.id, flataIhop(lokal.handelser, fjarr));
} else {
laggInArende({
id: rad.id,
nummer: rad.nummer,
skapad: new Date(rad.skapad).toISOString(),
delningskod: rad.delningskod ?? undefined,
metodikId: rad.metodik_id ?? undefined,
handelser: fjarr,
});
}
navigate(`/felsokning/arende/${rad.id}`);
} catch {
setFel("Kunde inte hämta ärendet.");
}
};
const pagaende = rader?.filter((r) => !r.avslutat) ?? [];
const avslutade = rader?.filter((r) => r.avslutat) ?? [];
const medelLedtidMs =
avslutade.length > 0
? avslutade.reduce(
(summa, r) =>
summa + (r.forsta && r.senaste ? new Date(r.senaste).getTime() - new Date(r.forsta).getTime() : 0),
0,
) / avslutade.length
: 0;
return (
<FelsokningSkal
rubrik="Organisationsöversikt"
tillbaka={{ till: "/felsokning", text: "Ärenden" }}
hoger={<span className="text-[12px] font-semibold text-[#A9C3DE]">{konto?.organisation}</span>}
>
{fel && <p className="mb-3 font-semibold text-[#8B1A1A]">{fel}</p>}
{!rader && !fel && <p className="animate-pulse text-[14px] text-[#4A5560]">Hämtar </p>}
{rader && (
<>
<Panel rubrik="Statistik">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<p className="text-[20px] font-semibold">{rader.length}</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Totalt</p>
</div>
<div>
<p className="text-[20px] font-semibold text-[#00437A]">{pagaende.length}</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Pågående</p>
</div>
<div>
<p className="text-[20px] font-semibold">{avslutade.length}</p>
<p className="text-[12px] font-semibold uppercase text-[#4A5560]">Avslutade</p>
</div>
</div>
{avslutade.length > 0 && (
<p className="mt-3 text-center text-[#333333]">
Genomsnittlig ledtid för avslutade: <span className="font-semibold">{formateraTid(medelLedtidMs)}</span>
</p>
)}
</Panel>
{felorsaker.length > 0 && (
<Panel rubrik="Felorsaksstatistik — återkommande fel i organisationen">
{felorsaker.map((rad) => (
<div key={rad.orsak} className="flex items-center gap-2 py-0.5 text-[13px]">
<span className="w-52 shrink-0 text-[#333333]">{rad.orsak}</span>
<span
className="h-3 rounded-sm bg-[#00437A]"
style={{ width: `${Math.min(100, (rad.antal / felorsaker[0].antal) * 100)}%`, minWidth: 6 }}
/>
<span className="font-semibold">{rad.antal}</span>
</div>
))}
<p className="mt-2 text-[11px] text-[#707070]">
Ur dokumenterade felorsaksanalyser visar mönster som slitage, tidigare reparationer eller möjliga konstruktionsproblem.
</p>
</Panel>
)}
{rader.length === 0 && <p className="text-center text-[14px] text-[#4A5560]">Inga ärenden i organisationen ännu.</p>}
{rader.map((rad) => (
<div key={rad.id} className="mb-3 rounded border border-[#C6C6C6] bg-[#F7F7F7] p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-[12px] font-semibold uppercase tracking-widest text-[#4A5560]">
Ärende #{rad.nummer}
</span>
<span
className={`rounded px-2 py-0.5 text-[11px] font-semibold uppercase ${
rad.avslutat ? "bg-[#8A94A0] text-white" : "bg-[#00437A] text-white"
}`}
>
{rad.avslutat ? "Avslutat" : "Pågående"}
</span>
</div>
<p className="mt-1 text-[15px] font-semibold">{rad.objekt ?? "Okänt objekt"}</p>
{rad.felbeskrivning && <p className="text-[14px] text-[#333333]">{rad.felbeskrivning}</p>}
<p className="mt-1 text-[12px] text-[#707070]">
Ansvarig: <span className="font-semibold text-[#333333]">{rad.ansvarig ?? rad.skapare ?? "—"}</span>
{` · ${rad.antal_handelser} händelser`}
{rad.senaste ? ` · senast ${tidDatum(rad.senaste)} ${tidKlockslag(rad.senaste)}` : ""}
</p>
<div className="mt-2 grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => oppna(rad)}>
Öppna ärendet
</StorKnapp>
{!rad.avslutat && <Omfordela rad={rad} vidKlar={() => hamtaOversikt().then(setRader)} />}
</div>
</div>
))}
</>
)}
</FelsokningSkal>
);
}
@@ -0,0 +1,139 @@
// Publik Live Share-sida: nås via delningskoden utan inloggning.
// Läser genom backend-funktionen hamta_delat_arende (security definer) —
// tabellerna exponeras aldrig, och interna poster är redan bortfiltrerade
// på serversidan. Pollar för liveuppdatering.
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import type { Arende, Handelse } from "@/felsokning/domain";
import { DelatArendeVy } from "@/felsokning/DelatArendeVy";
import { FelsokningSkal, Panel } from "@/felsokning/ui";
interface DelatSvar {
arende: { id: string; nummer: number; skapad: string };
handelser: { id: string; tidpunkt: string; anvandare: string; handelse: Handelse }[];
niva?: "kund" | "partner" | "intern";
}
const NIVA_NOTIS: Record<string, string> = {
kund: "Skrivskyddad livevy från verkstaden — sidan uppdateras automatiskt när ny information registreras.",
partner:
"Skrivskyddad livevy (partnernivå) — inkluderar tekniska hypoteser, tydligt märkta som ej verifierade. Uppdateras automatiskt.",
intern: "Skrivskyddad livevy (intern nivå) — full insyn i ärendet. Uppdateras automatiskt.",
};
async function hamtaDelat(kod: string): Promise<{ arende: Arende; niva: string } | undefined> {
const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform");
let data: unknown;
if (plattformAktiv()) {
// Självhostat: publik delningsendpoint i plattformstjänsten.
const res = await fetch(`${PLATTFORM_URL}/api/delad/${kod}`);
if (!res.ok) return undefined;
data = await res.json();
} else {
const { supabase } = await import("@/integrations/supabase/client");
const { data: rpcData, error } = await (supabase as unknown as {
rpc: (fn: string, args: object) => Promise<{ data: unknown; error: unknown }>;
}).rpc("hamta_delat_arende", { kod });
if (error || !rpcData) return undefined;
data = rpcData;
}
const svar = data as DelatSvar;
return {
niva: svar.niva ?? "kund",
arende: {
id: svar.arende.id,
nummer: svar.arende.nummer,
skapad: new Date(svar.arende.skapad).toISOString(),
handelser: svar.handelser.map((rad) => ({
id: rad.id,
tidpunkt: new Date(rad.tidpunkt).toISOString(),
anvandare: rad.anvandare,
handelse: rad.handelse,
})),
},
};
}
export default function PublikDelning() {
const { kod } = useParams<{ kod: string }>();
const [arende, setArende] = useState<Arende | undefined>();
const [niva, setNiva] = useState<string>("kund");
const [status, setStatus] = useState<"laddar" | "klar" | "saknas">("laddar");
const [nu, setNu] = useState(() => new Date().toISOString());
useEffect(() => {
if (!kod) return;
let aktiv = true;
const hamta = async () => {
try {
const resultat = await hamtaDelat(kod);
if (!aktiv) return;
setArende(resultat?.arende);
if (resultat) setNiva(resultat.niva);
setStatus(resultat ? "klar" : "saknas");
setNu(new Date().toISOString());
} catch {
if (aktiv) setStatus("saknas");
}
};
hamta();
const timer = setInterval(hamta, 20000);
return () => {
aktiv = false;
clearInterval(timer);
};
}, [kod]);
if (status !== "klar" || !arende) {
return (
<FelsokningSkal rubrik="Delat ärende">
<Panel>
<p className="text-[14px] text-[#333333]">
{status === "laddar"
? "Hämtar ärendet …"
: "Ärendet är inte tillgängligt. Kontrollera länken med verkstaden — delningen kan ha stängts av."}
</p>
</Panel>
</FelsokningSkal>
);
}
// Kunden kan svara på ett åtgärdsförslag direkt i sin länk. Endast
// kundnivån får svara — servern gör samma kontroll igen.
const skickaBeslut =
niva === "kund" && kod
? async (beslut: "godkant" | "avbojt", kommentar: string) => {
const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform");
if (!plattformAktiv()) return "Beskedet kan inte lämnas här — kontakta verkstaden.";
try {
const res = await fetch(`${PLATTFORM_URL}/api/delad/${kod}/beslut`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ beslut, kommentar }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
return data.error ?? "Beskedet kunde inte registreras — försök igen.";
}
// Hämta om direkt så kvittensen syns.
const uppdaterat = await hamtaDelat(kod);
if (uppdaterat) setArende(uppdaterat.arende);
return null;
} catch {
return "Beskedet kunde inte skickas — kontrollera anslutningen.";
}
}
: undefined;
return (
<DelatArendeVy
arende={arende}
nu={nu}
notis={NIVA_NOTIS[niva] ?? NIVA_NOTIS.kund}
redanFiltrerad
vidBeslut={skickaBeslut}
delningskod={kod}
/>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("example", () => {
it("should pass", () => {
expect(true).toBe(true);
});
});
+20
View File
@@ -0,0 +1,20 @@
import "@testing-library/jest-dom";
// Tester som kör servermoduler väljer node-miljön och har ingen window.
if (typeof window === "undefined") {
// Inget att förbereda — DOM-hjälpmedlen används inte där.
} else {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
});
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />