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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
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;
|
||||
@@ -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;
|
||||
@@ -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 så det har varit i över 70 år, och så 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;
|
||||
@@ -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;
|
||||
@@ -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 gå 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 på{" "}
|
||||
<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 00–199 99 för att gå 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 så 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 på "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 på 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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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; // 0–1
|
||||
// Ungefärlig position i den skannade bilden, normaliserad 0–1.
|
||||
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 0–1,
|
||||
// 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; // 0–1
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 85–95 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 85–95 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 80–100 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 80–100 km/h.",
|
||||
modell: "demo",
|
||||
}),
|
||||
post(86, {
|
||||
typ: "atgardsforslag",
|
||||
beskrivning: "Balansera om höger framhjul och genomför ny provkörning i 80–100 km/h.",
|
||||
uppskattadKostnad: "890 kr inkl. moms",
|
||||
}),
|
||||
post(87, { typ: "kategori_byte", kategori: "kundkontakt" }),
|
||||
post(87, {
|
||||
typ: "kundbeslut",
|
||||
beslut: "godkant",
|
||||
kanal: "Telefon",
|
||||
kommentar: "Kunden godkände ombalansering.",
|
||||
}),
|
||||
post(87, { typ: "kategori_byte", kategori: "aktiv_felsokning" }),
|
||||
post(87, {
|
||||
typ: "atgard_utford",
|
||||
beskrivning: "Balanserade om höger framhjul och monterade ny balansvikt på fälgens insida.",
|
||||
delar: "Balansvikter 40 g",
|
||||
utford: true,
|
||||
}),
|
||||
post(88, {
|
||||
typ: "kvalitetskontroll",
|
||||
resultat: "symptomet_borta",
|
||||
beskrivning: "Ny provkörning 80–110 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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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; // 0–1
|
||||
}
|
||||
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 10–15 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 10–15 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);
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -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_"));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -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;
|
||||
@@ -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 på 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;
|
||||
@@ -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 så 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;
|
||||
@@ -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 på 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;
|
||||
@@ -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;
|
||||
@@ -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 på 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;
|
||||
@@ -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;
|
||||
@@ -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 på 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 på 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 på {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 92–108 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 80–110 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 & 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 på 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 gå 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 på 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 på begäran och skriver utkast till överlämningen. Den
|
||||
skiljer alltid på 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 på
|
||||
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 på 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 80–95 % 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 på 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("example", () => {
|
||||
it("should pass", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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: () => {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||