Procházet zdrojové kódy

Real-time push notifications for customer actions (#3)

* push notifications vai websocket

* quotation notice

* remove console logs
Bernt Christian Egeland před 7 měsíci
rodič
revize
6f6f26f3ba

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 430 - 52
package-lock.json


+ 6 - 2
package.json

@@ -11,7 +11,8 @@
     "lint:fix": "biome lint . --write",
     "format": "biome format . --write",
     "check": "biome check .",
-    "check:fix": "biome check . --write"
+    "check:fix": "biome check . --write",
+    "prepare": "next-ws patch"
   },
   "dependencies": {
     "@aws-sdk/client-ses": "^3.992.0",
@@ -39,6 +40,7 @@
     "mailgun.js": "^12.7.0",
     "next": "16.1.6",
     "next-themes": "^0.4.6",
+    "next-ws": "^2.1.16",
     "nodemailer": "^8.0.1",
     "pdf-lib": "^1.17.1",
     "postmark": "^4.0.5",
@@ -55,6 +57,7 @@
     "sonner": "^2.0.7",
     "stripe": "^20.3.1",
     "tailwind-merge": "^3.4.0",
+    "ws": "^8.19.0",
     "zod": "^4.3.6",
     "zustand": "^5.0.11"
   },
@@ -67,6 +70,7 @@
     "@types/nodemailer": "^7.0.9",
     "@types/react": "^19",
     "@types/react-dom": "^19",
+    "@types/ws": "^8.18.1",
     "dotenv": "^17.2.4",
     "shadcn": "^3.8.5",
     "tailwindcss": "^4",
@@ -77,4 +81,4 @@
     "fast-xml-parser": ">=5.3.6",
     "qs": ">=6.14.2"
   }
-}
+}

+ 21 - 0
prisma/migrations/20260220182239_push_notifications/migration.sql

