Просмотр исходного кода

Add work board for technician job scheduling (#16)

* new work planning page

* presenter page

* presenter page

* status

* align statuses for work order and presenter

* inspection links

* status
Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
ce7d41af36
38 измененных файлов с 3260 добавлено и 80 удалено
  1. 41 0
      package-lock.json
  2. 2 0
      package.json
  3. 57 0
      prisma/migrations/20260223165833_job_board/migration.sql
  4. 58 7
      prisma/schema.prisma
  5. 7 1
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx
  6. 43 0
      src/app/(authenticated)/work-board/loading.tsx
  7. 58 0
      src/app/(authenticated)/work-board/page.tsx
  8. 0 6
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  9. 25 0
      src/app/(presenter)/layout.tsx
  10. 49 0
      src/app/(presenter)/work-board/presenter/page.tsx
  11. 13 0
      src/app/api/protected/ws/route.ts
  12. 2 0
      src/components/app-sidebar.tsx
  13. 2 0
      src/components/page-header.tsx
  14. 0 1
      src/features/calendar/Components/CalendarEventList.tsx
  15. 8 0
      src/features/inspections/Actions/inspectionActions.ts
  16. 1 1
      src/features/vehicles/Actions/dashboardActions.ts
  17. 110 1
      src/features/vehicles/Actions/serviceActions.ts
  18. 124 61
      src/features/vehicles/Components/service-edit/BasicInfoSection.tsx
  19. 4 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  20. 1 1
      src/features/vehicles/Schema/serviceSchema.ts
  21. 416 0
      src/features/workboard/Actions/boardActions.ts
  22. 133 0
      src/features/workboard/Actions/technicianActions.ts
  23. 47 0
      src/features/workboard/Components/BoardDayCell.tsx
  24. 157 0
      src/features/workboard/Components/BoardJobCard.tsx
  25. 96 0
      src/features/workboard/Components/JobDetailPopover.tsx
  26. 104 0
      src/features/workboard/Components/PresenterDayView.tsx
  27. 128 0
      src/features/workboard/Components/PresenterKanbanView.tsx
  28. 217 0
      src/features/workboard/Components/TechnicianDialog.tsx
  29. 54 0
      src/features/workboard/Components/TechnicianRow.tsx
  30. 72 0
      src/features/workboard/Components/UnassignedJobsPanel.tsx
  31. 415 0
      src/features/workboard/Components/WorkBoardClient.tsx
  32. 401 0
      src/features/workboard/Components/WorkBoardPresenter.tsx
  33. 67 0
      src/features/workboard/Components/WorkBoardToolbar.tsx
  34. 47 0
      src/features/workboard/Schema/workboardSchema.ts
  35. 136 0
      src/features/workboard/hooks/useWorkBoardWebSocket.ts
  36. 154 0
      src/features/workboard/store/workboardStore.ts
  37. 11 0
      src/lib/permissions.ts
  38. 0 1
      src/lib/table-utils.ts

+ 41 - 0
package-lock.json

@@ -10,6 +10,8 @@
       "license": "Elastic-2.0",
       "dependencies": {
         "@aws-sdk/client-ses": "^3.992.0",
+        "@dnd-kit/core": "^6.3.1",
+        "@dnd-kit/utilities": "^3.2.2",
         "@hookform/resolvers": "^5.2.2",
         "@prisma/client": "^6.19.2",
         "@react-pdf/renderer": "^4.3.2",
@@ -1759,6 +1761,45 @@
       "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
       "license": "MIT"
     },
+    "node_modules/@dnd-kit/accessibility": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+      "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/core": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+      "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/accessibility": "^3.1.1",
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/utilities": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/@dotenvx/dotenvx": {
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",

+ 2 - 0
package.json

@@ -18,6 +18,8 @@
   },
   "dependencies": {
     "@aws-sdk/client-ses": "^3.992.0",
+    "@dnd-kit/core": "^6.3.1",
+    "@dnd-kit/utilities": "^3.2.2",
     "@hookform/resolvers": "^5.2.2",
     "@prisma/client": "^6.19.2",
     "@react-pdf/renderer": "^4.3.2",

+ 57 - 0
prisma/migrations/20260223165833_job_board/migration.sql

@@ -0,0 +1,57 @@
+-- CreateTable
+CREATE TABLE "technicians" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "color" TEXT NOT NULL DEFAULT '#3b82f6',
+    "isActive" BOOLEAN NOT NULL DEFAULT true,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "memberId" TEXT,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "technicians_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "board_assignments" (
+    "id" TEXT NOT NULL,
+    "date" DATE NOT NULL,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "notes" TEXT,
+    "technicianId" TEXT NOT NULL,
+    "serviceRecordId" TEXT,
+    "inspectionId" TEXT,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "board_assignments_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "technicians_organizationId_idx" ON "technicians"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "board_assignments_organizationId_date_idx" ON "board_assignments"("organizationId", "date");
+
+-- CreateIndex
+CREATE INDEX "board_assignments_technicianId_date_idx" ON "board_assignments"("technicianId", "date");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "board_assignments_serviceRecordId_date_key" ON "board_assignments"("serviceRecordId", "date");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "board_assignments_inspectionId_date_key" ON "board_assignments"("inspectionId", "date");
+
+-- AddForeignKey
+ALTER TABLE "technicians" ADD CONSTRAINT "technicians_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "board_assignments" ADD CONSTRAINT "board_assignments_technicianId_fkey" FOREIGN KEY ("technicianId") REFERENCES "technicians"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "board_assignments" ADD CONSTRAINT "board_assignments_serviceRecordId_fkey" FOREIGN KEY ("serviceRecordId") REFERENCES "service_records"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "board_assignments" ADD CONSTRAINT "board_assignments_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "inspections"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 58 - 7
prisma/schema.prisma

@@ -183,10 +183,11 @@ model ServiceRecord {
   vehicleId String
   vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
 
-  partItems   ServicePart[]
-  laborItems  ServiceLabor[]
-  attachments ServiceAttachment[]
-  payments    Payment[]
+  partItems        ServicePart[]
+  laborItems       ServiceLabor[]
+  attachments      ServiceAttachment[]
+  payments         Payment[]
+  boardAssignments BoardAssignment[]
 
   @@index([vehicleId])
   @@map("service_records")
@@ -494,6 +495,7 @@ model Organization {
   invitations            TeamInvitation[]
   inspectionTemplates    InspectionTemplate[]
   smsMessages            SmsMessage[]
+  technicians            Technician[]
 
   @@map("organizations")
 }
@@ -732,9 +734,10 @@ model Inspection {
 
   organizationId String
 
-  items         InspectionItem[]
-  quoteRequests InspectionQuoteRequest[]
-  quotes        Quote[]
+  items            InspectionItem[]
+  quoteRequests    InspectionQuoteRequest[]
+  quotes           Quote[]
+  boardAssignments BoardAssignment[]
 
   @@index([vehicleId])
   @@index([organizationId])
@@ -816,3 +819,51 @@ model SmsMessage {
   @@index([toNumber, organizationId])
   @@map("sms_messages")
 }
+
+model Technician {
+  id        String  @id @default(cuid())
+  name      String
+  color     String  @default("#3b82f6")
+  isActive  Boolean @default(true)
+  sortOrder Int     @default(0)
+
+  memberId String?
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  assignments BoardAssignment[]
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([organizationId])
+  @@map("technicians")
+}
+
+model BoardAssignment {
+  id        String   @id @default(cuid())
+  date      DateTime @db.Date
+  sortOrder Int      @default(0)
+  notes     String?
+
+  technicianId String
+  technician   Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
+
+  serviceRecordId String?
+  serviceRecord   ServiceRecord? @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
+
+  inspectionId String?
+  inspection   Inspection? @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
+
+  organizationId String
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@unique([serviceRecordId, date])
+  @@unique([inspectionId, date])
+  @@index([organizationId, date])
+  @@index([technicianId, date])
+  @@map("board_assignments")
+}

+ 7 - 1
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx

@@ -3,6 +3,7 @@ import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { getInventoryPartsList } from "@/features/inventory/Actions/inventoryActions";
 import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
+import { getTechnicians } from "@/features/workboard/Actions/technicianActions";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { getFeatures } from "@/lib/features";
 import { db } from "@/lib/db";
@@ -17,7 +18,7 @@ export default async function ServiceDetailPage({
 }) {
   const { id, serviceId } = await params;
 
-  const [result, settingsResult, inventoryResult, vehiclesResult, authContext, session] =
+  const [result, settingsResult, inventoryResult, vehiclesResult, techniciansResult, authContext, session] =
     await Promise.all([
       getServiceRecord(serviceId),
       getSettings([
@@ -29,6 +30,7 @@ export default async function ServiceDetailPage({
       ]),
       getInventoryPartsList(),
       getVehicles(),
+      getTechnicians(),
       getAuthContext(),
       getCachedSession(),
     ]);
@@ -67,6 +69,9 @@ export default async function ServiceDetailPage({
     id: v.id,
     label: `${v.year} ${v.make} ${v.model}${v.licensePlate ? ` (${v.licensePlate})` : ""}`,
   }));
+  const boardTechnicians = (
+    techniciansResult.success && techniciansResult.data ? techniciansResult.data : []
+  ).map((t) => ({ id: t.id, name: t.name }));
   const organizationId = authContext?.organizationId || "";
 
   // Fetch team members and features
@@ -160,6 +165,7 @@ export default async function ServiceDetailPage({
         inventoryParts={inventoryParts}
         vehicles={vehicles}
         teamMembers={teamMembers}
+        boardTechnicians={boardTechnicians}
         currentUserName={currentUserName}
         imageAttachmentsForManager={imageAttachmentsForManager}
         videoAttachments={videoAttachments}

+ 43 - 0
src/app/(authenticated)/work-board/loading.tsx

@@ -0,0 +1,43 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function WorkBoardLoading() {
+  return (
+    <div className="flex flex-1 flex-col gap-4 p-4">
+      {/* Toolbar skeleton */}
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          <Skeleton className="h-8 w-20" />
+          <Skeleton className="h-8 w-48" />
+        </div>
+        <Skeleton className="h-8 w-36" />
+      </div>
+
+      {/* Grid skeleton */}
+      <div className="flex gap-4">
+        <div className="flex-1 space-y-2">
+          {/* Header row */}
+          <div className="grid grid-cols-[140px_repeat(7,1fr)] gap-1">
+            <div />
+            {Array.from({ length: 7 }).map((_, i) => (
+              <Skeleton key={i} className="h-7" />
+            ))}
+          </div>
+          {/* Tech rows */}
+          {Array.from({ length: 4 }).map((_, i) => (
+            <div
+              key={i}
+              className="grid grid-cols-[140px_repeat(7,1fr)] gap-1"
+            >
+              <Skeleton className="h-20" />
+              {Array.from({ length: 7 }).map((_, j) => (
+                <Skeleton key={j} className="h-20" />
+              ))}
+            </div>
+          ))}
+        </div>
+        {/* Unassigned panel skeleton */}
+        <Skeleton className="h-96 w-64" />
+      </div>
+    </div>
+  );
+}

+ 58 - 0
src/app/(authenticated)/work-board/page.tsx

@@ -0,0 +1,58 @@
+import { PageHeader } from "@/components/page-header";
+import { getTechnicians } from "@/features/workboard/Actions/technicianActions";
+import { getBoardAssignments, getUnassignedJobs } from "@/features/workboard/Actions/boardActions";
+import { WorkBoardClient } from "@/features/workboard/Components/WorkBoardClient";
+
+function getMonday(date: Date): string {
+  const d = new Date(date);
+  const day = d.getDay();
+  const diff = d.getDate() - day + (day === 0 ? -6 : 1);
+  d.setDate(diff);
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, "0");
+  const dd = String(d.getDate()).padStart(2, "0");
+  return `${y}-${m}-${dd}`;
+}
+
+export default async function WorkBoardPage() {
+  const weekStart = getMonday(new Date());
+
+  const [techResult, assignResult, unassignedResult] = await Promise.all([
+    getTechnicians(),
+    getBoardAssignments(weekStart),
+    getUnassignedJobs(),
+  ]);
+
+  if (!techResult.success) {
+    return (
+      <>
+        <PageHeader />
+        <div className="flex h-[50vh] items-center justify-center">
+          <p className="text-muted-foreground">
+            {techResult.error || "Failed to load work board"}
+          </p>
+        </div>
+      </>
+    );
+  }
+
+  const technicians = techResult.data ?? [];
+  const assignments = assignResult.success && assignResult.data ? assignResult.data : [];
+  const unassigned = unassignedResult.success && unassignedResult.data
+    ? unassignedResult.data
+    : { serviceRecords: [], inspections: [] };
+
+  return (
+    <>
+      <PageHeader />
+      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+        <WorkBoardClient
+          initialTechnicians={technicians as Parameters<typeof WorkBoardClient>[0]["initialTechnicians"]}
+          initialAssignments={assignments as Parameters<typeof WorkBoardClient>[0]["initialAssignments"]}
+          initialUnassigned={unassigned}
+          initialWeekStart={weekStart}
+        />
+      </div>
+    </>
+  );
+}

+ 0 - 6
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -87,14 +87,12 @@ const statusTabs = [
   { key: "pending", label: "Pending" },
   { key: "in-progress", label: "In Progress" },
   { key: "waiting-parts", label: "Waiting Parts" },
-  { key: "ready", label: "Ready" },
   { key: "completed", label: "Completed" },
 ];
 
 const statusTemplateKeys: Record<string, string> = {
   "in-progress": SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
   "waiting-parts": SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
-  "ready": SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
   "completed": SETTING_KEYS.SMS_TEMPLATE_STATUS_COMPLETED,
 };
 
@@ -104,14 +102,10 @@ const statusTransitions: Record<string, { label: string; target: string }[]> = {
   ],
   "in-progress": [
     { label: "Waiting Parts", target: "waiting-parts" },
-    { label: "Mark Ready", target: "ready" },
     { label: "Complete", target: "completed" },
   ],
   "waiting-parts": [
     { label: "Resume Work", target: "in-progress" },
-    { label: "Mark Ready", target: "ready" },
-  ],
-  ready: [
     { label: "Complete", target: "completed" },
   ],
   completed: [

+ 25 - 0
src/app/(presenter)/layout.tsx

@@ -0,0 +1,25 @@
+import { redirect } from "next/navigation";
+import { getLayoutData } from "@/lib/get-layout-data";
+import { DateSettingsProvider } from "@/components/date-settings-context";
+import { ConfirmProvider } from "@/components/confirm-dialog";
+
+export default async function PresenterLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  const data = await getLayoutData();
+
+  if (data.status === "unauthenticated") redirect("/auth/sign-in");
+  if (data.status === "no-organization") redirect("/onboarding");
+
+  return (
+    <DateSettingsProvider
+      dateFormat={data.dateFormat}
+      timeFormat={data.timeFormat}
+      timezone={data.timezone}
+    >
+      <ConfirmProvider>{children}</ConfirmProvider>
+    </DateSettingsProvider>
+  );
+}

+ 49 - 0
src/app/(presenter)/work-board/presenter/page.tsx

@@ -0,0 +1,49 @@
+import { getTechnicians } from "@/features/workboard/Actions/technicianActions";
+import { getBoardAssignments } from "@/features/workboard/Actions/boardActions";
+import { WorkBoardPresenter } from "@/features/workboard/Components/WorkBoardPresenter";
+
+function getMonday(date: Date): string {
+  const d = new Date(date);
+  const day = d.getDay();
+  const diff = d.getDate() - day + (day === 0 ? -6 : 1);
+  d.setDate(diff);
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, "0");
+  const dd = String(d.getDate()).padStart(2, "0");
+  return `${y}-${m}-${dd}`;
+}
+
+export default async function WorkBoardPresenterPage() {
+  const weekStart = getMonday(new Date());
+
+  const [techResult, assignResult] = await Promise.all([
+    getTechnicians(),
+    getBoardAssignments(weekStart),
+  ]);
+
+  if (!techResult.success) {
+    return (
+      <div className="flex h-screen items-center justify-center">
+        <p className="text-muted-foreground text-lg">
+          {techResult.error || "Failed to load work board"}
+        </p>
+      </div>
+    );
+  }
+
+  const technicians = techResult.data ?? [];
+  const assignments =
+    assignResult.success && assignResult.data ? assignResult.data : [];
+
+  return (
+    <WorkBoardPresenter
+      initialTechnicians={
+        technicians as Parameters<typeof WorkBoardPresenter>[0]["initialTechnicians"]
+      }
+      initialAssignments={
+        assignments as Parameters<typeof WorkBoardPresenter>[0]["initialAssignments"]
+      }
+      initialWeekStart={weekStart}
+    />
+  );
+}

+ 13 - 0
src/app/api/protected/ws/route.ts

@@ -32,6 +32,19 @@ notificationBus.on("notification", (notification: { organizationId: string }) =>
   }
 });
 
+// Work board events — broadcasts board updates to matching org clients
+notificationBus.on("workboard", (event: { organizationId: string }) => {
+  const payload = JSON.stringify({ type: "workboard", data: event });
+  for (const client of clients) {
+    if (
+      client.organizationId === event.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, …)

+ 2 - 0
src/components/app-sidebar.tsx

@@ -38,6 +38,7 @@ import {
   ChevronsUpDown,
   ClipboardCheck,
   ClipboardList,
+  Columns3,
   FileText,
   MessageSquare,
   LayoutDashboard,
@@ -102,6 +103,7 @@ export function AppSidebar({
     { title: 'Work Orders', url: '/work-orders', icon: ClipboardList },
     { title: 'Inspections', url: '/inspections', icon: ClipboardCheck },
     { title: 'Calendar', url: '/calendar', icon: CalendarDays },
+    { title: 'Work Board', url: '/work-board', icon: Columns3 },
   ]
 
   const businessItems = [

+ 2 - 0
src/components/page-header.tsx

@@ -48,6 +48,8 @@ const breadcrumbMap: Record<string, BreadcrumbSegment[]> = {
   '/billing': [{ label: 'Billing', href: '/billing' }, { label: 'Billing History' }],
   '/inventory': [{ label: 'Inventory', href: '/inventory' }, { label: 'All Parts' }],
   '/reports': [{ label: 'Reports', href: '/reports' }, { label: 'Reports' }],
+  '/work-board': [{ label: 'Work Board' }],
+  '/work-board/presenter': [{ label: 'Work Board', href: '/work-board' }, { label: 'Presenter' }],
   '/admin': [{ label: 'Admin Overview' }],
   '/admin/users': [{ label: 'Admin', href: '/admin' }, { label: 'Users' }],
   '/admin/organizations': [{ label: 'Admin', href: '/admin' }, { label: 'Organizations' }],

+ 0 - 1
src/features/calendar/Components/CalendarEventList.tsx

@@ -31,7 +31,6 @@ function getStatusBadge(event: CalendarEvent) {
       case "completed": return <Badge variant="outline" className="text-emerald-600 border-emerald-300 text-[10px] px-1.5 py-0">Completed</Badge>;
       case "in_progress": case "in-progress": return <Badge variant="outline" className="text-blue-600 border-blue-300 text-[10px] px-1.5 py-0">In Progress</Badge>;
       case "waiting-parts": return <Badge variant="outline" className="text-orange-600 border-orange-300 text-[10px] px-1.5 py-0">Waiting Parts</Badge>;
-      case "ready": return <Badge variant="outline" className="text-cyan-600 border-cyan-300 text-[10px] px-1.5 py-0">Ready</Badge>;
       default: return <Badge variant="outline" className="text-amber-600 border-amber-300 text-[10px] px-1.5 py-0">Pending</Badge>;
     }
   }

+ 8 - 0
src/features/inspections/Actions/inspectionActions.ts

@@ -5,6 +5,7 @@ import { withAuth } from "@/lib/with-auth";
 import { createInspectionSchema, updateInspectionItemSchema } from "../Schema/inspectionSchema";
 import { revalidatePath } from "next/cache";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { notificationBus } from "@/lib/notification-bus";
 
 export async function getInspectionsPaginated(params: {
   page?: number;
@@ -207,6 +208,13 @@ export async function completeInspection(id: string) {
       data: { status: "completed", completedAt: new Date() },
     });
 
+    notificationBus.emit("workboard", {
+      type: "job_status_changed",
+      organizationId,
+      inspectionId: id,
+      status: "completed",
+    });
+
     revalidatePath("/inspections");
     revalidatePath(`/inspections/${id}`);
     revalidatePath(`/vehicles/${inspection.vehicleId}`);

+ 1 - 1
src/features/vehicles/Actions/dashboardActions.ts

@@ -38,7 +38,7 @@ export async function getDashboardStats() {
       db.serviceRecord.findMany({
         where: {
           vehicle: { organizationId },
-          status: { in: ["pending", "in-progress", "waiting-parts", "ready"] },
+          status: { in: ["pending", "in-progress", "waiting-parts"] },
         },
         include: {
           vehicle: {

+ 110 - 1
src/features/vehicles/Actions/serviceActions.ts

@@ -8,6 +8,7 @@ import { unlink } from "fs/promises";
 import { randomUUID } from "crypto";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
+import { notificationBus } from "@/lib/notification-bus";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 
 export async function getServiceRecords(vehicleId: string) {
@@ -346,7 +347,10 @@ export async function updateServiceRecord(input: unknown) {
     const data = updateServiceSchema.parse(input);
     const existing = await db.serviceRecord.findFirst({
       where: { id: data.id, vehicle: { organizationId } },
-      include: { attachments: { select: { fileUrl: true, category: true } } },
+      include: {
+        attachments: { select: { fileUrl: true, category: true } },
+        vehicle: { select: { id: true, make: true, model: true, year: true, licensePlate: true } },
+      },
     });
     if (!existing) throw new Error("Service record not found");
 
@@ -430,6 +434,93 @@ export async function updateServiceRecord(input: unknown) {
       }
     }
 
+    // Notify workboard if status changed
+    if (record.status !== existing.status) {
+      notificationBus.emit("workboard", {
+        type: "job_status_changed",
+        organizationId,
+        serviceRecordId: id,
+        status: record.status,
+        serviceRecord: {
+          id: existing.id,
+          title: record.title || existing.title,
+          status: record.status,
+          vehicle: existing.vehicle,
+        },
+      });
+    }
+
+    // Sync board assignment when techName or serviceDate changes
+    const techChanged = recordData.techName !== undefined && recordData.techName !== existing.techName;
+    const dateChanged = recordData.serviceDate !== undefined && recordData.serviceDate !== existing.serviceDate.toISOString().split("T")[0];
+
+    if (techChanged || dateChanged) {
+      const boardAssignment = await db.boardAssignment.findFirst({
+        where: { serviceRecordId: id, organizationId },
+      });
+      if (boardAssignment) {
+        // Build update payload
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const updateData: any = {};
+
+        if (dateChanged && recordData.serviceDate) {
+          updateData.date = new Date(recordData.serviceDate);
+        }
+
+        if (techChanged && recordData.techName) {
+          const matchingTech = await db.technician.findFirst({
+            where: { organizationId, name: recordData.techName, isActive: true },
+          });
+          if (matchingTech) {
+            updateData.technicianId = matchingTech.id;
+          }
+        }
+
+        if (Object.keys(updateData).length > 0) {
+          const updated = await db.boardAssignment.update({
+            where: { id: boardAssignment.id },
+            data: updateData,
+            include: {
+              technician: true,
+              serviceRecord: {
+                select: {
+                  id: true, title: true, status: true,
+                  vehicle: {
+                    select: { id: true, make: true, model: true, year: true, licensePlate: true },
+                  },
+                },
+              },
+              inspection: {
+                select: {
+                  id: true, status: true,
+                  vehicle: {
+                    select: { id: true, make: true, model: true, year: true, licensePlate: true },
+                  },
+                  template: { select: { name: true } },
+                },
+              },
+            },
+          });
+          notificationBus.emit("workboard", {
+            type: "assignment_moved",
+            organizationId,
+            assignment: {
+              ...updated,
+              date: updated.date.toISOString().split("T")[0],
+              createdAt: updated.createdAt.toISOString(),
+              updatedAt: updated.updatedAt.toISOString(),
+              technician: {
+                ...updated.technician,
+                createdAt: updated.technician.createdAt.toISOString(),
+                updatedAt: updated.technician.updatedAt.toISOString(),
+              },
+            },
+          });
+          revalidatePath("/work-board");
+        }
+      }
+    }
+
     revalidatePath("/");
     revalidatePath(`/vehicles/${existing.vehicleId}`);
     revalidatePath(`/vehicles/${existing.vehicleId}/service/${id}`);
@@ -442,6 +533,11 @@ export async function updateServiceStatus(recordId: string, status: string) {
   return withAuth(async ({ organizationId }) => {
     const record = await db.serviceRecord.findFirst({
       where: { id: recordId, vehicle: { organizationId } },
+      include: {
+        vehicle: {
+          select: { id: true, make: true, model: true, year: true, licensePlate: true },
+        },
+      },
     });
     if (!record) throw new Error("Record not found");
 
@@ -450,6 +546,19 @@ export async function updateServiceStatus(recordId: string, status: string) {
       data: { status },
     });
 
+    notificationBus.emit("workboard", {
+      type: "job_status_changed",
+      organizationId,
+      serviceRecordId: recordId,
+      status,
+      serviceRecord: {
+        id: record.id,
+        title: record.title,
+        status,
+        vehicle: record.vehicle,
+      },
+    });
+
     revalidatePath("/");
     revalidatePath("/work-orders");
     revalidatePath("/services");

+ 124 - 61
src/features/vehicles/Components/service-edit/BasicInfoSection.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -24,6 +24,11 @@ import Link from 'next/link'
 import { Check, ChevronsUpDown, ExternalLink } from 'lucide-react'
 import type { InitialData, VehicleOption, TeamMemberOption } from './form-types'
 
+export interface BoardTechnicianOption {
+  id: string
+  name: string
+}
+
 interface CustomerInfo {
   id: string
   name: string
@@ -48,6 +53,7 @@ interface BasicInfoSectionProps {
   techOpen: boolean
   setTechOpen: (open: boolean) => void
   teamMembers: TeamMemberOption[]
+  boardTechnicians?: BoardTechnicianOption[]
   customer?: CustomerInfo | null
 }
 
@@ -68,6 +74,7 @@ export function BasicInfoSection({
   techOpen,
   setTechOpen,
   teamMembers,
+  boardTechnicians = [],
   customer,
 }: BasicInfoSectionProps) {
   const selectedVehicleLabel = useMemo(() => {
@@ -216,72 +223,20 @@ export function BasicInfoSection({
               <SelectItem value="pending">Pending</SelectItem>
               <SelectItem value="in-progress">In Progress</SelectItem>
               <SelectItem value="waiting-parts">Waiting Parts</SelectItem>
-              <SelectItem value="ready">Ready</SelectItem>
               <SelectItem value="completed">Completed</SelectItem>
             </SelectContent>
           </Select>
         </div>
       </div>
 
-      <div className="space-y-1">
-        <Label className="text-xs">Technician</Label>
-        <Popover open={techOpen} onOpenChange={setTechOpen}>
-          <PopoverTrigger asChild>
-            <Button
-              variant="outline"
-              role="combobox"
-              aria-expanded={techOpen}
-              className="w-full justify-between font-normal"
-            >
-              {techName || <span className="text-muted-foreground">Select or type a name...</span>}
-              <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 or type name..."
-                value={techName}
-                onValueChange={setTechName}
-              />
-              <CommandList>
-                <CommandEmpty>
-                  {techName ? (
-                    <button
-                      type="button"
-                      className="w-full px-2 py-1.5 text-sm text-left"
-                      onClick={() => setTechOpen(false)}
-                    >
-                      Use &quot;{techName}&quot;
-                    </button>
-                  ) : (
-                    'Type a name...'
-                  )}
-                </CommandEmpty>
-                {teamMembers.length > 0 && (
-                  <CommandGroup heading="Team Members">
-                    {teamMembers.map((member) => (
-                      <CommandItem
-                        key={member.id}
-                        value={member.name}
-                        onSelect={() => {
-                          setTechName(member.name)
-                          setTechOpen(false)
-                        }}
-                      >
-                        <Check
-                          className={`mr-2 h-4 w-4 ${techName === member.name ? 'opacity-100' : 'opacity-0'}`}
-                        />
-                        {member.name}
-                      </CommandItem>
-                    ))}
-                  </CommandGroup>
-                )}
-              </CommandList>
-            </Command>
-          </PopoverContent>
-        </Popover>
-      </div>
+      <TechnicianPicker
+        techName={techName}
+        setTechName={setTechName}
+        techOpen={techOpen}
+        setTechOpen={setTechOpen}
+        boardTechnicians={boardTechnicians}
+        teamMembers={teamMembers}
+      />
 
       <div className="space-y-1">
         <Label htmlFor="invoiceNumber" className="text-xs">Invoice Number</Label>
@@ -295,3 +250,111 @@ export function BasicInfoSection({
     </div>
   )
 }
+
+function TechnicianPicker({
+  techName,
+  setTechName,
+  techOpen,
+  setTechOpen,
+  boardTechnicians,
+  teamMembers,
+}: {
+  techName: string
+  setTechName: (name: string) => void
+  techOpen: boolean
+  setTechOpen: (open: boolean) => void
+  boardTechnicians: BoardTechnicianOption[]
+  teamMembers: TeamMemberOption[]
+}) {
+  const [search, setSearch] = useState('')
+
+  return (
+    <div className="space-y-1">
+      <Label className="text-xs">Technician</Label>
+      <Popover
+        open={techOpen}
+        onOpenChange={(open) => {
+          setTechOpen(open)
+          if (open) setSearch('')
+        }}
+      >
+        <PopoverTrigger asChild>
+          <Button
+            variant="outline"
+            role="combobox"
+            aria-expanded={techOpen}
+            className="w-full justify-between font-normal"
+          >
+            {techName || <span className="text-muted-foreground">Select or type a name...</span>}
+            <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 or type name..."
+              value={search}
+              onValueChange={setSearch}
+            />
+            <CommandList>
+              <CommandEmpty>
+                {search ? (
+                  <button
+                    type="button"
+                    className="w-full px-2 py-1.5 text-sm text-left"
+                    onClick={() => {
+                      setTechName(search)
+                      setTechOpen(false)
+                    }}
+                  >
+                    Use &quot;{search}&quot;
+                  </button>
+                ) : (
+                  'Type a name...'
+                )}
+              </CommandEmpty>
+              {boardTechnicians.length > 0 && (
+                <CommandGroup heading="Board Technicians">
+                  {boardTechnicians.map((tech) => (
+                    <CommandItem
+                      key={tech.id}
+                      value={tech.name}
+                      onSelect={() => {
+                        setTechName(tech.name)
+                        setTechOpen(false)
+                      }}
+                    >
+                      <Check
+                        className={`mr-2 h-4 w-4 ${techName === tech.name ? 'opacity-100' : 'opacity-0'}`}
+                      />
+                      {tech.name}
+                    </CommandItem>
+                  ))}
+                </CommandGroup>
+              )}
+              {teamMembers.length > 0 && (
+                <CommandGroup heading="Team Members">
+                  {teamMembers.map((member) => (
+                    <CommandItem
+                      key={member.id}
+                      value={member.name}
+                      onSelect={() => {
+                        setTechName(member.name)
+                        setTechOpen(false)
+                      }}
+                    >
+                      <Check
+                        className={`mr-2 h-4 w-4 ${techName === member.name ? 'opacity-100' : 'opacity-0'}`}
+                      />
+                      {member.name}
+                    </CommandItem>
+                  ))}
+                </CommandGroup>
+              )}
+            </CommandList>
+          </Command>
+        </PopoverContent>
+      </Popover>
+    </div>
+  )
+}

+ 4 - 0
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -12,6 +12,7 @@ import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
 import type { ServicePartInput, ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
 import type { ServiceDetail } from '../service-detail/types'
 import type { InitialData, InventoryPartOption, VehicleOption, TeamMemberOption } from '../service-edit/form-types'
+import type { BoardTechnicianOption } from '../service-edit/BasicInfoSection'
 
 import { ServiceDetailContent } from '../service-detail/ServiceDetailContent'
 import { InvoiceSummary } from '../service-detail/InvoiceSummary'
@@ -63,6 +64,7 @@ export interface ServicePageClientProps {
   inventoryParts: InventoryPartOption[]
   vehicles: VehicleOption[]
   teamMembers: TeamMemberOption[]
+  boardTechnicians?: BoardTechnicianOption[]
   currentUserName: string
   // Media data
   imageAttachmentsForManager: Attachment[]
@@ -88,6 +90,7 @@ export function ServicePageClient({
   inventoryParts,
   vehicles,
   teamMembers,
+  boardTechnicians = [],
   currentUserName,
   imageAttachmentsForManager,
   videoAttachments,
@@ -465,6 +468,7 @@ export function ServicePageClient({
         techOpen={techOpen}
         setTechOpen={setTechOpen}
         teamMembers={teamMembers}
+        boardTechnicians={boardTechnicians}
         customer={record.vehicle.customer}
       />
       <TotalsSection

+ 1 - 1
src/features/vehicles/Schema/serviceSchema.ts

@@ -31,7 +31,7 @@ export const createServiceSchema = z.object({
   title: z.string().min(1, "Title is required"),
   description: z.string().optional(),
   type: z.enum(["maintenance", "repair", "upgrade", "inspection"]).default("maintenance"),
-  status: z.enum(["pending", "in-progress", "waiting-parts", "ready", "completed"]).default("pending"),
+  status: z.enum(["pending", "in-progress", "waiting-parts", "completed"]).default("pending"),
   cost: z.coerce.number().min(0).default(0),
   mileage: z.coerce.number().optional(),
   serviceDate: z.string().default(() => new Date().toISOString()),

+ 416 - 0
src/features/workboard/Actions/boardActions.ts

@@ -0,0 +1,416 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+import { notificationBus } from "@/lib/notification-bus";
+import {
+  createBoardAssignmentSchema,
+  moveAssignmentSchema,
+  removeAssignmentSchema,
+} from "../Schema/workboardSchema";
+
+export type BoardAssignmentWithJob = {
+  id: string;
+  date: string;
+  sortOrder: number;
+  notes: string | null;
+  technicianId: string;
+  serviceRecordId: string | null;
+  inspectionId: string | null;
+  organizationId: string;
+  serviceRecord?: {
+    id: string;
+    title: string;
+    status: string;
+    vehicle: {
+      id: string;
+      make: string;
+      model: string;
+      year: number;
+      licensePlate: string | null;
+    };
+  } | null;
+  inspection?: {
+    id: string;
+    status: string;
+    vehicle: {
+      id: string;
+      make: string;
+      model: string;
+      year: number;
+      licensePlate: string | null;
+    };
+    template: { name: string };
+  } | null;
+};
+
+export async function getBoardAssignments(weekStart: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const start = new Date(weekStart);
+      const end = new Date(start);
+      end.setDate(end.getDate() + 7);
+
+      const assignments = await db.boardAssignment.findMany({
+        where: {
+          organizationId,
+          date: { gte: start, lt: end },
+        },
+        include: {
+          technician: true,
+          serviceRecord: {
+            select: {
+              id: true,
+              title: true,
+              status: true,
+              vehicle: {
+                select: {
+                  id: true,
+                  make: true,
+                  model: true,
+                  year: true,
+                  licensePlate: true,
+                },
+              },
+            },
+          },
+          inspection: {
+            select: {
+              id: true,
+              status: true,
+              vehicle: {
+                select: {
+                  id: true,
+                  make: true,
+                  model: true,
+                  year: true,
+                  licensePlate: true,
+                },
+              },
+              template: { select: { name: true } },
+            },
+          },
+        },
+        orderBy: { sortOrder: "asc" },
+      });
+
+      // Serialize dates to strings for client
+      return assignments.map((a) => ({
+        ...a,
+        date: a.date.toISOString().split("T")[0],
+        createdAt: a.createdAt.toISOString(),
+        updatedAt: a.updatedAt.toISOString(),
+        technician: {
+          ...a.technician,
+          createdAt: a.technician.createdAt.toISOString(),
+          updatedAt: a.technician.updatedAt.toISOString(),
+        },
+      }));
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function getUnassignedJobs() {
+  return withAuth(
+    async ({ organizationId }) => {
+      // Get IDs of service records that have any board assignment
+      const assignedServiceRecordIds = (
+        await db.boardAssignment.findMany({
+          where: { organizationId, serviceRecordId: { not: null } },
+          select: { serviceRecordId: true },
+          distinct: ["serviceRecordId"],
+        })
+      ).map((a) => a.serviceRecordId!);
+
+      const assignedInspectionIds = (
+        await db.boardAssignment.findMany({
+          where: { organizationId, inspectionId: { not: null } },
+          select: { inspectionId: true },
+          distinct: ["inspectionId"],
+        })
+      ).map((a) => a.inspectionId!);
+
+      const [serviceRecords, inspections] = await Promise.all([
+        db.serviceRecord.findMany({
+          where: {
+            vehicle: { organizationId },
+            status: { in: ["pending", "in-progress", "waiting-parts", "scheduled"] },
+            id: { notIn: assignedServiceRecordIds },
+          },
+          select: {
+            id: true,
+            title: true,
+            status: true,
+            vehicle: {
+              select: {
+                id: true,
+                make: true,
+                model: true,
+                year: true,
+                licensePlate: true,
+              },
+            },
+          },
+          orderBy: { createdAt: "desc" },
+          take: 100,
+        }),
+        db.inspection.findMany({
+          where: {
+            organizationId,
+            status: { in: ["in_progress", "pending"] },
+            id: { notIn: assignedInspectionIds },
+          },
+          select: {
+            id: true,
+            status: true,
+            vehicle: {
+              select: {
+                id: true,
+                make: true,
+                model: true,
+                year: true,
+                licensePlate: true,
+              },
+            },
+            template: { select: { name: true } },
+          },
+          orderBy: { createdAt: "desc" },
+          take: 100,
+        }),
+      ]);
+
+      return { serviceRecords, inspections };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function createBoardAssignment(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = createBoardAssignmentSchema.parse(input);
+
+      // Verify the technician belongs to this org
+      const tech = await db.technician.findFirst({
+        where: { id: data.technicianId, organizationId },
+      });
+      if (!tech) throw new Error("Technician not found");
+
+      if (!data.serviceRecordId && !data.inspectionId) {
+        throw new Error("Must provide serviceRecordId or inspectionId");
+      }
+
+      const assignment = await db.boardAssignment.create({
+        data: {
+          date: new Date(data.date),
+          sortOrder: data.sortOrder,
+          notes: data.notes,
+          technicianId: data.technicianId,
+          serviceRecordId: data.serviceRecordId || null,
+          inspectionId: data.inspectionId || null,
+          organizationId,
+        },
+        include: {
+          technician: true,
+          serviceRecord: {
+            select: {
+              id: true,
+              title: true,
+              status: true,
+              vehicle: {
+                select: { id: true, make: true, model: true, year: true, licensePlate: true },
+              },
+            },
+          },
+          inspection: {
+            select: {
+              id: true,
+              status: true,
+              vehicle: {
+                select: { id: true, make: true, model: true, year: true, licensePlate: true },
+              },
+              template: { select: { name: true } },
+            },
+          },
+        },
+      });
+
+      // Sync service record: techName + serviceDate from board
+      if (assignment.serviceRecordId) {
+        await db.serviceRecord.update({
+          where: { id: assignment.serviceRecordId },
+          data: { techName: tech.name, serviceDate: new Date(data.date) },
+        });
+      }
+
+      notificationBus.emit("workboard", {
+        type: "assignment_created",
+        organizationId,
+        assignment: {
+          ...assignment,
+          date: assignment.date.toISOString().split("T")[0],
+          createdAt: assignment.createdAt.toISOString(),
+          updatedAt: assignment.updatedAt.toISOString(),
+          technician: {
+            ...assignment.technician,
+            createdAt: assignment.technician.createdAt.toISOString(),
+            updatedAt: assignment.technician.updatedAt.toISOString(),
+          },
+        },
+      });
+
+      revalidatePath("/work-board");
+      return {
+        ...assignment,
+        date: assignment.date.toISOString().split("T")[0],
+        createdAt: assignment.createdAt.toISOString(),
+        updatedAt: assignment.updatedAt.toISOString(),
+        technician: {
+          ...assignment.technician,
+          createdAt: assignment.technician.createdAt.toISOString(),
+          updatedAt: assignment.technician.updatedAt.toISOString(),
+        },
+      };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function moveAssignment(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = moveAssignmentSchema.parse(input);
+
+      const assignment = await db.boardAssignment.findFirst({
+        where: { id: data.id, organizationId },
+      });
+      if (!assignment) throw new Error("Assignment not found");
+
+      const updated = await db.boardAssignment.update({
+        where: { id: data.id },
+        data: {
+          technicianId: data.technicianId,
+          date: new Date(data.date),
+          sortOrder: data.sortOrder,
+        },
+        include: {
+          technician: true,
+          serviceRecord: {
+            select: {
+              id: true,
+              title: true,
+              status: true,
+              vehicle: {
+                select: { id: true, make: true, model: true, year: true, licensePlate: true },
+              },
+            },
+          },
+          inspection: {
+            select: {
+              id: true,
+              status: true,
+              vehicle: {
+                select: { id: true, make: true, model: true, year: true, licensePlate: true },
+              },
+              template: { select: { name: true } },
+            },
+          },
+        },
+      });
+
+      // Sync service record: techName + serviceDate from board
+      if (updated.serviceRecordId) {
+        await db.serviceRecord.update({
+          where: { id: updated.serviceRecordId },
+          data: { techName: updated.technician.name, serviceDate: new Date(data.date) },
+        });
+      }
+
+      notificationBus.emit("workboard", {
+        type: "assignment_moved",
+        organizationId,
+        assignment: {
+          ...updated,
+          date: updated.date.toISOString().split("T")[0],
+          createdAt: updated.createdAt.toISOString(),
+          updatedAt: updated.updatedAt.toISOString(),
+          technician: {
+            ...updated.technician,
+            createdAt: updated.technician.createdAt.toISOString(),
+            updatedAt: updated.technician.updatedAt.toISOString(),
+          },
+        },
+      });
+
+      revalidatePath("/work-board");
+      return {
+        ...updated,
+        date: updated.date.toISOString().split("T")[0],
+        createdAt: updated.createdAt.toISOString(),
+        updatedAt: updated.updatedAt.toISOString(),
+        technician: {
+          ...updated.technician,
+          createdAt: updated.technician.createdAt.toISOString(),
+          updatedAt: updated.technician.updatedAt.toISOString(),
+        },
+      };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function removeAssignment(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = removeAssignmentSchema.parse(input);
+
+      const assignment = await db.boardAssignment.findFirst({
+        where: { id: data.id, organizationId },
+      });
+      if (!assignment) throw new Error("Assignment not found");
+
+      // Clear techName on the linked service record
+      if (assignment.serviceRecordId) {
+        await db.serviceRecord.update({
+          where: { id: assignment.serviceRecordId },
+          data: { techName: null },
+        });
+      }
+
+      await db.boardAssignment.delete({ where: { id: data.id } });
+
+      notificationBus.emit("workboard", {
+        type: "assignment_removed",
+        organizationId,
+        assignmentId: data.id,
+      });
+
+      revalidatePath("/work-board");
+      return { success: true };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}

+ 133 - 0
src/features/workboard/Actions/technicianActions.ts

@@ -0,0 +1,133 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+import { notificationBus } from "@/lib/notification-bus";
+import {
+  createTechnicianSchema,
+  updateTechnicianSchema,
+} from "../Schema/workboardSchema";
+
+export async function getTechnicians() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const technicians = await db.technician.findMany({
+        where: { organizationId, isActive: true },
+        orderBy: { sortOrder: "asc" },
+      });
+      return technicians;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function createTechnician(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = createTechnicianSchema.parse(input);
+
+      const maxOrder = await db.technician.aggregate({
+        where: { organizationId },
+        _max: { sortOrder: true },
+      });
+
+      const technician = await db.technician.create({
+        data: {
+          name: data.name,
+          color: data.color,
+          memberId: data.memberId || null,
+          sortOrder: (maxOrder._max.sortOrder ?? -1) + 1,
+          organizationId,
+        },
+      });
+
+      notificationBus.emit("workboard", {
+        type: "technician_created",
+        organizationId,
+        technician,
+      });
+
+      revalidatePath("/work-board");
+      return technician;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function updateTechnician(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = updateTechnicianSchema.parse(input);
+      const { id, ...updates } = data;
+
+      const technician = await db.technician.updateMany({
+        where: { id, organizationId },
+        data: updates,
+      });
+
+      notificationBus.emit("workboard", {
+        type: "technician_updated",
+        organizationId,
+        technicianId: id,
+      });
+
+      revalidatePath("/work-board");
+      return technician;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function deleteTechnician(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      await db.technician.deleteMany({
+        where: { id, organizationId },
+      });
+      notificationBus.emit("workboard", {
+        type: "technician_removed",
+        organizationId,
+        technicianId: id,
+      });
+
+      revalidatePath("/work-board");
+      return { success: true };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function getOrgMembers() {
+  return withAuth(async ({ organizationId }) => {
+    const members = await db.organizationMember.findMany({
+      where: { organizationId },
+      select: {
+        userId: true,
+        user: { select: { id: true, name: true, email: true } },
+      },
+    });
+    return members.map((m) => ({
+      id: m.user.id,
+      name: m.user.name,
+      email: m.user.email,
+    }));
+  });
+}

+ 47 - 0
src/features/workboard/Components/BoardDayCell.tsx

@@ -0,0 +1,47 @@
+"use client";
+
+import { useDroppable } from "@dnd-kit/core";
+import { cn } from "@/lib/utils";
+import type { BoardAssignmentWithJob } from "../Actions/boardActions";
+import { BoardJobCard } from "./BoardJobCard";
+
+export function BoardDayCell({
+  technicianId,
+  date,
+  assignments,
+  onCardClick,
+}: {
+  technicianId: string;
+  date: string;
+  assignments: BoardAssignmentWithJob[];
+  onCardClick: (assignment: BoardAssignmentWithJob) => void;
+}) {
+  const droppableId = `${technicianId}::${date}`;
+  const { isOver, setNodeRef } = useDroppable({
+    id: droppableId,
+    data: { technicianId, date },
+  });
+
+  const now = new Date();
+  const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+  const isToday = date === today;
+
+  return (
+    <div
+      ref={setNodeRef}
+      className={cn(
+        "flex min-h-[80px] flex-col gap-1 rounded-md border p-1 transition-colors",
+        isOver && "border-primary bg-primary/5",
+        isToday && "border-primary/30 bg-primary/5",
+      )}
+    >
+      {assignments.map((a) => (
+        <BoardJobCard
+          key={a.id}
+          assignment={a}
+          onClick={() => onCardClick(a)}
+        />
+      ))}
+    </div>
+  );
+}

+ 157 - 0
src/features/workboard/Components/BoardJobCard.tsx

@@ -0,0 +1,157 @@
+"use client";
+
+import { useDraggable } from "@dnd-kit/core";
+import { CSS } from "@dnd-kit/utilities";
+import { GripVertical, Wrench, ClipboardCheck } from "lucide-react";
+import type { BoardAssignmentWithJob } from "../Actions/boardActions";
+
+export function BoardJobCard({
+  assignment,
+  onClick,
+}: {
+  assignment: BoardAssignmentWithJob;
+  onClick?: () => void;
+}) {
+  const { attributes, listeners, setNodeRef, transform, isDragging } =
+    useDraggable({
+      id: assignment.id,
+      data: { assignment },
+    });
+
+  const style = transform
+    ? {
+        transform: CSS.Translate.toString(transform),
+        zIndex: isDragging ? 50 : undefined,
+        opacity: isDragging ? 0.5 : undefined,
+      }
+    : undefined;
+
+  const isServiceRecord = !!assignment.serviceRecordId;
+  const vehicle = isServiceRecord
+    ? assignment.serviceRecord?.vehicle
+    : assignment.inspection?.vehicle;
+  const title = isServiceRecord
+    ? assignment.serviceRecord?.title
+    : assignment.inspection?.template?.name;
+  const status = isServiceRecord
+    ? assignment.serviceRecord?.status
+    : assignment.inspection?.status;
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={style}
+      className="group flex cursor-pointer items-start gap-1 rounded-md border bg-card p-1.5 text-xs shadow-sm transition-shadow hover:shadow-md"
+      onClick={onClick}
+    >
+      <button
+        {...listeners}
+        {...attributes}
+        className="mt-0.5 shrink-0 cursor-grab touch-none text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <GripVertical className="h-3 w-3" />
+      </button>
+      <div className="min-w-0 flex-1">
+        <div className="flex items-center gap-1">
+          {isServiceRecord ? (
+            <Wrench className="h-3 w-3 shrink-0 text-blue-500" />
+          ) : (
+            <ClipboardCheck className="h-3 w-3 shrink-0 text-green-500" />
+          )}
+          <span className="truncate font-medium">{title}</span>
+        </div>
+        {vehicle && (
+          <p className="truncate text-muted-foreground">
+            {vehicle.year} {vehicle.make} {vehicle.model}
+            {vehicle.licensePlate ? ` · ${vehicle.licensePlate}` : ""}
+          </p>
+        )}
+        {status && (
+          <span className="inline-block rounded bg-muted px-1 py-0.5 text-[10px] capitalize">
+            {status.replace(/_/g, " ")}
+          </span>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function UnassignedJobCard({
+  job,
+  type,
+}: {
+  job:
+    | {
+        id: string;
+        title: string;
+        status: string;
+        vehicle: {
+          id: string;
+          make: string;
+          model: string;
+          year: number;
+          licensePlate: string | null;
+        };
+      }
+    | {
+        id: string;
+        status: string;
+        vehicle: {
+          id: string;
+          make: string;
+          model: string;
+          year: number;
+          licensePlate: string | null;
+        };
+        template: { name: string };
+      };
+  type: "serviceRecord" | "inspection";
+}) {
+  const dragId =
+    type === "serviceRecord" ? `unassigned-sr-${job.id}` : `unassigned-insp-${job.id}`;
+
+  const { attributes, listeners, setNodeRef, transform, isDragging } =
+    useDraggable({
+      id: dragId,
+      data: { job, type },
+    });
+
+  const style = transform
+    ? {
+        transform: CSS.Translate.toString(transform),
+        zIndex: isDragging ? 50 : undefined,
+        opacity: isDragging ? 0.5 : undefined,
+      }
+    : undefined;
+
+  const isServiceRecord = type === "serviceRecord";
+  const title = isServiceRecord
+    ? (job as { title: string }).title
+    : (job as { template: { name: string } }).template.name;
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={style}
+      {...listeners}
+      {...attributes}
+      className="flex cursor-grab items-start gap-1 rounded-md border bg-card p-1.5 text-xs shadow-sm touch-none active:cursor-grabbing"
+    >
+      <div className="min-w-0 flex-1">
+        <div className="flex items-center gap-1">
+          {isServiceRecord ? (
+            <Wrench className="h-3 w-3 shrink-0 text-blue-500" />
+          ) : (
+            <ClipboardCheck className="h-3 w-3 shrink-0 text-green-500" />
+          )}
+          <span className="truncate font-medium">{title}</span>
+        </div>
+        <p className="truncate text-muted-foreground">
+          {job.vehicle.year} {job.vehicle.make} {job.vehicle.model}
+          {job.vehicle.licensePlate ? ` · ${job.vehicle.licensePlate}` : ""}
+        </p>
+      </div>
+    </div>
+  );
+}

+ 96 - 0
src/features/workboard/Components/JobDetailPopover.tsx

@@ -0,0 +1,96 @@
+"use client";
+
+import Link from "next/link";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Wrench, ClipboardCheck, ExternalLink, Trash2 } from "lucide-react";
+import type { BoardAssignmentWithJob } from "../Actions/boardActions";
+
+export function JobDetailPopover({
+  assignment,
+  open,
+  onOpenChange,
+  onRemove,
+}: {
+  assignment: BoardAssignmentWithJob;
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onRemove: () => void;
+}) {
+  const isServiceRecord = !!assignment.serviceRecordId;
+  const vehicle = isServiceRecord
+    ? assignment.serviceRecord?.vehicle
+    : assignment.inspection?.vehicle;
+  const title = isServiceRecord
+    ? assignment.serviceRecord?.title
+    : assignment.inspection?.template?.name;
+  const status = isServiceRecord
+    ? assignment.serviceRecord?.status
+    : assignment.inspection?.status;
+
+  const detailUrl = isServiceRecord
+    ? `/vehicles/${vehicle?.id}/service/${assignment.serviceRecordId}`
+    : `/inspections/${assignment.inspectionId}`;
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-sm">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            {isServiceRecord ? (
+              <Wrench className="h-4 w-4 shrink-0 text-blue-500" />
+            ) : (
+              <ClipboardCheck className="h-4 w-4 shrink-0 text-green-500" />
+            )}
+            {title}
+          </DialogTitle>
+        </DialogHeader>
+        <div className="space-y-3">
+          {vehicle && (
+            <p className="text-sm text-muted-foreground">
+              {vehicle.year} {vehicle.make} {vehicle.model}
+              {vehicle.licensePlate ? ` · ${vehicle.licensePlate}` : ""}
+            </p>
+          )}
+
+          {status && (
+            <Badge variant="secondary" className="capitalize">
+              {status.replace(/_/g, " ")}
+            </Badge>
+          )}
+
+          {assignment.notes && (
+            <p className="text-sm text-muted-foreground">{assignment.notes}</p>
+          )}
+
+          <p className="text-xs text-muted-foreground">
+            Date: {assignment.date}
+          </p>
+
+          <div className="flex gap-2">
+            <Button asChild variant="outline" size="sm" className="flex-1">
+              <Link href={detailUrl}>
+                <ExternalLink className="mr-1.5 h-3.5 w-3.5" />
+                View Details
+              </Link>
+            </Button>
+            <Button
+              variant="destructive"
+              size="sm"
+              onClick={onRemove}
+            >
+              <Trash2 className="mr-1.5 h-3.5 w-3.5" />
+              Remove
+            </Button>
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 104 - 0
src/features/workboard/Components/PresenterDayView.tsx

@@ -0,0 +1,104 @@
+'use client'
+
+import { Wrench, ClipboardCheck } from 'lucide-react'
+import type { Technician } from '../store/workboardStore'
+import type { BoardAssignmentWithJob } from '../Actions/boardActions'
+
+function DayJobCard({ assignment }: { assignment: BoardAssignmentWithJob }) {
+  const isServiceRecord = !!assignment.serviceRecordId
+  const vehicle = isServiceRecord
+    ? assignment.serviceRecord?.vehicle
+    : assignment.inspection?.vehicle
+  const title = isServiceRecord
+    ? assignment.serviceRecord?.title
+    : assignment.inspection?.template?.name
+  const status = isServiceRecord ? assignment.serviceRecord?.status : assignment.inspection?.status
+
+  return (
+    <div className="flex items-start gap-3 rounded-lg border bg-card p-3 shadow-sm">
+      <div className="mt-0.5">
+        {isServiceRecord ? (
+          <Wrench className="h-5 w-5 text-blue-500" />
+        ) : (
+          <ClipboardCheck className="h-5 w-5 text-green-500" />
+        )}
+      </div>
+      <div className="min-w-0 flex-1">
+        <p className="truncate text-base font-semibold">{title}</p>
+        {vehicle && (
+          <p className="truncate text-sm text-muted-foreground">
+            {vehicle.year} {vehicle.make} {vehicle.model}
+            {vehicle.licensePlate ? ` · ${vehicle.licensePlate}` : ''}
+          </p>
+        )}
+      </div>
+      {status && (
+        <span className="shrink-0 rounded-full bg-muted px-2.5 py-1 text-xs font-medium capitalize">
+          {status.replace(/_/g, ' ')}
+        </span>
+      )}
+    </div>
+  )
+}
+
+export function PresenterDayView({
+  date,
+  technicians,
+  assignments,
+}: {
+  date: string
+  technicians: Technician[]
+  assignments: BoardAssignmentWithJob[]
+}) {
+  const dayAssignments = assignments.filter((a) => a.date === date)
+
+  if (technicians.length === 0) {
+    return (
+      <div className="flex flex-1 items-center justify-center">
+        <p className="text-lg text-muted-foreground">No technicians configured</p>
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex-1 overflow-auto">
+      <div className="divide-y">
+        {technicians.map((tech) => {
+          const techJobs = dayAssignments
+            .filter((a) => a.technicianId === tech.id)
+            .sort((a, b) => a.sortOrder - b.sortOrder)
+
+          return (
+            <div key={tech.id} className="flex gap-4 p-4">
+              {/* Tech label */}
+              <div className="flex w-36 shrink-0 items-start gap-2 pt-1">
+                <div
+                  className="mt-1 h-3.5 w-3.5 shrink-0 rounded-full"
+                  style={{ backgroundColor: tech.color }}
+                />
+                <div className="min-w-0">
+                  <p className="truncate text-sm font-semibold">{tech.name}</p>
+                  <p className="text-xs text-muted-foreground">
+                    {techJobs.length} {techJobs.length === 1 ? 'job' : 'jobs'}
+                  </p>
+                </div>
+              </div>
+
+              {/* Jobs */}
+              <div className="flex flex-1 flex-wrap gap-2">
+                {techJobs.length === 0 && (
+                  <p className="py-2 text-sm text-muted-foreground/60">No jobs today</p>
+                )}
+                {techJobs.map((assignment) => (
+                  <div key={assignment.id} className="w-72">
+                    <DayJobCard assignment={assignment} />
+                  </div>
+                ))}
+              </div>
+            </div>
+          )
+        })}
+      </div>
+    </div>
+  )
+}

+ 128 - 0
src/features/workboard/Components/PresenterKanbanView.tsx

@@ -0,0 +1,128 @@
+'use client'
+
+import { Wrench, ClipboardCheck } from 'lucide-react'
+import type { Technician } from '../store/workboardStore'
+import type { BoardAssignmentWithJob } from '../Actions/boardActions'
+
+const STATUS_COLUMNS = [
+  { key: 'pending', label: 'Pending', color: 'bg-yellow-500' },
+  { key: 'in-progress', label: 'In Progress', color: 'bg-orange-500' },
+  { key: 'waiting-parts', label: 'Waiting Parts', color: 'bg-red-500' },
+  { key: 'completed', label: 'Completed', color: 'bg-emerald-500' },
+] as const
+
+function getAssignmentStatus(a: BoardAssignmentWithJob): string {
+  if (a.serviceRecordId) return a.serviceRecord?.status ?? 'pending'
+  if (a.inspectionId) return a.inspection?.status ?? 'pending'
+  return 'pending'
+}
+
+function KanbanCard({ assignment }: { assignment: BoardAssignmentWithJob }) {
+  const isServiceRecord = !!assignment.serviceRecordId
+  const vehicle = isServiceRecord
+    ? assignment.serviceRecord?.vehicle
+    : assignment.inspection?.vehicle
+  const title = isServiceRecord
+    ? assignment.serviceRecord?.title
+    : assignment.inspection?.template?.name
+
+  return (
+    <div className="rounded-lg border bg-card p-2.5 shadow-sm">
+      <div className="flex items-center gap-1.5">
+        {isServiceRecord ? (
+          <Wrench className="h-4 w-4 shrink-0 text-blue-500" />
+        ) : (
+          <ClipboardCheck className="h-4 w-4 shrink-0 text-green-500" />
+        )}
+        <span className="truncate text-sm font-semibold">{title}</span>
+      </div>
+      {vehicle && (
+        <p className="mt-1 truncate text-sm text-muted-foreground">
+          {vehicle.year} {vehicle.make} {vehicle.model}
+          {vehicle.licensePlate ? ` · ${vehicle.licensePlate}` : ''}
+        </p>
+      )}
+    </div>
+  )
+}
+
+export function PresenterKanbanView({
+  date,
+  technicians,
+  assignments,
+}: {
+  date: string
+  technicians: Technician[]
+  assignments: BoardAssignmentWithJob[]
+}) {
+  const dayAssignments = assignments.filter((a) => a.date === date)
+
+  if (technicians.length === 0) {
+    return (
+      <div className="flex flex-1 items-center justify-center">
+        <p className="text-lg text-muted-foreground">No technicians configured</p>
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex-1 overflow-auto">
+      <div
+        className="grid h-full"
+        style={{
+          gridTemplateColumns: `140px repeat(${STATUS_COLUMNS.length}, 1fr)`,
+          gridTemplateRows: `auto ${technicians.length > 0 ? `repeat(${technicians.length}, 1fr)` : '1fr'}`,
+        }}
+      >
+        {/* Status header row */}
+        <div className="border-b p-2" />
+        {STATUS_COLUMNS.map((col) => {
+          const count = dayAssignments.filter((a) => getAssignmentStatus(a) === col.key).length
+          return (
+            <div key={col.key} className="flex items-center justify-center gap-2 border-b border-l p-2">
+              <div className={`h-2.5 w-2.5 rounded-full ${col.color}`} />
+              <span className="text-sm font-semibold">{col.label}</span>
+              <span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">{count}</span>
+            </div>
+          )
+        })}
+
+        {/* Tech rows */}
+        {technicians.map((tech) => {
+          const techJobs = dayAssignments.filter((a) => a.technicianId === tech.id)
+          return (
+            <div key={tech.id} className="contents">
+              {/* Tech name */}
+              <div className="flex items-center gap-2 border-b p-2">
+                <div
+                  className="h-3 w-3 shrink-0 rounded-full"
+                  style={{ backgroundColor: tech.color }}
+                />
+                <div className="min-w-0">
+                  <p className="truncate text-sm font-semibold">{tech.name}</p>
+                  <p className="text-xs text-muted-foreground">
+                    {techJobs.length} {techJobs.length === 1 ? 'job' : 'jobs'}
+                  </p>
+                </div>
+              </div>
+
+              {/* Status columns */}
+              {STATUS_COLUMNS.map((col) => {
+                const cellJobs = techJobs
+                  .filter((a) => getAssignmentStatus(a) === col.key)
+                  .sort((a, b) => a.sortOrder - b.sortOrder)
+                return (
+                  <div key={`${tech.id}-${col.key}`} className="space-y-1.5 overflow-y-auto border-b border-l p-1.5">
+                    {cellJobs.map((assignment) => (
+                      <KanbanCard key={assignment.id} assignment={assignment} />
+                    ))}
+                  </div>
+                )
+              })}
+            </div>
+          )
+        })}
+      </div>
+    </div>
+  )
+}

+ 217 - 0
src/features/workboard/Components/TechnicianDialog.tsx

@@ -0,0 +1,217 @@
+"use client";
+
+import { useState, useEffect } from "react";
+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 {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Loader2 } from "lucide-react";
+import { createTechnician, updateTechnician, deleteTechnician, getOrgMembers } from "../Actions/technicianActions";
+import { useWorkBoardStore, type Technician } from "../store/workboardStore";
+import { useConfirm } from "@/components/confirm-dialog";
+
+const PRESET_COLORS = [
+  "#3b82f6",
+  "#ef4444",
+  "#22c55e",
+  "#f59e0b",
+  "#8b5cf6",
+  "#ec4899",
+  "#06b6d4",
+  "#f97316",
+  "#14b8a6",
+  "#6366f1",
+];
+
+type OrgMember = { id: string; name: string; email: string };
+
+export function TechnicianDialog({
+  open,
+  onOpenChange,
+  technician,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  technician?: Technician | null;
+}) {
+  const [name, setName] = useState("");
+  const [color, setColor] = useState(PRESET_COLORS[0]);
+  const [memberId, setMemberId] = useState<string>("");
+  const [members, setMembers] = useState<OrgMember[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [deleting, setDeleting] = useState(false);
+  const addTechnician = useWorkBoardStore((s) => s.addTechnician);
+  const confirm = useConfirm();
+
+  useEffect(() => {
+    if (open) {
+      if (technician) {
+        setName(technician.name);
+        setColor(technician.color);
+        setMemberId(technician.memberId || "");
+      } else {
+        setName("");
+        setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]);
+        setMemberId("");
+      }
+      // Fetch org members
+      getOrgMembers().then((res) => {
+        if (res.success && res.data) setMembers(res.data);
+      });
+    }
+  }, [open, technician]);
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!name.trim()) return;
+    setLoading(true);
+
+    if (technician) {
+      const res = await updateTechnician({
+        id: technician.id,
+        name: name.trim(),
+        color,
+        memberId: memberId && memberId !== "none" ? memberId : null,
+      });
+      if (res.success) {
+        // Update store
+        const store = useWorkBoardStore.getState();
+        store.setTechnicians(
+          store.technicians.map((t) =>
+            t.id === technician.id
+              ? { ...t, name: name.trim(), color, memberId: memberId || null }
+              : t,
+          ),
+        );
+        onOpenChange(false);
+      }
+    } else {
+      const res = await createTechnician({
+        name: name.trim(),
+        color,
+        memberId: memberId && memberId !== "none" ? memberId : undefined,
+      });
+      if (res.success && res.data) {
+        addTechnician(res.data as Technician);
+        onOpenChange(false);
+      }
+    }
+
+    setLoading(false);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>
+            {technician ? "Edit Technician" : "Add Technician"}
+          </DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="tech-name">Name</Label>
+            <Input
+              id="tech-name"
+              placeholder="e.g. John Smith"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              autoFocus
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label>Color</Label>
+            <div className="flex flex-wrap gap-2">
+              {PRESET_COLORS.map((c) => (
+                <button
+                  key={c}
+                  type="button"
+                  onClick={() => setColor(c)}
+                  className="h-7 w-7 rounded-full border-2 transition-transform hover:scale-110"
+                  style={{
+                    backgroundColor: c,
+                    borderColor: color === c ? "currentColor" : "transparent",
+                  }}
+                />
+              ))}
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="tech-member">Link to Org Member (optional)</Label>
+            <Select value={memberId} onValueChange={setMemberId}>
+              <SelectTrigger id="tech-member">
+                <SelectValue placeholder="Standalone technician" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="none">Standalone technician</SelectItem>
+                {members.map((m) => (
+                  <SelectItem key={m.id} value={m.id}>
+                    {m.name} ({m.email})
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </div>
+
+          <div className="flex justify-between gap-2">
+            {technician ? (
+              <Button
+                type="button"
+                variant="destructive"
+                disabled={deleting || loading}
+                onClick={async () => {
+                  const ok = await confirm({
+                    title: "Delete Technician",
+                    description: `Remove "${technician.name}" from the work board? Their existing assignments will also be removed.`,
+                    confirmLabel: "Delete",
+                    destructive: true,
+                  });
+                  if (!ok) return;
+                  setDeleting(true);
+                  const res = await deleteTechnician(technician.id);
+                  if (res.success) {
+                    useWorkBoardStore.getState().removeTechnician(technician.id);
+                    onOpenChange(false);
+                  }
+                  setDeleting(false);
+                }}
+              >
+                {deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                Delete
+              </Button>
+            ) : (
+              <div />
+            )}
+            <div className="flex gap-2">
+              <Button
+                type="button"
+                variant="outline"
+                onClick={() => onOpenChange(false)}
+              >
+                Cancel
+              </Button>
+              <Button type="submit" disabled={loading || !name.trim()}>
+                {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                {technician ? "Save" : "Add Technician"}
+              </Button>
+            </div>
+          </div>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 54 - 0
src/features/workboard/Components/TechnicianRow.tsx

@@ -0,0 +1,54 @@
+"use client";
+
+import type { BoardAssignmentWithJob } from "../Actions/boardActions";
+import type { Technician } from "../store/workboardStore";
+import { BoardDayCell } from "./BoardDayCell";
+
+export function TechnicianRow({
+  technician,
+  days,
+  assignments,
+  onCardClick,
+  onTechClick,
+}: {
+  technician: Technician;
+  days: string[];
+  assignments: BoardAssignmentWithJob[];
+  onCardClick: (assignment: BoardAssignmentWithJob) => void;
+  onTechClick?: (technician: Technician) => void;
+}) {
+  return (
+    <div className="grid grid-cols-[140px_repeat(7,1fr)] gap-1">
+      {/* Tech name column */}
+      <button
+        type="button"
+        className="flex items-start gap-2 rounded-md bg-muted/50 p-2 text-left transition-colors hover:bg-muted"
+        onClick={() => onTechClick?.(technician)}
+      >
+        <div
+          className="mt-0.5 h-3 w-3 shrink-0 rounded-full"
+          style={{ backgroundColor: technician.color }}
+        />
+        <span className="text-sm font-medium leading-tight">
+          {technician.name}
+        </span>
+      </button>
+
+      {/* Day cells */}
+      {days.map((day) => {
+        const cellAssignments = assignments.filter(
+          (a) => a.technicianId === technician.id && a.date === day,
+        );
+        return (
+          <BoardDayCell
+            key={day}
+            technicianId={technician.id}
+            date={day}
+            assignments={cellAssignments}
+            onCardClick={onCardClick}
+          />
+        );
+      })}
+    </div>
+  );
+}

+ 72 - 0
src/features/workboard/Components/UnassignedJobsPanel.tsx

@@ -0,0 +1,72 @@
+"use client";
+
+import { useState } from "react";
+import { Search } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useWorkBoardStore } from "../store/workboardStore";
+import { UnassignedJobCard } from "./BoardJobCard";
+
+export function UnassignedJobsPanel() {
+  const serviceRecords = useWorkBoardStore((s) => s.unassignedServiceRecords);
+  const inspections = useWorkBoardStore((s) => s.unassignedInspections);
+  const [search, setSearch] = useState("");
+
+  const lowerSearch = search.toLowerCase();
+
+  const filteredSR = serviceRecords.filter((sr) => {
+    if (!search) return true;
+    return (
+      sr.title.toLowerCase().includes(lowerSearch) ||
+      sr.vehicle.make.toLowerCase().includes(lowerSearch) ||
+      sr.vehicle.model.toLowerCase().includes(lowerSearch) ||
+      sr.vehicle.licensePlate?.toLowerCase().includes(lowerSearch)
+    );
+  });
+
+  const filteredInsp = inspections.filter((i) => {
+    if (!search) return true;
+    return (
+      i.template.name.toLowerCase().includes(lowerSearch) ||
+      i.vehicle.make.toLowerCase().includes(lowerSearch) ||
+      i.vehicle.model.toLowerCase().includes(lowerSearch) ||
+      i.vehicle.licensePlate?.toLowerCase().includes(lowerSearch)
+    );
+  });
+
+  const totalCount = filteredSR.length + filteredInsp.length;
+
+  return (
+    <div className="flex w-64 shrink-0 flex-col rounded-lg border bg-muted/30">
+      <div className="border-b p-3">
+        <h3 className="mb-2 text-sm font-semibold">
+          Unassigned Jobs ({totalCount})
+        </h3>
+        <div className="relative">
+          <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
+          <Input
+            placeholder="Search jobs..."
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+            className="h-8 pl-7 text-xs"
+          />
+        </div>
+      </div>
+      <ScrollArea className="flex-1">
+        <div className="flex flex-col gap-1 p-2">
+          {filteredSR.map((sr) => (
+            <UnassignedJobCard key={sr.id} job={sr} type="serviceRecord" />
+          ))}
+          {filteredInsp.map((insp) => (
+            <UnassignedJobCard key={insp.id} job={insp} type="inspection" />
+          ))}
+          {totalCount === 0 && (
+            <p className="py-8 text-center text-xs text-muted-foreground">
+              No unassigned jobs
+            </p>
+          )}
+        </div>
+      </ScrollArea>
+    </div>
+  );
+}

+ 415 - 0
src/features/workboard/Components/WorkBoardClient.tsx

@@ -0,0 +1,415 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { DndContext, DragOverlay, type DragEndEvent, type DragStartEvent, PointerSensor, TouchSensor, KeyboardSensor, useSensor, useSensors } from "@dnd-kit/core";
+import { toast } from "sonner";
+import { useWorkBoardStore, type Technician } from "../store/workboardStore";
+import { useWorkBoardWebSocket } from "../hooks/useWorkBoardWebSocket";
+import type { BoardAssignmentWithJob } from "../Actions/boardActions";
+import {
+  getBoardAssignments,
+  getUnassignedJobs,
+  createBoardAssignment,
+  moveAssignment,
+  removeAssignment,
+} from "../Actions/boardActions";
+import { WorkBoardToolbar } from "./WorkBoardToolbar";
+import { TechnicianRow } from "./TechnicianRow";
+import { UnassignedJobsPanel } from "./UnassignedJobsPanel";
+import { TechnicianDialog } from "./TechnicianDialog";
+import { JobDetailPopover } from "./JobDetailPopover";
+import { BoardJobCard } from "./BoardJobCard";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Users, Wrench, ClipboardCheck } from "lucide-react";
+
+function toLocalDateString(d: Date): string {
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, "0");
+  const day = String(d.getDate()).padStart(2, "0");
+  return `${y}-${m}-${day}`;
+}
+
+function getMonday(date: Date): string {
+  const d = new Date(date);
+  const day = d.getDay();
+  const diff = d.getDate() - day + (day === 0 ? -6 : 1);
+  d.setDate(diff);
+  return toLocalDateString(d);
+}
+
+function getWeekDays(weekStart: string): string[] {
+  const days: string[] = [];
+  for (let i = 0; i < 7; i++) {
+    const d = new Date(weekStart + "T12:00:00");
+    d.setDate(d.getDate() + i);
+    days.push(toLocalDateString(d));
+  }
+  return days;
+}
+
+const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+
+function formatDayHeader(dateStr: string, index: number) {
+  const d = new Date(dateStr + "T12:00:00");
+  return `${DAY_LABELS[index]} ${d.getDate()}`;
+}
+
+function UnassignedJobOverlayCard({
+  job,
+  type,
+}: {
+  job: Record<string, unknown>;
+  type: "serviceRecord" | "inspection";
+}) {
+  const isServiceRecord = type === "serviceRecord";
+  const vehicle = job.vehicle as { year: number; make: string; model: string; licensePlate: string | null } | undefined;
+  const title = isServiceRecord
+    ? (job.title as string)
+    : ((job.template as { name: string })?.name ?? "Inspection");
+
+  return (
+    <div className="flex items-start gap-1 rounded-md border bg-card p-1.5 text-xs shadow-md">
+      <div className="min-w-0 flex-1">
+        <div className="flex items-center gap-1">
+          {isServiceRecord ? (
+            <Wrench className="h-3 w-3 shrink-0 text-blue-500" />
+          ) : (
+            <ClipboardCheck className="h-3 w-3 shrink-0 text-green-500" />
+          )}
+          <span className="truncate font-medium">{title}</span>
+        </div>
+        {vehicle && (
+          <p className="truncate text-muted-foreground">
+            {vehicle.year} {vehicle.make} {vehicle.model}
+            {vehicle.licensePlate ? ` · ${vehicle.licensePlate}` : ""}
+          </p>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function WorkBoardClient({
+  initialTechnicians,
+  initialAssignments,
+  initialUnassigned,
+  initialWeekStart,
+}: {
+  initialTechnicians: Technician[];
+  initialAssignments: BoardAssignmentWithJob[];
+  initialUnassigned: { serviceRecords: unknown[]; inspections: unknown[] };
+  initialWeekStart: string;
+}) {
+  const store = useWorkBoardStore();
+
+  // Initialize store from server data
+  useEffect(() => {
+    store.setTechnicians(initialTechnicians);
+    store.setAssignments(initialAssignments);
+    store.setUnassigned(
+      initialUnassigned.serviceRecords as Parameters<typeof store.setUnassigned>[0],
+      initialUnassigned.inspections as Parameters<typeof store.setUnassigned>[1],
+    );
+    store.setWeekStart(initialWeekStart);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useWorkBoardWebSocket();
+
+  const [techDialogOpen, setTechDialogOpen] = useState(false);
+  const [editingTech, setEditingTech] = useState<Technician | null>(null);
+  const [selectedAssignment, setSelectedAssignment] = useState<BoardAssignmentWithJob | null>(null);
+  const [popoverOpen, setPopoverOpen] = useState(false);
+  const [activeDrag, setActiveDrag] = useState<{
+    id: string;
+    assignment?: BoardAssignmentWithJob;
+    unassignedJob?: { job: Record<string, unknown>; type: "serviceRecord" | "inspection" };
+  } | null>(null);
+
+  const weekStart = store.weekStart || initialWeekStart;
+  const days = getWeekDays(weekStart);
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+    useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
+    useSensor(KeyboardSensor),
+  );
+
+  const loadWeekData = useCallback(async (ws: string) => {
+    store.setWeekStart(ws);
+    const [assignRes, unassignedRes] = await Promise.all([
+      getBoardAssignments(ws),
+      getUnassignedJobs(),
+    ]);
+    if (assignRes.success && assignRes.data) {
+      store.setAssignments(assignRes.data as BoardAssignmentWithJob[]);
+    }
+    if (unassignedRes.success && unassignedRes.data) {
+      store.setUnassigned(
+        unassignedRes.data.serviceRecords as Parameters<typeof store.setUnassigned>[0],
+        unassignedRes.data.inspections as Parameters<typeof store.setUnassigned>[1],
+      );
+    }
+  }, [store]);
+
+  const handlePrevWeek = () => {
+    const d = new Date(weekStart + "T12:00:00");
+    d.setDate(d.getDate() - 7);
+    loadWeekData(toLocalDateString(d));
+  };
+
+  const handleNextWeek = () => {
+    const d = new Date(weekStart + "T12:00:00");
+    d.setDate(d.getDate() + 7);
+    loadWeekData(toLocalDateString(d));
+  };
+
+  const handleToday = () => {
+    loadWeekData(getMonday(new Date()));
+  };
+
+  const handleDragStart = (event: DragStartEvent) => {
+    const { active } = event;
+    const data = active.data.current;
+    if (data?.assignment) {
+      setActiveDrag({ id: active.id as string, assignment: data.assignment });
+    } else if (data?.job && data?.type) {
+      setActiveDrag({
+        id: active.id as string,
+        unassignedJob: { job: data.job, type: data.type as "serviceRecord" | "inspection" },
+      });
+    } else {
+      setActiveDrag({ id: active.id as string });
+    }
+  };
+
+  const handleDragEnd = async (event: DragEndEvent) => {
+    setActiveDrag(null);
+    const { active, over } = event;
+    if (!over) return;
+
+    const overData = over.data.current as
+      | { technicianId: string; date: string }
+      | undefined;
+    if (!overData?.technicianId || !overData?.date) return;
+
+    const activeData = active.data.current;
+
+    // Existing assignment being moved
+    if (activeData?.assignment) {
+      const assignment = activeData.assignment as BoardAssignmentWithJob;
+      // Same position — skip
+      if (
+        assignment.technicianId === overData.technicianId &&
+        assignment.date === overData.date
+      ) {
+        return;
+      }
+
+      // Optimistic update
+      store.optimisticMove(assignment.id, overData.technicianId, overData.date);
+
+      const res = await moveAssignment({
+        id: assignment.id,
+        technicianId: overData.technicianId,
+        date: overData.date,
+        sortOrder: 0,
+      });
+
+      if (!res.success) {
+        toast.error("Failed to move assignment", { description: res.error });
+        // Revert
+        store.optimisticMove(
+          assignment.id,
+          assignment.technicianId,
+          assignment.date,
+        );
+      } else if (res.data) {
+        store.updateAssignment(res.data as BoardAssignmentWithJob);
+      }
+      return;
+    }
+
+    // Unassigned job being dropped on a cell
+    if (activeData?.job && activeData?.type) {
+      const job = activeData.job;
+      const jobType = activeData.type as "serviceRecord" | "inspection";
+
+      const input = {
+        date: overData.date,
+        technicianId: overData.technicianId,
+        ...(jobType === "serviceRecord"
+          ? { serviceRecordId: job.id }
+          : { inspectionId: job.id }),
+      };
+
+      // Optimistic: remove from unassigned
+      store.removeFromUnassigned(job.id, jobType);
+
+      const res = await createBoardAssignment(input);
+      if (!res.success) {
+        toast.error("Failed to assign job", { description: res.error });
+        // Revert
+        store.addToUnassigned(job, jobType);
+      } else if (res.data) {
+        store.addAssignment(res.data as BoardAssignmentWithJob);
+      }
+    }
+  };
+
+  const handleRemoveAssignment = async (assignment: BoardAssignmentWithJob) => {
+    setPopoverOpen(false);
+    setSelectedAssignment(null);
+
+    // Optimistic removal
+    store.removeAssignment(assignment.id);
+
+    const res = await removeAssignment({ id: assignment.id });
+    if (!res.success) {
+      toast.error("Failed to remove assignment", { description: res.error });
+      // Re-add on failure
+      store.addAssignment(assignment);
+    } else {
+      // Refresh unassigned list
+      const unRes = await getUnassignedJobs();
+      if (unRes.success && unRes.data) {
+        store.setUnassigned(
+          unRes.data.serviceRecords as Parameters<typeof store.setUnassigned>[0],
+          unRes.data.inspections as Parameters<typeof store.setUnassigned>[1],
+        );
+      }
+    }
+  };
+
+  const handleCardClick = (assignment: BoardAssignmentWithJob) => {
+    setSelectedAssignment(assignment);
+    setPopoverOpen(true);
+  };
+
+  if (store.technicians.length === 0) {
+    return (
+      <div className="flex flex-1 flex-col">
+        <WorkBoardToolbar
+          weekStart={weekStart}
+          onPrevWeek={handlePrevWeek}
+          onNextWeek={handleNextWeek}
+          onToday={handleToday}
+          onAddTech={() => setTechDialogOpen(true)}
+        />
+        <div className="flex flex-1 flex-col items-center justify-center gap-4 py-20">
+          <Users className="h-12 w-12 text-muted-foreground/40" />
+          <div className="text-center">
+            <h3 className="text-lg font-medium">No Technicians Yet</h3>
+            <p className="text-sm text-muted-foreground">
+              Add a technician to start scheduling work on the board.
+            </p>
+          </div>
+        </div>
+        <TechnicianDialog
+          open={techDialogOpen}
+          onOpenChange={setTechDialogOpen}
+        />
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex flex-1 flex-col gap-4">
+      <WorkBoardToolbar
+        weekStart={weekStart}
+        onPrevWeek={handlePrevWeek}
+        onNextWeek={handleNextWeek}
+        onToday={handleToday}
+        onAddTech={() => {
+          setEditingTech(null);
+          setTechDialogOpen(true);
+        }}
+      />
+
+      <DndContext
+        sensors={sensors}
+        onDragStart={handleDragStart}
+        onDragEnd={handleDragEnd}
+      >
+        <div className="flex flex-1 gap-4 overflow-hidden">
+          {/* Main grid */}
+          <ScrollArea className="flex-1">
+            <div className="min-w-[900px] space-y-1">
+              {/* Day headers */}
+              <div className="grid grid-cols-[140px_repeat(7,1fr)] gap-1">
+                <div />
+                {days.map((day, i) => {
+                  const isToday = day === toLocalDateString(new Date());
+                  return (
+                    <div
+                      key={day}
+                      className={`rounded-md px-2 py-1 text-center text-xs font-medium ${
+                        isToday
+                          ? "bg-primary text-primary-foreground"
+                          : "text-muted-foreground"
+                      }`}
+                    >
+                      {formatDayHeader(day, i)}
+                    </div>
+                  );
+                })}
+              </div>
+
+              {/* Technician rows */}
+              {store.technicians.map((tech) => (
+                <TechnicianRow
+                  key={tech.id}
+                  technician={tech}
+                  days={days}
+                  assignments={store.assignments}
+                  onCardClick={handleCardClick}
+                  onTechClick={(t) => {
+                    setEditingTech(t);
+                    setTechDialogOpen(true);
+                  }}
+                />
+              ))}
+            </div>
+          </ScrollArea>
+
+          {/* Unassigned panel */}
+          <UnassignedJobsPanel />
+        </div>
+
+        <DragOverlay dropAnimation={null}>
+          {activeDrag?.assignment ? (
+            <div className="w-48 opacity-90">
+              <BoardJobCard assignment={activeDrag.assignment} />
+            </div>
+          ) : activeDrag?.unassignedJob ? (
+            <div className="w-48 opacity-90">
+              <UnassignedJobOverlayCard
+                job={activeDrag.unassignedJob.job}
+                type={activeDrag.unassignedJob.type}
+              />
+            </div>
+          ) : null}
+        </DragOverlay>
+      </DndContext>
+
+      {/* Detail dialog for selected card */}
+      {selectedAssignment && (
+        <JobDetailPopover
+          assignment={selectedAssignment}
+          open={popoverOpen}
+          onOpenChange={(o) => {
+            setPopoverOpen(o);
+            if (!o) setSelectedAssignment(null);
+          }}
+          onRemove={() => handleRemoveAssignment(selectedAssignment)}
+        />
+      )}
+
+      <TechnicianDialog
+        open={techDialogOpen}
+        onOpenChange={setTechDialogOpen}
+        technician={editingTech}
+      />
+    </div>
+  );
+}

+ 401 - 0
src/features/workboard/Components/WorkBoardPresenter.tsx

@@ -0,0 +1,401 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { Button } from '@/components/ui/button'
+import { ChevronLeft, ChevronRight, Wrench, ClipboardCheck, Wifi, WifiOff } from 'lucide-react'
+import { useWorkBoardStore, type Technician } from '../store/workboardStore'
+import { useWorkBoardWebSocket } from '../hooks/useWorkBoardWebSocket'
+import type { BoardAssignmentWithJob } from '../Actions/boardActions'
+import { getBoardAssignments } from '../Actions/boardActions'
+import { getTechnicians } from '../Actions/technicianActions'
+import { PresenterDayView } from './PresenterDayView'
+import { PresenterKanbanView } from './PresenterKanbanView'
+
+type ViewMode = 'week' | 'day' | 'status'
+
+function toLocalDateString(d: Date): string {
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${day}`
+}
+
+function getMonday(date: Date): string {
+  const d = new Date(date)
+  const day = d.getDay()
+  const diff = d.getDate() - day + (day === 0 ? -6 : 1)
+  d.setDate(diff)
+  return toLocalDateString(d)
+}
+
+function getWeekDays(weekStart: string): string[] {
+  const days: string[] = []
+  for (let i = 0; i < 7; i++) {
+    const d = new Date(weekStart + 'T12:00:00')
+    d.setDate(d.getDate() + i)
+    days.push(toLocalDateString(d))
+  }
+  return days
+}
+
+const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
+
+function formatDayHeader(dateStr: string, index: number) {
+  const d = new Date(dateStr + 'T12:00:00')
+  return `${DAY_LABELS[index]} ${d.getDate()}/${d.getMonth() + 1}`
+}
+
+function formatWeekRange(weekStart: string): string {
+  const start = new Date(weekStart + 'T12:00:00')
+  const end = new Date(start)
+  end.setDate(end.getDate() + 6)
+  const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }
+  return `${start.toLocaleDateString(undefined, opts)} – ${end.toLocaleDateString(undefined, opts)}`
+}
+
+function formatDayDate(dateStr: string): string {
+  const d = new Date(dateStr + 'T12:00:00')
+  return d.toLocaleDateString(undefined, {
+    weekday: 'long',
+    month: 'long',
+    day: 'numeric',
+  })
+}
+
+function LiveClock() {
+  const [time, setTime] = useState<string | null>(null)
+  useEffect(() => {
+    function update() {
+      setTime(
+        new Date().toLocaleTimeString(undefined, {
+          hour: '2-digit',
+          minute: '2-digit',
+          second: '2-digit',
+        })
+      )
+    }
+    update()
+    const timer = setInterval(update, 1000)
+    return () => clearInterval(timer)
+  }, [])
+  if (!time) return <span className="tabular-nums">&nbsp;</span>
+  return <span className="tabular-nums">{time}</span>
+}
+
+function PresenterJobCard({ assignment }: { assignment: BoardAssignmentWithJob }) {
+  const isServiceRecord = !!assignment.serviceRecordId
+  const vehicle = isServiceRecord
+    ? assignment.serviceRecord?.vehicle
+    : assignment.inspection?.vehicle
+  const title = isServiceRecord
+    ? assignment.serviceRecord?.title
+    : assignment.inspection?.template?.name
+  const status = isServiceRecord ? assignment.serviceRecord?.status : assignment.inspection?.status
+
+  return (
+    <div className="rounded-md border bg-card p-2 text-sm shadow-sm">
+      <div className="flex items-center gap-1.5">
+        {isServiceRecord ? (
+          <Wrench className="h-4 w-4 shrink-0 text-blue-500" />
+        ) : (
+          <ClipboardCheck className="h-4 w-4 shrink-0 text-green-500" />
+        )}
+        <span className="truncate font-semibold">{title}</span>
+      </div>
+      {vehicle && (
+        <p className="mt-0.5 truncate text-muted-foreground">
+          {vehicle.year} {vehicle.make} {vehicle.model}
+          {vehicle.licensePlate ? ` · ${vehicle.licensePlate}` : ''}
+        </p>
+      )}
+      {status && (
+        <span className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5 text-xs capitalize">
+          {status.replace(/_/g, ' ')}
+        </span>
+      )}
+    </div>
+  )
+}
+
+export function WorkBoardPresenter({
+  initialTechnicians,
+  initialAssignments,
+  initialWeekStart,
+}: {
+  initialTechnicians: Technician[]
+  initialAssignments: BoardAssignmentWithJob[]
+  initialWeekStart: string
+}) {
+  const store = useWorkBoardStore()
+  const [viewMode, setViewMode] = useState<ViewMode>(() => {
+    if (typeof window !== 'undefined') {
+      const saved = localStorage.getItem('presenter-view-mode')
+      if (saved === 'day' || saved === 'week' || saved === 'status') return saved
+    }
+    return 'week'
+  })
+  const [selectedDate, setSelectedDate] = useState(() => toLocalDateString(new Date()))
+
+  const handleSetViewMode = (mode: ViewMode) => {
+    setViewMode(mode)
+    localStorage.setItem('presenter-view-mode', mode)
+  }
+
+  // Initialize store from server data
+  useEffect(() => {
+    store.setTechnicians(initialTechnicians)
+    store.setAssignments(initialAssignments)
+    store.setWeekStart(initialWeekStart)
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  useWorkBoardWebSocket()
+
+  const weekStart = store.weekStart || initialWeekStart
+  const days = getWeekDays(weekStart)
+  const today = toLocalDateString(new Date())
+
+  const loadWeekData = useCallback(
+    async (ws: string) => {
+      store.setWeekStart(ws)
+      const [assignRes, techRes] = await Promise.all([getBoardAssignments(ws), getTechnicians()])
+      if (assignRes.success && assignRes.data) {
+        store.setAssignments(assignRes.data as BoardAssignmentWithJob[])
+      }
+      if (techRes.success && techRes.data) {
+        store.setTechnicians(techRes.data as Technician[])
+      }
+    },
+    [store]
+  )
+
+  // Ensure the selected day's week data is loaded
+  const ensureWeekLoaded = useCallback(
+    (dateStr: string) => {
+      const monday = getMonday(new Date(dateStr + 'T12:00:00'))
+      if (monday !== weekStart) {
+        loadWeekData(monday)
+      }
+    },
+    [weekStart, loadWeekData]
+  )
+
+  const handlePrev = () => {
+    if (viewMode === 'week') {
+      const d = new Date(weekStart + 'T12:00:00')
+      d.setDate(d.getDate() - 7)
+      loadWeekData(toLocalDateString(d))
+    } else {
+      const d = new Date(selectedDate + 'T12:00:00')
+      d.setDate(d.getDate() - 1)
+      const newDate = toLocalDateString(d)
+      setSelectedDate(newDate)
+      ensureWeekLoaded(newDate)
+    }
+  }
+
+  const handleNext = () => {
+    if (viewMode === 'week') {
+      const d = new Date(weekStart + 'T12:00:00')
+      d.setDate(d.getDate() + 7)
+      loadWeekData(toLocalDateString(d))
+    } else {
+      const d = new Date(selectedDate + 'T12:00:00')
+      d.setDate(d.getDate() + 1)
+      const newDate = toLocalDateString(d)
+      setSelectedDate(newDate)
+      ensureWeekLoaded(newDate)
+    }
+  }
+
+  const handleToday = () => {
+    const todayStr = toLocalDateString(new Date())
+    setSelectedDate(todayStr)
+    loadWeekData(getMonday(new Date()))
+  }
+
+  // Auto-advance at midnight
+  useEffect(() => {
+    function msUntilMidnight() {
+      const now = new Date()
+      const midnight = new Date(now)
+      midnight.setHours(24, 0, 0, 0)
+      return midnight.getTime() - now.getTime()
+    }
+
+    let timeout: ReturnType<typeof setTimeout>
+
+    function scheduleAdvance() {
+      timeout = setTimeout(() => {
+        const now = new Date()
+        const newMonday = getMonday(now)
+        setSelectedDate(toLocalDateString(now))
+        loadWeekData(newMonday)
+        scheduleAdvance()
+      }, msUntilMidnight() + 500)
+    }
+
+    scheduleAdvance()
+    return () => clearTimeout(timeout)
+  }, [loadWeekData])
+
+  const dateLabel =
+    viewMode === 'week' ? formatWeekRange(weekStart) : formatDayDate(selectedDate)
+
+  return (
+    <div className="flex h-screen flex-col bg-background">
+      {/* Header bar */}
+      <header className="flex shrink-0 items-center justify-between border-b px-4 py-2">
+        <div className="flex items-center gap-3">
+          <h1 className="text-lg font-bold">Work Board</h1>
+          <span className="text-sm text-muted-foreground">{dateLabel}</span>
+        </div>
+        <div className="flex items-center gap-3">
+          {/* View mode toggle */}
+          <div className="flex rounded-md border">
+            <Button
+              variant={viewMode === 'day' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none rounded-l-md"
+              onClick={() => handleSetViewMode('day')}
+            >
+              Day
+            </Button>
+            <Button
+              variant={viewMode === 'status' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none border-x"
+              onClick={() => handleSetViewMode('status')}
+            >
+              Status
+            </Button>
+            <Button
+              variant={viewMode === 'week' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none rounded-r-md"
+              onClick={() => handleSetViewMode('week')}
+            >
+              Week
+            </Button>
+          </div>
+
+          {/* Navigation */}
+          <div className="flex items-center gap-1">
+            <Button variant="ghost" size="icon" onClick={handlePrev}>
+              <ChevronLeft className="h-5 w-5" />
+            </Button>
+            <Button variant="outline" size="sm" onClick={handleToday}>
+              Today
+            </Button>
+            <Button variant="ghost" size="icon" onClick={handleNext}>
+              <ChevronRight className="h-5 w-5" />
+            </Button>
+          </div>
+
+          {/* Status */}
+          <div className="flex items-center gap-2 text-sm text-muted-foreground">
+            {store.isConnected ? (
+              <span
+                className="flex items-center gap-1.5 text-green-600"
+                title="Board is receiving live updates"
+              >
+                <Wifi className="h-4 w-4" />
+                <span className="text-xs">Live</span>
+              </span>
+            ) : (
+              <span
+                className="flex items-center gap-1.5 animate-pulse text-red-500"
+                title="Check WebSocket connection — ensure you are logged in as an admin or owner"
+              >
+                <WifiOff className="h-4 w-4" />
+                <span className="text-xs">Live updates disconnected</span>
+              </span>
+            )}
+            <LiveClock />
+          </div>
+        </div>
+      </header>
+
+      {/* Content */}
+      {viewMode === 'day' ? (
+        <PresenterDayView
+          date={selectedDate}
+          technicians={store.technicians}
+          assignments={store.assignments}
+        />
+      ) : viewMode === 'status' ? (
+        <PresenterKanbanView
+          date={selectedDate}
+          technicians={store.technicians}
+          assignments={store.assignments}
+        />
+      ) : (
+        <div className="flex-1 overflow-auto">
+          <div
+            className="grid h-full min-w-225"
+            style={{
+              gridTemplateColumns: '160px repeat(7, 1fr)',
+              gridTemplateRows: `auto ${store.technicians.length > 0 ? `repeat(${store.technicians.length}, 1fr)` : '1fr'}`,
+            }}
+          >
+            {/* Day header row */}
+            <div className="border-b p-2" />
+            {days.map((day, i) => {
+              const isToday = day === today
+              return (
+                <div
+                  key={day}
+                  className={`border-b border-l p-2 text-center text-sm font-semibold ${
+                    isToday ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
+                  }`}
+                >
+                  {formatDayHeader(day, i)}
+                </div>
+              )
+            })}
+
+            {/* Technician rows */}
+            {store.technicians.map((tech) => {
+              const techAssignments = store.assignments.filter((a) => a.technicianId === tech.id)
+              return (
+                <div key={tech.id} className="contents">
+                  <div className="flex items-center gap-2 border-b p-2">
+                    <div
+                      className="h-3 w-3 shrink-0 rounded-full"
+                      style={{ backgroundColor: tech.color }}
+                    />
+                    <span className="truncate text-sm font-semibold">{tech.name}</span>
+                  </div>
+                  {days.map((day) => {
+                    const cellAssignments = techAssignments
+                      .filter((a) => a.date === day)
+                      .sort((a, b) => a.sortOrder - b.sortOrder)
+                    const isToday = day === today
+                    return (
+                      <div
+                        key={`${tech.id}-${day}`}
+                        className={`space-y-1 overflow-y-auto border-b border-l p-1.5 ${
+                          isToday ? 'bg-primary/5' : ''
+                        }`}
+                      >
+                        {cellAssignments.map((assignment) => (
+                          <PresenterJobCard key={assignment.id} assignment={assignment} />
+                        ))}
+                      </div>
+                    )
+                  })}
+                </div>
+              )
+            })}
+
+            {store.technicians.length === 0 && (
+              <div className="col-span-8 flex items-center justify-center p-10">
+                <p className="text-lg text-muted-foreground">No technicians configured</p>
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 67 - 0
src/features/workboard/Components/WorkBoardToolbar.tsx

@@ -0,0 +1,67 @@
+"use client";
+
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { ChevronLeft, ChevronRight, Plus, Calendar, Monitor } from "lucide-react";
+
+function formatWeekRange(weekStart: string) {
+  const start = new Date(weekStart + "T12:00:00");
+  const end = new Date(start);
+  end.setDate(end.getDate() + 6);
+
+  const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
+  const startStr = start.toLocaleDateString("en-US", opts);
+  const endStr = end.toLocaleDateString("en-US", {
+    ...opts,
+    year: "numeric",
+  });
+  return `${startStr} – ${endStr}`;
+}
+
+export function WorkBoardToolbar({
+  weekStart,
+  onPrevWeek,
+  onNextWeek,
+  onToday,
+  onAddTech,
+}: {
+  weekStart: string;
+  onPrevWeek: () => void;
+  onNextWeek: () => void;
+  onToday: () => void;
+  onAddTech: () => void;
+}) {
+  return (
+    <div className="flex items-center justify-between gap-4">
+      <div className="flex items-center gap-2">
+        <Button variant="outline" size="sm" onClick={onToday}>
+          <Calendar className="mr-1.5 h-3.5 w-3.5" />
+          Today
+        </Button>
+        <div className="flex items-center gap-1">
+          <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrevWeek}>
+            <ChevronLeft className="h-4 w-4" />
+          </Button>
+          <span className="min-w-[180px] text-center text-sm font-medium">
+            {formatWeekRange(weekStart)}
+          </span>
+          <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNextWeek}>
+            <ChevronRight className="h-4 w-4" />
+          </Button>
+        </div>
+      </div>
+      <div className="flex items-center gap-2">
+        <Button variant="outline" size="sm" asChild>
+          <Link href="/work-board/presenter" target="_blank">
+            <Monitor className="mr-1.5 h-3.5 w-3.5" />
+            Presenter
+          </Link>
+        </Button>
+        <Button size="sm" onClick={onAddTech}>
+          <Plus className="mr-1.5 h-3.5 w-3.5" />
+          Add Technician
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 47 - 0
src/features/workboard/Schema/workboardSchema.ts

@@ -0,0 +1,47 @@
+import { z } from "zod";
+
+export const createTechnicianSchema = z.object({
+  name: z.string().min(1, "Name is required").max(100),
+  color: z
+    .string()
+    .regex(/^#[0-9a-fA-F]{6}$/, "Must be a valid hex color")
+    .default("#3b82f6"),
+  memberId: z.string().optional(),
+});
+
+export const updateTechnicianSchema = z.object({
+  id: z.string().min(1),
+  name: z.string().min(1, "Name is required").max(100).optional(),
+  color: z
+    .string()
+    .regex(/^#[0-9a-fA-F]{6}$/, "Must be a valid hex color")
+    .optional(),
+  isActive: z.boolean().optional(),
+  sortOrder: z.number().int().min(0).optional(),
+  memberId: z.string().nullable().optional(),
+});
+
+export const createBoardAssignmentSchema = z.object({
+  date: z.string().min(1, "Date is required"),
+  technicianId: z.string().min(1, "Technician is required"),
+  serviceRecordId: z.string().optional(),
+  inspectionId: z.string().optional(),
+  notes: z.string().optional(),
+  sortOrder: z.number().int().min(0).default(0),
+});
+
+export const moveAssignmentSchema = z.object({
+  id: z.string().min(1),
+  technicianId: z.string().min(1),
+  date: z.string().min(1),
+  sortOrder: z.number().int().min(0).default(0),
+});
+
+export const removeAssignmentSchema = z.object({
+  id: z.string().min(1),
+});
+
+export type CreateTechnicianInput = z.infer<typeof createTechnicianSchema>;
+export type UpdateTechnicianInput = z.infer<typeof updateTechnicianSchema>;
+export type CreateBoardAssignmentInput = z.infer<typeof createBoardAssignmentSchema>;
+export type MoveAssignmentInput = z.infer<typeof moveAssignmentSchema>;

+ 136 - 0
src/features/workboard/hooks/useWorkBoardWebSocket.ts

@@ -0,0 +1,136 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useWorkBoardStore } from "../store/workboardStore";
+
+export function useWorkBoardWebSocket() {
+  const wsRef = useRef<WebSocket | null>(null);
+  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
+  const mountedRef = useRef(true);
+
+  useEffect(() => {
+    mountedRef.current = true;
+
+    function connect() {
+      if (!mountedRef.current) return;
+
+      const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+      const url = `${protocol}//${window.location.host}/api/protected/ws`;
+      const ws = new WebSocket(url);
+      wsRef.current = ws;
+
+      ws.onopen = () => {
+        useWorkBoardStore.getState().setConnected(true);
+      };
+
+      ws.onmessage = (event) => {
+        try {
+          const msg = JSON.parse(event.data);
+          if (msg.type !== "workboard") return;
+
+          const store = useWorkBoardStore.getState();
+          const data = msg.data;
+
+          switch (data.type) {
+            case "assignment_created":
+              store.addAssignment(data.assignment);
+              // Remove from unassigned lists
+              if (data.assignment.serviceRecordId) {
+                store.removeFromUnassigned(
+                  data.assignment.serviceRecordId,
+                  "serviceRecord",
+                );
+              }
+              if (data.assignment.inspectionId) {
+                store.removeFromUnassigned(
+                  data.assignment.inspectionId,
+                  "inspection",
+                );
+              }
+              break;
+
+            case "assignment_moved":
+              store.updateAssignment(data.assignment);
+              break;
+
+            case "assignment_removed":
+              store.removeAssignment(data.assignmentId);
+              break;
+
+            case "technician_created":
+              store.addTechnician(data.technician);
+              break;
+
+            case "technician_updated":
+            case "technician_removed":
+              // Reload full technician list for updates/removals
+              import("../Actions/technicianActions").then(({ getTechnicians }) => {
+                getTechnicians().then((res) => {
+                  if (res.success && res.data) {
+                    store.setTechnicians(res.data as Parameters<typeof store.setTechnicians>[0]);
+                  }
+                });
+              });
+              break;
+
+            case "job_status_changed": {
+              const { serviceRecordId, inspectionId, status, serviceRecord } = data;
+              const activeStatuses = ["pending", "in-progress", "waiting-parts", "scheduled"];
+
+              // Update the status on matching assignments in-place
+              const updated = store.assignments.map((a) => {
+                if (serviceRecordId && a.serviceRecordId === serviceRecordId && a.serviceRecord) {
+                  return { ...a, serviceRecord: { ...a.serviceRecord, status } };
+                }
+                if (inspectionId && a.inspectionId === inspectionId && a.inspection) {
+                  return { ...a, inspection: { ...a.inspection, status } };
+                }
+                return a;
+              });
+              store.setAssignments(updated);
+
+              // Add/remove from unassigned pool for service records
+              if (serviceRecordId && serviceRecord) {
+                const isAssigned = store.assignments.some(
+                  (a) => a.serviceRecordId === serviceRecordId,
+                );
+                const isAlreadyUnassigned = store.unassignedServiceRecords.some(
+                  (sr) => sr.id === serviceRecordId,
+                );
+
+                if (activeStatuses.includes(status) && !isAssigned && !isAlreadyUnassigned) {
+                  store.addToUnassigned(serviceRecord, "serviceRecord");
+                } else if (!activeStatuses.includes(status)) {
+                  store.removeFromUnassigned(serviceRecordId, "serviceRecord");
+                }
+              }
+              break;
+            }
+          }
+        } catch {
+          // ignore malformed messages
+        }
+      };
+
+      ws.onclose = () => {
+        useWorkBoardStore.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();
+    };
+  }, []);
+}

+ 154 - 0
src/features/workboard/store/workboardStore.ts

@@ -0,0 +1,154 @@
+import { create } from "zustand";
+import type { BoardAssignmentWithJob } from "../Actions/boardActions";
+
+type UnassignedServiceRecord = {
+  id: string;
+  title: string;
+  status: string;
+  vehicle: {
+    id: string;
+    make: string;
+    model: string;
+    year: number;
+    licensePlate: string | null;
+  };
+};
+
+type UnassignedInspection = {
+  id: string;
+  status: string;
+  vehicle: {
+    id: string;
+    make: string;
+    model: string;
+    year: number;
+    licensePlate: string | null;
+  };
+  template: { name: string };
+};
+
+export type Technician = {
+  id: string;
+  name: string;
+  color: string;
+  isActive: boolean;
+  sortOrder: number;
+  memberId: string | null;
+  organizationId: string;
+};
+
+type WorkBoardState = {
+  technicians: Technician[];
+  assignments: BoardAssignmentWithJob[];
+  unassignedServiceRecords: UnassignedServiceRecord[];
+  unassignedInspections: UnassignedInspection[];
+  weekStart: string;
+  isConnected: boolean;
+
+  setTechnicians: (techs: Technician[]) => void;
+  addTechnician: (tech: Technician) => void;
+  removeTechnician: (id: string) => void;
+
+  setAssignments: (assignments: BoardAssignmentWithJob[]) => void;
+  addAssignment: (assignment: BoardAssignmentWithJob) => void;
+  updateAssignment: (assignment: BoardAssignmentWithJob) => void;
+  removeAssignment: (id: string) => void;
+
+  setUnassigned: (
+    serviceRecords: UnassignedServiceRecord[],
+    inspections: UnassignedInspection[],
+  ) => void;
+  removeFromUnassigned: (jobId: string, type: "serviceRecord" | "inspection") => void;
+  addToUnassigned: (job: UnassignedServiceRecord | UnassignedInspection, type: "serviceRecord" | "inspection") => void;
+
+  setWeekStart: (weekStart: string) => void;
+  setConnected: (connected: boolean) => void;
+
+  // Optimistic DnD: move an assignment locally before server confirms
+  optimisticMove: (
+    assignmentId: string,
+    newTechId: string,
+    newDate: string,
+  ) => void;
+};
+
+export const useWorkBoardStore = create<WorkBoardState>((set) => ({
+  technicians: [],
+  assignments: [],
+  unassignedServiceRecords: [],
+  unassignedInspections: [],
+  weekStart: "",
+  isConnected: false,
+
+  setTechnicians: (technicians) => set({ technicians }),
+  addTechnician: (tech) =>
+    set((s) => ({ technicians: [...s.technicians, tech] })),
+  removeTechnician: (id) =>
+    set((s) => ({ technicians: s.technicians.filter((t) => t.id !== id) })),
+
+  setAssignments: (assignments) => set({ assignments }),
+  addAssignment: (assignment) =>
+    set((s) => {
+      // Avoid duplicates
+      const filtered = s.assignments.filter((a) => a.id !== assignment.id);
+      return { assignments: [...filtered, assignment] };
+    }),
+  updateAssignment: (assignment) =>
+    set((s) => ({
+      assignments: s.assignments.map((a) =>
+        a.id === assignment.id ? assignment : a,
+      ),
+    })),
+  removeAssignment: (id) =>
+    set((s) => ({
+      assignments: s.assignments.filter((a) => a.id !== id),
+    })),
+
+  setUnassigned: (serviceRecords, inspections) =>
+    set({
+      unassignedServiceRecords: serviceRecords,
+      unassignedInspections: inspections,
+    }),
+  removeFromUnassigned: (jobId, type) =>
+    set((s) =>
+      type === "serviceRecord"
+        ? {
+            unassignedServiceRecords: s.unassignedServiceRecords.filter(
+              (sr) => sr.id !== jobId,
+            ),
+          }
+        : {
+            unassignedInspections: s.unassignedInspections.filter(
+              (i) => i.id !== jobId,
+            ),
+          },
+    ),
+  addToUnassigned: (job, type) =>
+    set((s) =>
+      type === "serviceRecord"
+        ? {
+            unassignedServiceRecords: [
+              job as UnassignedServiceRecord,
+              ...s.unassignedServiceRecords,
+            ],
+          }
+        : {
+            unassignedInspections: [
+              job as UnassignedInspection,
+              ...s.unassignedInspections,
+            ],
+          },
+    ),
+
+  setWeekStart: (weekStart) => set({ weekStart }),
+  setConnected: (isConnected) => set({ isConnected }),
+
+  optimisticMove: (assignmentId, newTechId, newDate) =>
+    set((s) => ({
+      assignments: s.assignments.map((a) =>
+        a.id === assignmentId
+          ? { ...a, technicianId: newTechId, date: newDate }
+          : a,
+      ),
+    })),
+}));

+ 11 - 0
src/lib/permissions.ts

@@ -18,6 +18,7 @@ export enum PermissionSubject {
   INSPECTIONS = "inspections",
   REPORTS = "reports",
   SETTINGS = "settings",
+  WORK_BOARD = "work_board",
 }
 
 export type PermissionInput = {
@@ -129,6 +130,16 @@ export const permissionGroups: PermissionGroup[] = [
       { action: PermissionAction.READ, label: "View" },
     ],
   },
+  {
+    name: "Work Board",
+    subject: PermissionSubject.WORK_BOARD,
+    permissions: [
+      { action: PermissionAction.CREATE, label: "Create" },
+      { action: PermissionAction.READ, label: "View" },
+      { action: PermissionAction.UPDATE, label: "Edit" },
+      { action: PermissionAction.DELETE, label: "Delete" },
+    ],
+  },
   {
     name: "Settings",
     subject: PermissionSubject.SETTINGS,

+ 0 - 1
src/lib/table-utils.ts

@@ -9,6 +9,5 @@ export const statusColors: Record<string, string> = {
   pending: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
   "in-progress": "bg-blue-500/10 text-blue-500 border-blue-500/20",
   "waiting-parts": "bg-orange-500/10 text-orange-500 border-orange-500/20",
-  ready: "bg-purple-500/10 text-purple-500 border-purple-500/20",
   completed: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
 };