@@ -0,0 +1,21 @@
+-- CreateTable
+CREATE TABLE "notifications" (
+    "id" TEXT NOT NULL,
+    "type" TEXT NOT NULL,
+    "title" TEXT NOT NULL,
+    "message" TEXT NOT NULL,
+    "entityType" TEXT NOT NULL,
+    "entityId" TEXT NOT NULL,
+    "entityUrl" TEXT NOT NULL,
+    "read" BOOLEAN NOT NULL DEFAULT false,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "notifications_organizationId_createdAt_idx" ON "notifications"("organizationId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "notifications_organizationId_read_idx" ON "notifications"("organizationId", "read");

+ 17 - 0
prisma/schema.prisma

@@ -769,3 +769,20 @@ model InspectionQuoteRequest {
   @@index([inspectionId])
   @@map("inspection_quote_requests")
 }
+
+model Notification {
+  id             String   @id @default(cuid())
+  type           String
+  title          String
+  message        String
+  entityType     String
+  entityId       String
+  entityUrl      String
+  read           Boolean  @default(false)
+  organizationId String
+  createdAt      DateTime @default(now())
+
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([organizationId, read])
+  @@map("notifications")
+}

+ 3 - 0
src/app/(authenticated)/layout.tsx

@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
 import { AppSidebar } from "@/components/app-sidebar";
 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
 import { SearchCommand } from "@/features/search/Components/SearchCommand";
+import { NotificationInitializer } from "@/features/notifications/Components/NotificationInitializer";
 import { ConfirmProvider } from "@/components/confirm-dialog";
 import { getLayoutData } from "@/lib/get-layout-data";
 import { getFeatures, isCloudMode } from "@/lib/features";
@@ -69,9 +70,11 @@ export default async function DashboardLayout({
           features={features}
           canAccessSettings={canAccessSettings}
           canAccessReports={canAccessReports}
+          isAdminOrOwner={isOwnerOrAdmin}
         />
         <SidebarInset>{children}</SidebarInset>
         <SearchCommand />
+        {isOwnerOrAdmin && <NotificationInitializer />}
       </ConfirmProvider>
       </DateSettingsProvider>
     </SidebarProvider>

+ 27 - 0
src/app/(public)/share/inspection/[orgId]/[token]/inspection-view.tsx

@@ -75,6 +75,7 @@ export function InspectionView({
   publicToken,
   orgId,
   hasExistingQuoteRequest,
+  quoteShareUrl,
 }: {
   inspection: InspectionRecord;
   workshop: { name: string; address: string; phone: string; email: string };
@@ -86,6 +87,7 @@ export function InspectionView({
   publicToken: string;
   orgId: string;
   hasExistingQuoteRequest: boolean;
+  quoteShareUrl?: string;
 }) {
   const fmt = dateFormat || DEFAULT_DATE_FORMAT;
   const tz = timezone || "America/New_York";
@@ -284,6 +286,31 @@ export function InspectionView({
         </div>
       </div>
 
+      {/* Quote available banner */}
+      {quoteShareUrl && (
+        <div className="mb-6 flex items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-4">
+          <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100">
+            <FileText className="h-4 w-4 text-emerald-600" />
+          </div>
+          <div className="min-w-0 flex-1">
+            <p className="text-sm font-medium text-emerald-900">
+              A quote has been prepared for this inspection
+            </p>
+            <p className="mt-0.5 text-xs text-emerald-700/70">
+              Review the quote to see pricing and approve the recommended work.
+            </p>
+          </div>
+          <a
+            href={quoteShareUrl}
+            className="inline-flex shrink-0 items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors hover:opacity-90"
+            style={{ backgroundColor: primaryColor }}
+          >
+            <FileText className="h-4 w-4" />
+            View Quote
+          </a>
+        </div>
+      )}
+
       {/* Action buttons */}
       <div className="mb-6 flex flex-wrap gap-3">
         {hasIssues && (

+ 11 - 0
src/app/(public)/share/inspection/[orgId]/[token]/page.tsx

@@ -33,6 +33,11 @@ export default async function PublicInspectionPage({
       },
       template: { select: { name: true } },
       items: { orderBy: { sortOrder: "asc" } },
+      quotes: {
+        where: { publicToken: { not: null } },
+        select: { publicToken: true },
+        take: 1,
+      },
     },
   });
 
@@ -103,6 +108,11 @@ export default async function PublicInspectionPage({
     })),
   };
 
+  const linkedQuote = inspection.quotes?.[0];
+  const quoteShareUrl = linkedQuote?.publicToken
+    ? `/share/quote/${orgId}/${linkedQuote.publicToken}`
+    : undefined;
+
   return (
     <InspectionView
       inspection={publicInspection}
@@ -115,6 +125,7 @@ export default async function PublicInspectionPage({
       publicToken={token}
       orgId={orgId}
       hasExistingQuoteRequest={!!existingRequest}
+      quoteShareUrl={quoteShareUrl}
     />
   );
 }

+ 12 - 1
src/app/api/share/invoice/[orgId]/[token]/verify/route.ts

@@ -3,6 +3,7 @@ import { z } from "zod";
 import { db } from "@/lib/db";
 import { getPaymentProvider, getEnabledProviders } from "@/lib/payment-providers";
 import { rateLimit } from "@/lib/rate-limit";
+import { notify } from "@/lib/notify";
 
 const verifySchema = z.object({
   provider: z.enum(["stripe", "vipps", "paypal"]),
@@ -22,7 +23,7 @@ export async function POST(
     const record = await db.serviceRecord.findUnique({
       where: { publicToken: token },
       include: {
-        vehicle: { select: { organizationId: true } },
+        vehicle: { select: { id: true, organizationId: true, customer: { select: { name: true } } } },
       },
     });
 
@@ -78,6 +79,16 @@ export async function POST(
           serviceRecordId: record.id,
         },
       });
+
+      notify({
+        organizationId: orgId,
+        type: "invoice_payment",
+        title: "Invoice Payment Received",
+        message: `${record.vehicle.customer?.name || "A customer"} paid ${result.amount.toFixed(2)} for invoice ${record.invoiceNumber || record.title}`,
+        entityType: "invoice",
+        entityId: record.id,
+        entityUrl: `/vehicles/${record.vehicle.id}?tab=service&record=${record.id}`,
+      });
     }
 
     return NextResponse.json({

+ 148 - 0
src/app/api/ws/route.ts

@@ -0,0 +1,148 @@
+import type { IncomingMessage } from "node:http";
+import type WebSocket from "ws";
+import { cookies } from "next/headers";
+import { db } from "@/lib/db";
+import { notificationBus } from "@/lib/notification-bus";
+
+// Required so Next.js route validator recognizes this as a valid route module
+export function GET() {
+  return new Response("WebSocket endpoint", { status: 426 });
+}
+
+export interface TaggedWebSocket extends WebSocket {
+  userId: string;
+  organizationId: string;
+  role: string;
+  isAlive: boolean;
+}
+
+// Track all authenticated clients in a Set so the bus listener can broadcast
+const clients = new Set<TaggedWebSocket>();
+
+// Single listener on the global bus — broadcasts to matching org clients
+notificationBus.on("notification", (notification: { organizationId: string }) => {
+  const payload = JSON.stringify({ type: "notification", data: notification });
+  for (const client of clients) {
+    if (
+      client.organizationId === notification.organizationId &&
+      client.readyState === 1 // OPEN
+    ) {
+      client.send(payload);
+    }
+  }
+});
+
+/**
+ * Resolve the session token from the Next.js cookie store.
+ * better-auth uses chunked cookies for large tokens (.0, .1, …)
+ * and prefixes with __Secure- when useSecureCookies is enabled.
+ */
+function getSessionToken(cookieStore: Awaited<ReturnType<typeof cookies>>): string | undefined {
+  const isSecure = process.env.NEXT_PUBLIC_APP_URL?.startsWith("https://");
+  const prefix = isSecure ? "__Secure-" : "";
+  const baseName = `${prefix}better-auth.session_token`;
+
+  // Try single cookie first
+  let raw = cookieStore.get(baseName)?.value;
+
+  // Try chunked cookies (.0, .1, .2, …)
+  if (!raw) {
+    let chunked = "";
+    for (let i = 0; ; i++) {
+      const chunk = cookieStore.get(`${baseName}.${i}`)?.value;
+      if (!chunk) break;
+      chunked += chunk;
+    }
+    if (chunked) raw = chunked;
+  }
+
+  if (!raw) return undefined;
+
+  // better-auth signs cookies as "token.signature" — strip the signature
+  const dotIndex = raw.indexOf(".");
+  return dotIndex > 0 ? raw.substring(0, dotIndex) : raw;
+}
+
+export function UPGRADE(ws: WebSocket, _server: unknown, _request: IncomingMessage) {
+  const client = ws as TaggedWebSocket;
+  client.isAlive = true;
+
+  (async () => {
+    try {
+      // next-ws patches cookies() to resolve WebSocket request cookies
+      const cookieStore = await cookies();
+
+      const sessionToken = getSessionToken(cookieStore);
+      if (!sessionToken) {
+        ws.close(4001, "No session token");
+        return;
+      }
+
+      // Look up session in DB
+      const session = await db.session.findUnique({
+        where: { token: sessionToken },
+        select: { userId: true, expiresAt: true },
+      });
+
+      if (!session || session.expiresAt < new Date()) {
+        ws.close(4001, "Invalid or expired session");
+        return;
+      }
+
+      // Get org membership
+      const activeOrgId = cookieStore.get("active-org-id")?.value;
+
+      const membership = activeOrgId
+        ? await db.organizationMember.findFirst({
+            where: { userId: session.userId, organizationId: activeOrgId },
+            select: { organizationId: true, role: true },
+          })
+        : await db.organizationMember.findFirst({
+            where: { userId: session.userId },
+            select: { organizationId: true, role: true },
+          });
+
+      if (!membership) {
+        ws.close(4001, "No organization");
+        return;
+      }
+
+      const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
+      if (!isAdminOrOwner) {
+        ws.close(4003, "Insufficient role");
+        return;
+      }
+
+      client.userId = session.userId;
+      client.organizationId = membership.organizationId;
+      client.role = membership.role;
+
+      clients.add(client);
+
+      // Ping/pong keepalive
+      const pingInterval = setInterval(() => {
+        if (!client.isAlive) {
+          clearInterval(pingInterval);
+          ws.terminate();
+          return;
+        }
+        client.isAlive = false;
+        ws.ping();
+      }, 30_000);
+
+      ws.on("pong", () => {
+        client.isAlive = true;
+      });
+
+      ws.on("close", () => {
+        clearInterval(pingInterval);
+        clients.delete(client);
+      });
+
+      ws.send(JSON.stringify({ type: "connected" }));
+    } catch (err) {
+      console.error("[WS] Auth error:", err);
+      ws.close(4500, "Auth error");
+    }
+  })();
+}

+ 7 - 1
src/components/app-sidebar.tsx

@@ -55,6 +55,7 @@ import { switchOrganization } from '@/features/team/Actions/switchOrganization'
 import { createNewOrganization } from '@/features/team/Actions/createNewOrganization'
 import type { PlanFeatures } from '@/lib/features'
 import { useTheme } from '@/components/theme-provider'
+import { NotificationBell, NotificationPanel } from '@/features/notifications/Components/NotificationPanel'
 
 const baseNavItems = [
   { title: 'Dashboard', url: '/', icon: LayoutDashboard },
@@ -81,6 +82,7 @@ export function AppSidebar({
   features,
   canAccessSettings = true,
   canAccessReports = true,
+  isAdminOrOwner = false,
   ...props
 }: React.ComponentProps<typeof Sidebar> & {
   companyLogo?: string
@@ -90,6 +92,7 @@ export function AppSidebar({
   features?: PlanFeatures
   canAccessSettings?: boolean
   canAccessReports?: boolean
+  isAdminOrOwner?: boolean
 }) {
   const pathname = usePathname()
   const router = useRouter()
@@ -144,7 +147,7 @@ export function AppSidebar({
     <Sidebar variant="floating" {...props}>
       <SidebarHeader>
         <SidebarMenu>
-          <SidebarMenuItem>
+          <SidebarMenuItem className="flex items-center gap-1">
             <DropdownMenu>
               <DropdownMenuTrigger asChild>
                 <SidebarMenuButton
@@ -196,6 +199,7 @@ export function AppSidebar({
                 </DropdownMenuItem>
               </DropdownMenuContent>
             </DropdownMenu>
+            {isAdminOrOwner && <NotificationBell />}
           </SidebarMenuItem>
         </SidebarMenu>
       </SidebarHeader>
@@ -297,6 +301,8 @@ export function AppSidebar({
         </SidebarMenu>
       </SidebarFooter>
 
+      {isAdminOrOwner && <NotificationPanel />}
+
       <Dialog open={showCreateOrg} onOpenChange={setShowCreateOrg}>
         <DialogContent className="sm:max-w-md">
           <DialogHeader>

+ 12 - 1
src/features/inspections/Actions/inspectionActions.ts

@@ -83,7 +83,18 @@ export async function getInspection(id: string) {
         },
         template: { select: { id: true, name: true } },
         items: { orderBy: { sortOrder: "asc" } },
-        quotes: { select: { id: true, quoteNumber: true, status: true } },
+        quotes: {
+          select: {
+            id: true, quoteNumber: true, status: true, createdAt: true,
+            user: { select: { name: true } },
+          },
+        },
+        quoteRequests: {
+          where: { status: "pending" },
+          select: { id: true, message: true, selectedItemIds: true, createdAt: true },
+          orderBy: { createdAt: "desc" as const },
+          take: 1,
+        },
       },
     });
     if (!inspection) throw new Error("Inspection not found");

+ 13 - 0
src/features/inspections/Actions/quoteRequestActions.ts

@@ -3,6 +3,7 @@
 import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { notify } from "@/lib/notify";
 import { z } from "zod";
 
 const createQuoteRequestSchema = z.object({
@@ -22,6 +23,7 @@ export async function createQuoteRequest(input: unknown) {
   // Verify the inspection exists and the token matches
   const inspection = await db.inspection.findFirst({
     where: { id: data.inspectionId, publicToken: data.publicToken },
+    include: { vehicle: { select: { customer: { select: { name: true } } } } },
   });
   if (!inspection) {
     return { success: false, error: "Inspection not found" };
@@ -44,6 +46,17 @@ export async function createQuoteRequest(input: unknown) {
     },
   });
 
+  const customerName = inspection.vehicle?.customer?.name || "A customer";
+  notify({
+    organizationId: inspection.organizationId,
+    type: "inspection_quote_request",
+    title: "Quote Requested from Inspection",
+    message: `${customerName} requested a quote for ${data.selectedItemIds.length} item(s) from an inspection`,
+    entityType: "inspection",
+    entityId: inspection.id,
+    entityUrl: `/inspections/${inspection.id}`,
+  });
+
   return { success: true };
 }
 

+ 57 - 2
src/features/inspections/Components/InspectionPageClient.tsx

@@ -17,7 +17,7 @@ import {
 } from "@/components/ui/dropdown-menu";
 import {
   ArrowLeft, Camera, Check, CheckCircle2, ClipboardCheck, Download, FileText, Loader2,
-  MoreVertical, Share2, Trash2, X,
+  MessageSquareText, MoreVertical, Share2, Trash2, X,
 } from "lucide-react";
 import { toast } from "sonner";
 import { updateInspectionItem, completeInspection, deleteInspection } from "../Actions/inspectionActions";
@@ -56,7 +56,8 @@ interface InspectionData {
   };
   template: { id: string; name: string };
   items: InspectionItem[];
-  quotes: { id: string; quoteNumber: string | null; status: string }[];
+  quotes: { id: string; quoteNumber: string | null; status: string; createdAt: Date; user: { name: string } }[];
+  quoteRequests: { id: string; message: string | null; selectedItemIds: string[]; createdAt: Date }[];
 }
 
 const conditionConfig: Record<Condition, { label: string; color: string; bgColor: string; icon: React.ReactNode }> = {
@@ -440,6 +441,7 @@ export function InspectionPageClient({
   const failCount = inspection.items.filter((i) => i.condition === "fail").length;
   const attentionCount = inspection.items.filter((i) => i.condition === "attention").length;
   const hasIssueItems = failCount > 0 || attentionCount > 0;
+  const pendingQuoteRequest = inspection.quoteRequests?.[0] ?? null;
 
   const handleComplete = () => {
     startTransition(async () => {
@@ -599,6 +601,59 @@ export function InspectionPageClient({
         </div>
       </div>
 
+      {/* Quote created banner */}
+      {inspection.quotes.length > 0 ? (
+        <Card className="border-emerald-500/30 bg-emerald-50 dark:bg-emerald-950/20">
+          <CardContent className="flex items-center gap-3 py-3">
+            <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500/15">
+              <FileText className="h-4 w-4 text-emerald-600" />
+            </div>
+            <div className="min-w-0 flex-1">
+              <p className="text-sm font-medium text-emerald-900 dark:text-emerald-200">
+                Quote created by {inspection.quotes[0].user.name} on {formatDate(new Date(inspection.quotes[0].createdAt))}
+              </p>
+            </div>
+            <Button
+              size="sm"
+              variant="outline"
+              className="shrink-0 border-emerald-500/30 text-emerald-900 hover:bg-emerald-100 dark:text-emerald-200 dark:hover:bg-emerald-900/30"
+              onClick={() => router.push(`/quotes/${inspection.quotes[0].id}`)}
+            >
+              View Quote
+            </Button>
+          </CardContent>
+        </Card>
+      ) : pendingQuoteRequest ? (
+        <Card className="border-amber-500/30 bg-amber-50 dark:bg-amber-950/20">
+          <CardContent className="flex items-start gap-3 py-3">
+            <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-500/15">
+              <MessageSquareText className="h-4 w-4 text-amber-600" />
+            </div>
+            <div className="min-w-0 flex-1">
+              <p className="text-sm font-medium text-amber-900 dark:text-amber-200">
+                Customer requested a quote for {pendingQuoteRequest.selectedItemIds.length} item(s)
+              </p>
+              {pendingQuoteRequest.message && (
+                <p className="mt-0.5 text-sm text-amber-800/80 dark:text-amber-300/70">
+                  &ldquo;{pendingQuoteRequest.message}&rdquo;
+                </p>
+              )}
+              <div className="mt-2">
+                <Button
+                  size="sm"
+                  variant="outline"
+                  className="border-amber-500/30 text-amber-900 hover:bg-amber-100 dark:text-amber-200 dark:hover:bg-amber-900/30"
+                  onClick={() => router.push(`/quotes/new?fromInspection=${inspection.id}`)}
+                >
+                  <FileText className="mr-1 h-3.5 w-3.5" />
+                  Create Quote
+                </Button>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      ) : null}
+
       {/* Inspection sections */}
       <div className="space-y-4">
         {sectionOrder.map((sectionName) => {

+ 63 - 15
src/features/inspections/Components/NewInspectionDialog.tsx

@@ -11,7 +11,11 @@ import { Label } from "@/components/ui/label";
 import {
   Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
 } from "@/components/ui/select";
-import { Loader2 } from "lucide-react";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
+import { Check, ChevronsUpDown, Loader2, Settings } from "lucide-react";
+import { cn } from "@/lib/utils";
+import Link from "next/link";
 import { toast } from "sonner";
 import { createInspection } from "../Actions/inspectionActions";
 import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
@@ -50,6 +54,7 @@ export function NewInspectionDialog({
   const [vehicleId, setVehicleId] = useState(preselectedVehicleId || "");
   const [templateId, setTemplateId] = useState(defaultTemplate?.id || "");
   const [mileage, setMileage] = useState("");
+  const [vehiclePopoverOpen, setVehiclePopoverOpen] = useState(false);
 
   useEffect(() => {
     if (open && vehicles.length === 0) {
@@ -111,24 +116,67 @@ export function NewInspectionDialog({
                 Loading vehicles...
               </div>
             ) : (
-              <Select value={vehicleId} onValueChange={setVehicleId} required>
-                <SelectTrigger>
-                  <SelectValue placeholder="Select vehicle" />
-                </SelectTrigger>
-                <SelectContent>
-                  {vehicles.map((v) => (
-                    <SelectItem key={v.id} value={v.id}>
-                      {v.year} {v.make} {v.model}
-                      {v.licensePlate ? ` (${v.licensePlate})` : ""}
-                    </SelectItem>
-                  ))}
-                </SelectContent>
-              </Select>
+              <Popover open={vehiclePopoverOpen} onOpenChange={setVehiclePopoverOpen}>
+                <PopoverTrigger asChild>
+                  <Button
+                    variant="outline"
+                    role="combobox"
+                    aria-expanded={vehiclePopoverOpen}
+                    className="w-full justify-between font-normal"
+                  >
+                    {vehicleId
+                      ? (() => {
+                          const v = vehicles.find((v) => v.id === vehicleId);
+                          return v
+                            ? `${v.year} ${v.make} ${v.model}${v.licensePlate ? ` (${v.licensePlate})` : ""}`
+                            : "Select vehicle";
+                        })()
+                      : "Select vehicle"}
+                    <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+                  </Button>
+                </PopoverTrigger>
+                <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+                  <Command>
+                    <CommandInput placeholder="Search vehicles..." />
+                    <CommandList>
+                      <CommandEmpty>No vehicles found.</CommandEmpty>
+                      <CommandGroup>
+                        {vehicles.map((v) => {
+                          const label = `${v.year} ${v.make} ${v.model}${v.licensePlate ? ` (${v.licensePlate})` : ""}`;
+                          return (
+                            <CommandItem
+                              key={v.id}
+                              value={label}
+                              onSelect={() => {
+                                setVehicleId(v.id);
+                                setVehiclePopoverOpen(false);
+                              }}
+                            >
+                              <Check className={cn("mr-2 h-4 w-4", vehicleId === v.id ? "opacity-100" : "opacity-0")} />
+                              {label}
+                            </CommandItem>
+                          );
+                        })}
+                      </CommandGroup>
+                    </CommandList>
+                  </Command>
+                </PopoverContent>
+              </Popover>
             )}
           </div>
 
           <div className="space-y-2">
-            <Label>Template</Label>
+            <div className="flex items-center justify-between">
+              <Label>Template</Label>
+              <Link
+                href="/settings/templates?tab=inspection"
+                className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+                onClick={() => onOpenChange(false)}
+              >
+                <Settings className="h-3 w-3" />
+                Manage templates
+              </Link>
+            </div>
             <Select value={templateId} onValueChange={setTemplateId} required>
               <SelectTrigger>
                 <SelectValue placeholder="Select template" />

+ 53 - 0
src/features/notifications/Actions/notificationActions.ts

@@ -0,0 +1,53 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+
+export async function getNotifications() {
+  return withAuth(async ({ organizationId, role }) => {
+    const isAdminOrOwner = role === "owner" || role === "admin" || role === "super_admin";
+    if (!isAdminOrOwner) return { notifications: [], unreadCount: 0 };
+
+    const [notifications, unreadCount] = await Promise.all([
+      db.notification.findMany({
+        where: { organizationId },
+        orderBy: { createdAt: "desc" },
+        take: 50,
+      }),
+      db.notification.count({
+        where: { organizationId, read: false },
+      }),
+    ]);
+
+    return { notifications, unreadCount };
+  });
+}
+
+export async function markNotificationRead(id: string) {
+  return withAuth(async ({ organizationId }) => {
+    await db.notification.updateMany({
+      where: { id, organizationId },
+      data: { read: true },
+    });
+    return { success: true };
+  });
+}
+
+export async function markAllNotificationsRead() {
+  return withAuth(async ({ organizationId }) => {
+    await db.notification.updateMany({
+      where: { organizationId, read: false },
+      data: { read: true },
+    });
+    return { success: true };
+  });
+}
+
+export async function deleteNotification(id: string) {
+  return withAuth(async ({ organizationId }) => {
+    await db.notification.deleteMany({
+      where: { id, organizationId },
+    });
+    return { success: true };
+  });
+}

+ 8 - 0
src/features/notifications/Components/NotificationInitializer.tsx

@@ -0,0 +1,8 @@
+"use client";
+
+import { useNotificationWebSocket } from "../hooks/useNotificationWebSocket";
+
+export function NotificationInitializer() {
+  useNotificationWebSocket();
+  return null;
+}

+ 186 - 0
src/features/notifications/Components/NotificationPanel.tsx

@@ -0,0 +1,186 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { formatDistanceToNow } from "date-fns";
+import { Bell, FileText, ClipboardCheck, Receipt, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+  Sheet,
+  SheetContent,
+  SheetHeader,
+  SheetTitle,
+} from "@/components/ui/sheet";
+import { useNotificationStore } from "../store/notificationStore";
+import { markNotificationRead, markAllNotificationsRead, deleteNotification } from "../Actions/notificationActions";
+
+const entityIcons: Record<string, typeof FileText> = {
+  quote: FileText,
+  inspection: ClipboardCheck,
+  invoice: Receipt,
+};
+
+export function NotificationBell() {
+  const { unreadCount, setPanelOpen } = useNotificationStore();
+
+  return (
+    <Button
+      variant="ghost"
+      size="icon"
+      className="relative h-8 w-8"
+      onClick={() => setPanelOpen(true)}
+    >
+      <Bell className="h-4 w-4" />
+      {unreadCount > 0 && (
+        <span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
+          {unreadCount > 99 ? "99+" : unreadCount}
+        </span>
+      )}
+    </Button>
+  );
+}
+
+export function NotificationPanel() {
+  const router = useRouter();
+  const { notifications, unreadCount, isPanelOpen, setPanelOpen, markRead, markAllRead, removeNotification } =
+    useNotificationStore();
+  const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
+
+  const handleClickNotification = (id: string, entityUrl: string, read: boolean) => {
+    if (pendingDeleteId) {
+      setPendingDeleteId(null);
+      return;
+    }
+    if (!read) {
+      markRead(id);
+      markNotificationRead(id);
+    }
+    setPanelOpen(false);
+    setTimeout(() => {
+      router.push(entityUrl);
+    }, 300);
+  };
+
+  const handleDeleteClick = (e: React.MouseEvent, id: string) => {
+    e.stopPropagation();
+    setPendingDeleteId(id);
+  };
+
+  const handleConfirmDelete = (e: React.MouseEvent, id: string) => {
+    e.stopPropagation();
+    removeNotification(id);
+    deleteNotification(id);
+    setPendingDeleteId(null);
+  };
+
+  const handleCancelDelete = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    setPendingDeleteId(null);
+  };
+
+  const handleMarkAllRead = async () => {
+    markAllRead();
+    markAllNotificationsRead();
+  };
+
+  return (
+    <Sheet open={isPanelOpen} onOpenChange={(open) => { setPanelOpen(open); if (!open) setPendingDeleteId(null); }}>
+      <SheetContent className="w-[380px] p-0 sm:max-w-[380px]">
+        <SheetHeader className="border-b px-4 py-3">
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <SheetTitle>Notifications</SheetTitle>
+              {unreadCount > 0 && (
+                <Badge variant="secondary" className="text-xs">
+                  {unreadCount}
+                </Badge>
+              )}
+            </div>
+            {unreadCount > 0 && (
+              <Button variant="ghost" size="sm" className="text-xs" onClick={handleMarkAllRead}>
+                Mark all read
+              </Button>
+            )}
+          </div>
+        </SheetHeader>
+        <ScrollArea className="h-[calc(100vh-4rem)]">
+          {notifications.length === 0 ? (
+            <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
+              <Bell className="mb-2 h-8 w-8 opacity-40" />
+              <p className="text-sm">No notifications yet</p>
+            </div>
+          ) : (
+            <div className="divide-y">
+              {notifications.map((n) => {
+                const Icon = entityIcons[n.entityType] || Bell;
+                return (
+                  <div
+                    key={n.id}
+                    role="button"
+                    tabIndex={0}
+                    className="flex w-full cursor-pointer gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/50"
+                    onClick={() => handleClickNotification(n.id, n.entityUrl, n.read)}
+                    onKeyDown={(e) => { if (e.key === "Enter") handleClickNotification(n.id, n.entityUrl, n.read); }}
+                  >
+                    <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
+                      <Icon className="h-4 w-4 text-muted-foreground" />
+                    </div>
+                    <div className="min-w-0 flex-1">
+                      <div className="flex items-start justify-between gap-2">
+                        <p className="text-sm font-medium leading-tight">{n.title}</p>
+                        <div className="flex shrink-0 items-center gap-1">
+                          {!n.read && (
+                            <span className="h-2 w-2 rounded-full bg-primary" />
+                          )}
+                          <button
+                            type="button"
+                            className="rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground"
+                            onClick={(e) => handleDeleteClick(e, n.id)}
+                          >
+                            <X className="h-3.5 w-3.5" />
+                          </button>
+                        </div>
+                      </div>
+                      {pendingDeleteId === n.id ? (
+                        <div className="mt-1 flex items-center gap-2">
+                          <span className="text-xs text-muted-foreground">Are you sure?</span>
+                          <Button
+                            variant="destructive"
+                            size="sm"
+                            className="h-6 px-2 text-xs"
+                            onClick={(e) => handleConfirmDelete(e, n.id)}
+                          >
+                            Delete
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            className="h-6 px-2 text-xs"
+                            onClick={handleCancelDelete}
+                          >
+                            Cancel
+                          </Button>
+                        </div>
+                      ) : (
+                        <>
+                          <p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
+                            {n.message}
+                          </p>
+                          <p className="mt-1 text-xs text-muted-foreground/60">
+                            {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
+                          </p>
+                        </>
+                      )}
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          )}
+        </ScrollArea>
+      </SheetContent>
+    </Sheet>
+  );
+}

+ 80 - 0
src/features/notifications/hooks/useNotificationWebSocket.ts

@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { toast } from "sonner";
+import { useNotificationStore } from "../store/notificationStore";
+import { getNotifications } from "../Actions/notificationActions";
+
+export function useNotificationWebSocket() {
+  const wsRef = useRef<WebSocket | null>(null);
+  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
+  const mountedRef = useRef(true);
+
+  useEffect(() => {
+    mountedRef.current = true;
+
+    // Fetch initial notifications
+    getNotifications().then((result) => {
+      if (!mountedRef.current) return;
+      if (result.success && result.data) {
+        useNotificationStore.getState().setNotifications(
+          result.data.notifications,
+          result.data.unreadCount,
+        );
+      }
+    });
+
+    function connect() {
+      if (!mountedRef.current) return;
+
+      const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+      const url = `${protocol}//${window.location.host}/api/ws`;
+      const ws = new WebSocket(url);
+      wsRef.current = ws;
+
+      ws.onopen = () => {
+        useNotificationStore.getState().setConnected(true);
+      };
+
+      ws.onmessage = (event) => {
+        try {
+          const msg = JSON.parse(event.data);
+          if (msg.type === "notification") {
+            useNotificationStore.getState().addNotification(msg.data);
+            toast(msg.data.title, {
+              description: msg.data.message,
+              action: {
+                label: "View",
+                onClick: () => {
+                  window.location.href = msg.data.entityUrl;
+                },
+              },
+            });
+          }
+        } catch {
+          // ignore malformed messages
+        }
+      };
+
+      ws.onclose = () => {
+        useNotificationStore.getState().setConnected(false);
+        wsRef.current = null;
+        if (mountedRef.current) {
+          reconnectTimer.current = setTimeout(connect, 3000);
+        }
+      };
+
+      ws.onerror = () => {
+        ws.close();
+      };
+    }
+
+    connect();
+
+    return () => {
+      mountedRef.current = false;
+      clearTimeout(reconnectTimer.current);
+      wsRef.current?.close();
+    };
+  }, []); // no deps — mount once
+}

+ 63 - 0
src/features/notifications/store/notificationStore.ts

@@ -0,0 +1,63 @@
+import { create } from "zustand";
+
+export type Notification = {
+  id: string;
+  type: string;
+  title: string;
+  message: string;
+  entityType: string;
+  entityId: string;
+  entityUrl: string;
+  read: boolean;
+  organizationId: string;
+  createdAt: string | Date;
+};
+
+type NotificationState = {
+  notifications: Notification[];
+  unreadCount: number;
+  isConnected: boolean;
+  isPanelOpen: boolean;
+  setNotifications: (notifications: Notification[], unreadCount: number) => void;
+  addNotification: (notification: Notification) => void;
+  markRead: (id: string) => void;
+  markAllRead: () => void;
+  removeNotification: (id: string) => void;
+  setConnected: (connected: boolean) => void;
+  setPanelOpen: (open: boolean) => void;
+};
+
+export const useNotificationStore = create<NotificationState>((set) => ({
+  notifications: [],
+  unreadCount: 0,
+  isConnected: false,
+  isPanelOpen: false,
+  setNotifications: (notifications, unreadCount) => set({ notifications, unreadCount }),
+  addNotification: (notification) =>
+    set((state) => ({
+      notifications: [notification, ...state.notifications].slice(0, 50),
+      unreadCount: state.unreadCount + 1,
+    })),
+  markRead: (id) =>
+    set((state) => ({
+      notifications: state.notifications.map((n) =>
+        n.id === id ? { ...n, read: true } : n,
+      ),
+      unreadCount: Math.max(0, state.unreadCount - (state.notifications.find((n) => n.id === id && !n.read) ? 1 : 0)),
+    })),
+  markAllRead: () =>
+    set((state) => ({
+      notifications: state.notifications.map((n) => ({ ...n, read: true })),
+      unreadCount: 0,
+    })),
+  removeNotification: (id) =>
+    set((state) => {
+      const target = state.notifications.find((n) => n.id === id);
+      return {
+        notifications: state.notifications.filter((n) => n.id !== id),
+        unreadCount: Math.max(0, state.unreadCount - (target && !target.read ? 1 : 0)),
+      };
+    }),
+  setConnected: (isConnected) => set({ isConnected }),
+  setPanelOpen: (isPanelOpen) => set({ isPanelOpen }),
+}));

+ 16 - 0
src/features/quotes/Actions/quoteResponseActions.ts

@@ -3,6 +3,7 @@
 import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { notify } from "@/lib/notify";
 import { z } from "zod";
 
 const respondToQuoteSchema = z.object({
@@ -22,6 +23,7 @@ export async function respondToQuote(input: unknown) {
   // Verify the quote exists and the token matches
   const quote = await db.quote.findFirst({
     where: { id: data.quoteId, publicToken: data.publicToken },
+    include: { customer: { select: { name: true } } },
   });
   if (!quote) {
     return { success: false, error: "Quote not found" };
@@ -40,6 +42,20 @@ export async function respondToQuote(input: unknown) {
     },
   });
 
+  if (quote.organizationId) {
+    const customerName = quote.customer?.name || "A customer";
+    const actionLabel = data.action === "accepted" ? "accepted" : "requested changes on";
+    notify({
+      organizationId: quote.organizationId,
+      type: "quote_response",
+      title: `Quote ${data.action === "accepted" ? "Accepted" : "Changes Requested"}`,
+      message: `${customerName} ${actionLabel} quote ${quote.quoteNumber || quote.title}`,
+      entityType: "quote",
+      entityId: quote.id,
+      entityUrl: `/quotes/${quote.id}`,
+    });
+  }
+
   return { success: true };
 }
 

+ 11 - 0
src/lib/notification-bus.ts

@@ -0,0 +1,11 @@
+import { EventEmitter } from "node:events";
+
+const globalForBus = globalThis as unknown as {
+  notificationBus: EventEmitter | undefined;
+};
+
+export const notificationBus =
+  globalForBus.notificationBus ?? new EventEmitter();
+
+// Survive HMR in dev
+globalForBus.notificationBus = notificationBus;

+ 33 - 0
src/lib/notify.ts

@@ -0,0 +1,33 @@
+import { db } from "@/lib/db";
+import { notificationBus } from "@/lib/notification-bus";
+
+type NotifyInput = {
+  organizationId: string;
+  type: string;
+  title: string;
+  message: string;
+  entityType: string;
+  entityId: string;
+  entityUrl: string;
+};
+
+export async function notify(input: NotifyInput) {
+  try {
+    const notification = await db.notification.create({
+      data: {
+        type: input.type,
+        title: input.title,
+        message: input.message,
+        entityType: input.entityType,
+        entityId: input.entityId,
+        entityUrl: input.entityUrl,
+        organizationId: input.organizationId,
+      },
+    });
+
+    // Emit to in-process bus — the WS route subscribes and broadcasts
+    notificationBus.emit("notification", notification);
+  } catch (error) {
+    console.error("[notify] Failed:", error);
+  }
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů