4
0
Эх сурвалжийг харах

Work board: plan the week on a timeline (#265)

* Plan the week on a timeline, by technician or by work bay

The work board's week view showed jobs as cards in a day cell, so a
30-minute job and a full day looked the same. It now draws the whole week
against a clock: time down the left, one column per lane per day, blocks
sized by how long the job actually takes.

Lanes group by technician, by work bay, or not at all. Work bays are new:
a lift, a ramp, a wash bay, with their own colour and daily capacity, so a
shop that plans by bay rather than by person can.

Also: one scheduleJob write behind every drag instead of two round trips,
work bays in backups, and a bay picker on the work order itself.

* Fill the window, and lead with the week number

The app shell never gave pages a real height, so every `flex-1` a page
wrote resolved to nothing and the week board stopped at its content with
the rest of the screen blank. The shell is a flex column now, and the
timeline divides whatever height it gets among the hours it has to show:
the default zoom fills the board exactly and zooming in scrolls.

Also from the planner this view was modelled on: the ISO week number
beside the date range, a heavier rule between days than between lanes,
and columns narrow enough that a shop with eight bays still sees Monday
to Friday at once. The group-by control no longer clips its own label.

* Show where a dragged job will land, and put the week on the wall display

Dropping a card placed the job wherever the cursor happened to be, with
nothing on screen to aim at, and dragging an existing block snapped the
movement rather than the result, so a job already sitting at 12:07 stayed
seven minutes off every line. Blocks now snap to the grid itself, a
dashed ghost shows the snapped slot while a job is still in the air, and
a block being dragged reads out its own time.

The presenter's week was the card grid this branch replaced, so it now
shows the same timeline in a read-only mode, with a technician/bay toggle
that stays in the URL for a display pinned to one view.

Times follow the workshop's 12h/24h setting instead of always printing 24h.

* Make the week board survive a fifteen-technician shop

Fifteen technicians across five days is seventy-five columns and about
3,400px, which is three screens of sideways scrolling with names truncated
to nothing. The toolbar now has a lane filter: check off the lanes you
care about, or jump straight to the ones carrying work this week. The
choice is per device, and the button carries a shown/total count so a
filtered board never passes for the whole shop.

The grid rules were also one element per slot per column, which at that
size was two thousand nodes of decoration re-rendered on every frame of a
drag. They are two stacked gradients now, three elements a column.

* Let the board choose its layout, and say more on hover

Fifteen technicians on a timeline is seventy-five columns, and no amount
of scrolling makes that a week you can read. The original card grid was
the right answer for a shop that size all along, so it is back as a
choice rather than a casualty: a Layout control switches the week between
Timeline and Cards, and Cards is now lane-aware, so it groups by work bay
too. Fifteen technicians is fifteen rows and five columns, on one screen.

Dragging a card between day cells now goes through the same scheduleJob
write as the timeline, keeping the job's time of day.

Lane headers hover with something worth reading: the full name, booked
against capacity, what is left, and how many jobs make up the number.

* Let CSS own the timeline height so zoom works

Zoom did nothing because the board's height was measured in JavaScript and
every position derived from that number. When the measurement was small or
missing, all three zoom levels clamped to the same floor and the board
collapsed with it.

Nothing is measured now. The body is a grid row of minmax(<floor>, 1fr),
where the floor is a percentage of the board at the wider zooms, and every
block, rule and label sits at a percentage of that row. Zoom is a track
size, so it is monotonic by construction and correct on the first paint.
Drag reads its scale off a rendered column, so the maths and the layout
cannot disagree.

The lane menu keeps its two actions pinned and scrolls only the list, and
no longer scrolls the board when it closes.

* Name the owner when the column cannot

Grouping the week by nothing is the only timeline that fits a seventeen-lane
shop on one screen: five day columns, everyone's work side by side. But the
block took its accent from the lane, which ungrouped is a single grey
catch-all, so the one view that showed the whole week could not say whose
job anything was. Blocks now carry their technician's or bay's colour and
name whenever the column heading does not.

* Drag the board sideways, and show that you can

A week wider than the window was only reachable by a scrollbar at the far
bottom of a tall board, which is nowhere near where the eye is. The
background now pans under the mouse the way a map does, with the cursor
saying so before the drag starts. Touch is left alone: a finger already
flicks natively and hijacking it would break that.

Nothing said the week continued past the edge either. Fades and a round
button appear on whichever side has more, paging by exactly one day so it
lands on a boundary, and the hint line mentions the drag only while there
is somewhere to drag to.

* Say what the columns do instead of naming the concept

"By technician / By work bay / No grouping" described the mechanism and
left the reader to work out the consequence. The consequence is the whole
point: one of those three is the option that fits a week on screen. They
read "A column per technician / per work bay / per day" now, and the
board grows a "See the whole week" button whenever it runs off the edge.

* Make dragging cheap, and let the scrollbar do its job

Dragging a job onto the board asked every column for a fresh bounding box
on every pointer move. A seventeen-technician week has eighty-five of them,
so each frame forced eighty-five layouts, and the parent re-rendered all of
them to move one dashed rectangle. Boxes are measured once per gesture and
thrown away on scroll or resize, the preview paints once per frame, and
columns are memoised so only the one under the pointer redraws.

Pressing the horizontal scrollbar started a pan, so the board fought the
scrollbar and horizontal scrolling did nothing. That press belongs to the
browser.

Day/Week and the zoom pair are ButtonGroups of real Buttons now, so they
are the same height as every other control in the toolbar.

* Zoom with the wheel, and stop the drag card hiding the drop

Two buttons stepping through three named sizes was a clumsy way to express
a zoom. Ctrl (or command) with the wheel does it now, continuously between
fitting the day and six times that, which is also what a trackpad pinch
sends. The buttons are gone and the toolbar is shorter for it.

The card following the cursor was the full job card, and it sat squarely on
top of the ghost showing where the job would land, so placing anything
precisely meant guessing. It is a single small line now, offset down and
right of the cursor, leaving the snapped slot and its time in plain view.

* Stop a wide board shoving the unassigned panel off the row

A flex item's automatic minimum width is its content's, and the timeline's
content is a grid up to eighty-five columns wide. So with every lane
showing, the board refused to shrink and pushed the unassigned panel past
the right edge of a row that clips its overflow: the jobs waiting to be
scheduled simply vanished, exactly when a full board made them most
useful. min-w-0 lets the board take what is left and scroll inside it.

* Let the presenter show cards too

Choosing Cards and then opening the presenter still gave a timeline: the
presenter hardcoded one and never asked what you were looking at.

It has its own Timeline/Cards toggle now, kept in the address bar next to
the technician/bay one, because a wall display is usually a different
machine and should stay pinned to whatever it was set to rather than
follow a planner's saved preference. Opening the presenter from the board
carries the current layout and grouping across, so it starts on what you
were already looking at.

The card grid gained a read-only mode for it: no drop targets, and cards
without a grip, since the presenter wires no drag handlers for that grip
to lead to.

* Name the right axis in Cards

Cards lays its lanes out as rows, so "A column per technician" pointed at
an axis that is not there. The same setting now reads "A row per
technician" whenever Cards is the layout, and the control is labelled Rows
rather than Columns.

The presenter's toggle was borrowing those same sentences as button
labels, which was both too long for a header and wrong in Cards. It says
Technicians and Work bays.

* Ask one question per control in the presenter

The presenter's header put "Timeline" and "Status" beside "Day" and
"Week", which is two different questions in one row: how much to show and
how to draw it. So Timeline appeared twice on screen meaning two
different things, once for a day and once for a week.

It asks separately now. Day or Week first; then a day is drawn as a
timeline, a list or a status board, and a week as a timeline or an
overview. Nothing is ever offered twice.

"Cards" named the widget rather than what it gives you. The difference
that matters is that the layout drops the clock to fit the whole week, so
it is Overview, and the presenter's day-by-technician mode is List rather
than the old "Day", which now names the period.

* Make Day the same board, and tell the hover something worth reading

Switching to Day threw the whole board away and dropped into a separate
component: no grouping, no work bays, no lane filter, no zoom, no
tooltips, a different axis. Day is now the same board given one day's
worth of columns, so everything you set up survives the switch and the
lanes simply get the width back. The old day timeline is gone.

Hovering a job showed a browser tooltip with little more than the title
that was already on the block. There is a real one now: vehicle,
customer, the times, how long it runs, who has it, which bay and its
status. Jobs in the Overview layout had no tooltip at all and now share
the same one.

Customer travels with a board job to make that possible, resolved through
the vehicle or set directly for counter sales.

* Give the presenter the same board for a day

Selecting Day on the presenter swapped in one of three more one-off
components, the same mistake the planner made: a different axis, no work
bays, no grouping, no tooltips. Day is now the same board given one day's
worth of columns, so Timeline and Overview mean the same thing whichever
period is selected, and the technician/bay choice applies to both.

Status stays, because a kanban of one day by status is genuinely a third
thing rather than a layout of the board, and it is offered only for a day
since there is no weekly equivalent. The other two day views are gone.

Old ?view= URLs on pinned displays still resolve, now onto ?period= and
?layout=.

* Stop the toolbars rearranging themselves

Switching period reshuffled both headers: the planner hid its whole
right-hand cluster on Day, and the presenter added and removed buttons
between Day and Week, so controls slid under the pointer mid-click.

Everything that still applies now stays put. Layout, Columns and Lanes
apply to a day as much as to a week, since both are the same board, so
they no longer disappear. Weekends is greyed with a note rather than
removed, and so is the presenter's technician/bay pair while the status
board is up. Lanes greys out at one lane instead of vanishing, which it
did every time the columns collapsed to one per day.

Status is always offered and now switches to a day itself rather than
appearing and disappearing with the period: a control that acts beats one
that moves.

* Stop the presenter crying disconnected before it has connected

The header opened with "Live updates disconnected" in red, then replaced
it with "Live" once the socket landed a moment later. Two things wrong
with that: a socket that has not finished opening has not failed, and the
long red label is four times the width of the short green one, so every
control in the header slid sideways as it swapped.

The store tracks connecting, open and closed rather than a boolean, so
the first paint is a neutral state instead of an alarm. The indicator is
now the icon alone at a fixed size, with the words in its tooltip, which
means it cannot move anything however its state changes.

* Reserve the space the clock is about to need

The clock renders nothing until its first tick, because the server cannot
know what time it is where the screen is and guessing is a hydration
error. That left a one-space-wide element that jumped to full width a
moment later and shoved the header along with it.

It now reserves the width it will need, in tabular figures so the digits
do not jitter as they change, sized for whichever of the two formats is
in use. It also follows the workshop's 12 or 24 hour setting, which it
was ignoring in favour of the browser's locale.

The date label beside it had a smaller version of the same problem, since
"Monday, August 24, 2026" is wider than "Aug 24 – Aug 30, 2026" and
switching period moved everything around it.

* Keep the first and last hour labels inside the column

Every label straddled its own line, which is right in the middle of the
axis and wrong at the ends: the first one sat half above the top of the
body, where the sticky header painted over it, so 7 AM was a row of
half-height pixels behind the day names. The last had the same problem
against the bottom edge.

Those two tuck inside their line now. The rest still straddle, which is
what makes a label read as belonging to the rule it names.

* Mute tooltip text against the tooltip, not the page

The tooltips are deliberately inverted: `bg-foreground text-background`,
so they are dark on a light theme and light on a dark one. The secondary
rows inside the two new ones used `text-muted-foreground`, which is a mid
grey chosen to sit on the page background. On the near-black light-theme
tooltip that is thin, and on the near-white dark-theme one it is close to
invisible.

They fade the tooltip's own text colour now, so the contrast holds
whichever way the tooltip inverts.

* Drop imports left behind by the deleted day views

* Give the phone a drawer instead of a sidebar

A fixed 256px sidebar on a 390px phone leaves the board 130px of width,
which is not a board. Below md the sidebar is hidden and the same list
opens as a bottom drawer from a full-width button under the board, with
the waiting count on it so it is worth reaching for.

The drawer is not a drop target, deliberately: while it is open it covers
the board, so there is nowhere to drop onto. It is for seeing and
searching what is waiting. Dragging stays on a screen wide enough to show
both at once.

The hint line under the board goes too. Every instruction in it is about a
mouse, so on a phone it was a wasted line above the fold.
Bernt Christian Egeland 1 сар өмнө
parent
commit
e844c7c7ec
93 өөрчлөгдсөн 7218 нэмэгдсэн , 2082 устгасан
  1. 8 2
      messages/de/audit.json
  2. 4 1
      messages/de/service.json
  3. 79 4
      messages/de/workBoard.json
  4. 8 2
      messages/en/audit.json
  5. 4 1
      messages/en/service.json
  6. 79 4
      messages/en/workBoard.json
  7. 8 2
      messages/es/audit.json
  8. 4 1
      messages/es/service.json
  9. 79 4
      messages/es/workBoard.json
  10. 8 2
      messages/fr/audit.json
  11. 4 1
      messages/fr/service.json
  12. 79 4
      messages/fr/workBoard.json
  13. 8 2
      messages/it/audit.json
  14. 4 1
      messages/it/service.json
  15. 79 4
      messages/it/workBoard.json
  16. 8 2
      messages/lt/audit.json
  17. 4 1
      messages/lt/service.json
  18. 79 4
      messages/lt/workBoard.json
  19. 8 2
      messages/nb/audit.json
  20. 4 1
      messages/nb/service.json
  21. 79 4
      messages/nb/workBoard.json
  22. 8 2
      messages/nl/audit.json
  23. 4 1
      messages/nl/service.json
  24. 79 4
      messages/nl/workBoard.json
  25. 8 2
      messages/pl/audit.json
  26. 4 1
      messages/pl/service.json
  27. 79 4
      messages/pl/workBoard.json
  28. 8 2
      messages/pt-BR/audit.json
  29. 4 1
      messages/pt-BR/service.json
  30. 79 4
      messages/pt-BR/workBoard.json
  31. 8 2
      messages/ru/audit.json
  32. 4 1
      messages/ru/service.json
  33. 79 4
      messages/ru/workBoard.json
  34. 8 2
      messages/tr/audit.json
  35. 4 1
      messages/tr/service.json
  36. 79 4
      messages/tr/workBoard.json
  37. 38 0
      prisma/migrations/20260825120000_work_bays/migration.sql
  38. 64 24
      prisma/schema.prisma
  39. 2 0
      src/__tests__/features/workboard/datetime.test.ts
  40. 416 0
      src/__tests__/features/workboard/week-layout.test.ts
  41. 50 0
      src/__tests__/features/workboard/week-number.test.ts
  42. 6 1
      src/app/(authenticated)/layout.tsx
  43. 8 1
      src/app/(authenticated)/vehicles/[id]/service/new/page.tsx
  44. 45 31
      src/app/(authenticated)/work-board/page.tsx
  45. 36 32
      src/app/(presenter)/work-board/presenter/page.tsx
  46. 5 0
      src/app/api/protected/backup/export/route.ts
  47. 21 0
      src/app/api/protected/backup/import/route.ts
  48. 7 2
      src/features/vehicles/Actions/createDraftServiceRecord.ts
  49. 1 0
      src/features/vehicles/Components/service-detail/types.ts
  50. 60 1
      src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx
  51. 5 1
      src/features/vehicles/Components/service-page/DetailsRightColumn.tsx
  52. 2 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  53. 7 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  54. 6 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  55. 3 0
      src/features/vehicles/Lib/createDraftRecord.ts
  56. 138 237
      src/features/workboard/Actions/boardActions/assignments.ts
  57. 5 5
      src/features/workboard/Actions/boardActions/index.ts
  58. 121 0
      src/features/workboard/Actions/boardActions/mappers.ts
  59. 138 31
      src/features/workboard/Actions/boardActions/scheduling.ts
  60. 35 20
      src/features/workboard/Actions/boardActions/types.ts
  61. 162 0
      src/features/workboard/Actions/workBayActions.ts
  62. 0 99
      src/features/workboard/Components/BoardDayCell.tsx
  63. 0 109
      src/features/workboard/Components/DayTimeline/TimelineCard.tsx
  64. 0 166
      src/features/workboard/Components/DayTimeline/TimelineColumn.tsx
  65. 0 236
      src/features/workboard/Components/DayTimeline/index.tsx
  66. 0 39
      src/features/workboard/Components/DayTimeline/utils.ts
  67. 131 0
      src/features/workboard/Components/JobTooltip.tsx
  68. 91 0
      src/features/workboard/Components/LaneHeaderTooltip.tsx
  69. 0 98
      src/features/workboard/Components/PresenterDayView.tsx
  70. 0 174
      src/features/workboard/Components/PresenterTimeline.tsx
  71. 0 88
      src/features/workboard/Components/TechnicianRow.tsx
  72. 92 34
      src/features/workboard/Components/UnassignedJobsPanel.tsx
  73. 232 0
      src/features/workboard/Components/WeekCardGrid.tsx
  74. 81 0
      src/features/workboard/Components/WeekTimeline/UnscheduledStrip.tsx
  75. 180 0
      src/features/workboard/Components/WeekTimeline/WeekJobBlock.tsx
  76. 225 0
      src/features/workboard/Components/WeekTimeline/WeekLaneColumn.tsx
  77. 76 0
      src/features/workboard/Components/WeekTimeline/geometry.ts
  78. 578 0
      src/features/workboard/Components/WeekTimeline/index.tsx
  79. 121 0
      src/features/workboard/Components/WeekTimeline/useDragToPan.ts
  80. 391 0
      src/features/workboard/Components/WeekTimeline/useWeekDrag.ts
  81. 194 0
      src/features/workboard/Components/WorkBayDialog.tsx
  82. 788 208
      src/features/workboard/Components/WorkBoardClient.tsx
  83. 425 98
      src/features/workboard/Components/WorkBoardPresenter.tsx
  84. 266 74
      src/features/workboard/Components/WorkBoardToolbar.tsx
  85. 77 27
      src/features/workboard/Schema/workboardSchema.ts
  86. 129 0
      src/features/workboard/hooks/useBoardPreferences.ts
  87. 96 86
      src/features/workboard/hooks/useWorkBoardWebSocket.ts
  88. 118 76
      src/features/workboard/store/workboardStore.ts
  89. 33 0
      src/features/workboard/utils/clock.ts
  90. 28 0
      src/features/workboard/utils/job-colors.ts
  91. 144 0
      src/features/workboard/utils/lanes.ts
  92. 242 0
      src/features/workboard/utils/layout.ts
  93. 7 0
      src/lib/backup/manifest.ts

+ 8 - 2
messages/de/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Reifensatz ausgemustert",
     "tire_set_dispose": "Reifensatz ausgemustert",
     "tire_set_attach": "Dateien zu einem Reifensatz hinzugefügt",
     "tire_set_attach": "Dateien zu einem Reifensatz hinzugefügt",
     "tire_set_detach": "Datei von einem Reifensatz entfernt",
     "tire_set_detach": "Datei von einem Reifensatz entfernt",
-    "whatsapp_send": "WhatsApp-Nachricht gesendet"
+    "whatsapp_send": "WhatsApp-Nachricht gesendet",
+    "work_bay_create": "Arbeitsplatz erstellt",
+    "work_bay_update": "Arbeitsplatz aktualisiert",
+    "work_bay_delete": "Arbeitsplatz gelöscht"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -256,6 +259,9 @@
     "vehicle_update": "Fahrzeug {name} aktualisiert, geändert: {fields}",
     "vehicle_update": "Fahrzeug {name} aktualisiert, geändert: {fields}",
     "vehicle_updateNoChanges": "Fahrzeug {name} aktualisiert, nichts geändert",
     "vehicle_updateNoChanges": "Fahrzeug {name} aktualisiert, nichts geändert",
     "webhook_create": "Webhook {name} erstellt",
     "webhook_create": "Webhook {name} erstellt",
-    "whatsapp_send": "WhatsApp-Nachricht an {name} gesendet"
+    "whatsapp_send": "WhatsApp-Nachricht an {name} gesendet",
+    "work_bay_create": "Arbeitsplatz „{name}“ angelegt",
+    "work_bay_update": "Arbeitsplatz „{name}“ aktualisiert",
+    "work_bay_delete": "Arbeitsplatz {id} gelöscht"
   }
   }
 }
 }

+ 4 - 1
messages/de/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Mehr erfahren",
     "readMore": "Mehr erfahren",
-    "setDefaults": "Standardwerte"
+    "setDefaults": "Standardwerte",
+    "workBay": "Arbeitsplatz",
+    "selectWorkBay": "Arbeitsplatz wählen …",
+    "noWorkBay": "Kein Arbeitsplatz"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Kundenbenachrichtigungen",
     "title": "Kundenbenachrichtigungen",

+ 79 - 4
messages/de/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Spalten",
+    "byTechnician": "Eine Spalte je Mechaniker",
+    "byBay": "Eine Spalte je Arbeitsplatz",
+    "byNothing": "Eine Spalte je Tag",
+    "weekends": "Wochenende",
+    "add": "Hinzufügen",
+    "addBay": "Arbeitsplatz hinzufügen",
+    "weekNumber": "KW {number}",
+    "lanes": "Spalten",
+    "showAllLanes": "Alle anzeigen",
+    "onlyBusyLanes": "Nur mit Aufträgen",
+    "layout": "Ansicht",
+    "layoutTimeline": "Zeitachse",
+    "layoutCards": "Übersicht",
+    "rows": "Zeilen",
+    "rowPerTechnician": "Eine Zeile je Mechaniker",
+    "rowPerBay": "Eine Zeile je Arbeitsplatz",
+    "rowNothing": "Eine einzige Zeile",
+    "technicians": "Mechaniker",
+    "workBays": "Arbeitsplätze",
+    "weekendsDayHint": "Gilt nur in der Wochenansicht"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Zuweisung konnte nicht verschoben werden",
     "failedMove": "Zuweisung konnte nicht verschoben werden",
     "failedAssign": "Auftrag konnte nicht zugewiesen werden",
     "failedAssign": "Auftrag konnte nicht zugewiesen werden",
     "failedRemove": "Zuweisung konnte nicht entfernt werden",
     "failedRemove": "Zuweisung konnte nicht entfernt werden",
-    "createNewWorkorder": "Neuen Arbeitsauftrag erstellen"
+    "createNewWorkorder": "Neuen Arbeitsauftrag erstellen",
+    "noBays": "Noch keine Arbeitsplätze. Legen Sie einen an, um die Woche nach Arbeitsplatz zu planen.",
+    "addFirstBay": "Arbeitsplatz hinzufügen",
+    "lanes": {
+      "noBay": "Ohne Arbeitsplatz",
+      "noTechnician": "Ohne Mechaniker",
+      "everyone": "Alle Arbeiten",
+      "thisWeek": "Diese Woche"
+    },
+    "dropNeedsLane": "Gruppieren Sie das Board nach Mechaniker oder Arbeitsplatz, bevor Sie einen Auftrag darauf ziehen."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Nicht zugewiesene Aufträge ({count})",
     "title": "Nicht zugewiesene Aufträge ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "status": "Status",
     "week": "Woche",
     "week": "Woche",
     "today": "Heute",
     "today": "Heute",
-    "timeline": "Zeitleiste",
     "live": "Live",
     "live": "Live",
     "disconnected": "Live-Updates unterbrochen",
     "disconnected": "Live-Updates unterbrochen",
     "noTechnicians": "Keine Techniker konfiguriert",
     "noTechnicians": "Keine Techniker konfiguriert",
@@ -76,6 +105,52 @@
       "completed": "Abgeschlossen"
       "completed": "Abgeschlossen"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Die Statustafel gruppiert nach Status",
+    "connecting": "Verbinden …"
+  },
+  "week": {
+    "createAt": "Neuer Auftrag um {time}",
+    "unscheduled": "Nicht eingeplant ({count})",
+    "hint": "Auftrag ziehen zum Verschieben. Oberen oder unteren Rand ziehen, um die Dauer zu ändern.",
+    "hiddenDays": "{count} am Wochenende eingeplant. Wochenende anzeigen.",
+    "panLeft": "Früher in der Woche",
+    "panRight": "Später in der Woche",
+    "hintPan": "Hintergrund ziehen, um durch die Woche zu blättern.",
+    "wholeWeek": "Ganze Woche anzeigen",
+    "hintZoom": "Strg halten und scrollen zum Zoomen."
+  },
+  "bay": {
+    "addTitle": "Arbeitsplatz hinzufügen",
+    "editTitle": "Arbeitsplatz bearbeiten",
+    "description": "Ein Ort, an dem gearbeitet wird: Bühne, Rampe oder Waschplatz.",
+    "name": "Name",
+    "namePlaceholder": "z. B. Bühne 1",
+    "color": "Farbe",
+    "dailyCapacity": "Buchbare Stunden pro Tag",
+    "hoursPerDay": "Stunden pro Tag",
+    "capacityHint": "Zeigt an, wie voll jeder Tag ist.",
+    "deleteTitle": "Arbeitsplatz löschen",
+    "deleteDescription": "„{name}“ entfernen? Aufträge darin bleiben eingeplant und wechseln in die Spalte ohne Arbeitsplatz.",
+    "addBay": "Arbeitsplatz hinzufügen",
+    "saveFailed": "Arbeitsplatz konnte nicht gespeichert werden"
+  },
+  "lane": {
+    "booked": "Verplant",
+    "capacity": "Kapazität",
+    "free": "Frei",
+    "over": "Überbucht um",
+    "jobs": "Aufträge",
+    "utilisation": "{pct} % der Kapazität",
+    "placeholder": "Arbeiten, die hier noch niemandem zugeordnet sind."
+  },
+  "job": {
+    "time": "Zeit",
+    "duration": "Dauer",
+    "technician": "Mechaniker",
+    "workBay": "Arbeitsplatz",
+    "status": "Status",
+    "noTime": "Noch keine Zeit",
+    "none": "Noch niemand"
   }
   }
 }
 }

+ 8 - 2
messages/en/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Wrote off a tire set",
     "tire_set_dispose": "Wrote off a tire set",
     "tire_set_attach": "Added files to a tire set",
     "tire_set_attach": "Added files to a tire set",
     "tire_set_detach": "Removed a file from a tire set",
     "tire_set_detach": "Removed a file from a tire set",
-    "whatsapp_send": "Sent WhatsApp Message"
+    "whatsapp_send": "Sent WhatsApp Message",
+    "work_bay_create": "Created Work Bay",
+    "work_bay_update": "Updated Work Bay",
+    "work_bay_delete": "Deleted Work Bay"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
     "customField_create": "Created custom field \"{name}\"",
@@ -256,6 +259,9 @@
     "vehicle_update": "Updated vehicle {name}, changed: {fields}",
     "vehicle_update": "Updated vehicle {name}, changed: {fields}",
     "vehicle_updateNoChanges": "Updated vehicle {name}, nothing changed",
     "vehicle_updateNoChanges": "Updated vehicle {name}, nothing changed",
     "webhook_create": "Created webhook {name}",
     "webhook_create": "Created webhook {name}",
-    "whatsapp_send": "Sent a WhatsApp message to {name}"
+    "whatsapp_send": "Sent a WhatsApp message to {name}",
+    "work_bay_create": "Created work bay \"{name}\"",
+    "work_bay_update": "Updated work bay \"{name}\"",
+    "work_bay_delete": "Deleted work bay {id}"
   }
   }
 }
 }

+ 4 - 1
messages/en/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Read More",
     "readMore": "Read More",
-    "setDefaults": "Set Defaults"
+    "setDefaults": "Set Defaults",
+    "workBay": "Work bay",
+    "selectWorkBay": "Select work bay...",
+    "noWorkBay": "No work bay"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Customer Notifications",
     "title": "Customer Notifications",

+ 79 - 4
messages/en/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Columns",
+    "byTechnician": "A column per technician",
+    "byBay": "A column per work bay",
+    "byNothing": "A column per day",
+    "weekends": "Weekends",
+    "add": "Add",
+    "addBay": "Add work bay",
+    "weekNumber": "Week {number}",
+    "lanes": "Lanes",
+    "showAllLanes": "Show all",
+    "onlyBusyLanes": "Only those with work",
+    "layout": "Layout",
+    "layoutTimeline": "Timeline",
+    "layoutCards": "Overview",
+    "rows": "Rows",
+    "rowPerTechnician": "A row per technician",
+    "rowPerBay": "A row per work bay",
+    "rowNothing": "A single row",
+    "technicians": "Technicians",
+    "workBays": "Work bays",
+    "weekendsDayHint": "Only applies when a week is shown"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Failed to move assignment",
     "failedMove": "Failed to move assignment",
     "failedAssign": "Failed to assign job",
     "failedAssign": "Failed to assign job",
     "failedRemove": "Failed to remove assignment",
     "failedRemove": "Failed to remove assignment",
-    "createNewWorkorder": "Create new Workorder"
+    "createNewWorkorder": "Create new Workorder",
+    "noBays": "No work bays yet. Add one to plan the week bay by bay.",
+    "addFirstBay": "Add work bay",
+    "lanes": {
+      "noBay": "No bay",
+      "noTechnician": "No technician",
+      "everyone": "All work",
+      "thisWeek": "This week"
+    },
+    "dropNeedsLane": "Group the board by technician or work bay before dropping a job onto it."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Unassigned Jobs ({count})",
     "title": "Unassigned Jobs ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "status": "Status",
     "week": "Week",
     "week": "Week",
     "today": "Today",
     "today": "Today",
-    "timeline": "Timeline",
     "live": "Live",
     "live": "Live",
     "disconnected": "Live updates disconnected",
     "disconnected": "Live updates disconnected",
     "noTechnicians": "No technicians configured",
     "noTechnicians": "No technicians configured",
@@ -76,6 +105,52 @@
       "completed": "Completed"
       "completed": "Completed"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "The status board groups by status",
+    "connecting": "Connecting…"
+  },
+  "week": {
+    "createAt": "New work order at {time}",
+    "unscheduled": "Not scheduled ({count})",
+    "hint": "Drag a job to move it. Drag its top or bottom edge to change how long it takes.",
+    "hiddenDays": "{count} booked on the weekend. Show weekends.",
+    "panLeft": "Earlier in the week",
+    "panRight": "Later in the week",
+    "hintPan": "Drag the background to move across the week.",
+    "wholeWeek": "See the whole week",
+    "hintZoom": "Hold Ctrl and scroll to zoom."
+  },
+  "bay": {
+    "addTitle": "Add work bay",
+    "editTitle": "Edit work bay",
+    "description": "A place where work happens: a lift, a ramp, a wash bay.",
+    "name": "Name",
+    "namePlaceholder": "e.g. Lift 1",
+    "color": "Color",
+    "dailyCapacity": "Bookable hours per day",
+    "hoursPerDay": "hours per day",
+    "capacityHint": "Used to show how full each day is.",
+    "deleteTitle": "Delete work bay",
+    "deleteDescription": "Remove \"{name}\"? Jobs booked into it stay scheduled and move to the no-bay column.",
+    "addBay": "Add work bay",
+    "saveFailed": "Could not save the work bay"
+  },
+  "lane": {
+    "booked": "Booked",
+    "capacity": "Capacity",
+    "free": "Free",
+    "over": "Over by",
+    "jobs": "Jobs",
+    "utilisation": "{pct}% of capacity",
+    "placeholder": "Work with nothing assigned here yet."
+  },
+  "job": {
+    "time": "Time",
+    "duration": "Duration",
+    "technician": "Technician",
+    "workBay": "Work bay",
+    "status": "Status",
+    "noTime": "No time set yet",
+    "none": "Nobody yet"
   }
   }
 }
 }

+ 8 - 2
messages/es/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Juego de neumáticos dado de baja",
     "tire_set_dispose": "Juego de neumáticos dado de baja",
     "tire_set_attach": "Archivos añadidos a un juego de neumáticos",
     "tire_set_attach": "Archivos añadidos a un juego de neumáticos",
     "tire_set_detach": "Archivo quitado de un juego de neumáticos",
     "tire_set_detach": "Archivo quitado de un juego de neumáticos",
-    "whatsapp_send": "Mensaje de WhatsApp enviado"
+    "whatsapp_send": "Mensaje de WhatsApp enviado",
+    "work_bay_create": "Puesto de trabajo creado",
+    "work_bay_update": "Puesto de trabajo actualizado",
+    "work_bay_delete": "Puesto de trabajo eliminado"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
     "customField_create": "Campo personalizado «{name}» creado",
@@ -256,6 +259,9 @@
     "vehicle_update": "Vehículo {name} actualizado, cambios: {fields}",
     "vehicle_update": "Vehículo {name} actualizado, cambios: {fields}",
     "vehicle_updateNoChanges": "Vehículo {name} actualizado, sin cambios",
     "vehicle_updateNoChanges": "Vehículo {name} actualizado, sin cambios",
     "webhook_create": "Webhook {name} creado",
     "webhook_create": "Webhook {name} creado",
-    "whatsapp_send": "Envió un mensaje de WhatsApp a {name}"
+    "whatsapp_send": "Envió un mensaje de WhatsApp a {name}",
+    "work_bay_create": "Puesto de trabajo «{name}» creado",
+    "work_bay_update": "Puesto de trabajo «{name}» actualizado",
+    "work_bay_delete": "Puesto de trabajo {id} eliminado"
   }
   }
 }
 }

+ 4 - 1
messages/es/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Leer más",
     "readMore": "Leer más",
-    "setDefaults": "Configurar valores"
+    "setDefaults": "Configurar valores",
+    "workBay": "Puesto de trabajo",
+    "selectWorkBay": "Seleccionar puesto…",
+    "noWorkBay": "Sin puesto"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Notificaciones al cliente",
     "title": "Notificaciones al cliente",

+ 79 - 4
messages/es/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Columnas",
+    "byTechnician": "Una columna por técnico",
+    "byBay": "Una columna por puesto",
+    "byNothing": "Una columna por día",
+    "weekends": "Fin de semana",
+    "add": "Añadir",
+    "addBay": "Añadir puesto",
+    "weekNumber": "Sem. {number}",
+    "lanes": "Columnas",
+    "showAllLanes": "Mostrar todas",
+    "onlyBusyLanes": "Solo con trabajo",
+    "layout": "Vista",
+    "layoutTimeline": "Cronología",
+    "layoutCards": "Resumen",
+    "rows": "Filas",
+    "rowPerTechnician": "Una fila por técnico",
+    "rowPerBay": "Una fila por puesto",
+    "rowNothing": "Una sola fila",
+    "technicians": "Técnicos",
+    "workBays": "Puestos de trabajo",
+    "weekendsDayHint": "Solo se aplica al ver una semana"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Error al mover asignación",
     "failedMove": "Error al mover asignación",
     "failedAssign": "Error al asignar trabajo",
     "failedAssign": "Error al asignar trabajo",
     "failedRemove": "Error al eliminar asignación",
     "failedRemove": "Error al eliminar asignación",
-    "createNewWorkorder": "Crear nueva orden de trabajo"
+    "createNewWorkorder": "Crear nueva orden de trabajo",
+    "noBays": "Aún no hay puestos de trabajo. Añade uno para planificar la semana por puesto.",
+    "addFirstBay": "Añadir puesto",
+    "lanes": {
+      "noBay": "Sin puesto",
+      "noTechnician": "Sin técnico",
+      "everyone": "Todo el trabajo",
+      "thisWeek": "Esta semana"
+    },
+    "dropNeedsLane": "Agrupa el tablero por técnico o puesto de trabajo antes de soltar un trabajo en él."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Trabajos Sin Asignar ({count})",
     "title": "Trabajos Sin Asignar ({count})",
@@ -62,7 +92,6 @@
     "status": "Estado",
     "status": "Estado",
     "week": "Semana",
     "week": "Semana",
     "today": "Hoy",
     "today": "Hoy",
-    "timeline": "Línea de tiempo",
     "live": "En Vivo",
     "live": "En Vivo",
     "disconnected": "Actualizaciones en vivo desconectadas",
     "disconnected": "Actualizaciones en vivo desconectadas",
     "noTechnicians": "Sin técnicos configurados",
     "noTechnicians": "Sin técnicos configurados",
@@ -76,6 +105,52 @@
       "completed": "Completado"
       "completed": "Completado"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "El tablero de estado agrupa por estado",
+    "connecting": "Conectando…"
+  },
+  "week": {
+    "createAt": "Nueva orden de trabajo a las {time}",
+    "unscheduled": "Sin programar ({count})",
+    "hint": "Arrastra un trabajo para moverlo. Arrastra su borde superior o inferior para cambiar la duración.",
+    "hiddenDays": "{count} programado(s) el fin de semana. Mostrar fin de semana.",
+    "panLeft": "Antes en la semana",
+    "panRight": "Después en la semana",
+    "hintPan": "Arrastra el fondo para recorrer la semana.",
+    "wholeWeek": "Ver la semana completa",
+    "hintZoom": "Mantén Ctrl y desplázate para hacer zoom."
+  },
+  "bay": {
+    "addTitle": "Añadir puesto de trabajo",
+    "editTitle": "Editar puesto de trabajo",
+    "description": "Un lugar donde se trabaja: un elevador, una rampa o una zona de lavado.",
+    "name": "Nombre",
+    "namePlaceholder": "p. ej. Elevador 1",
+    "color": "Color",
+    "dailyCapacity": "Horas reservables por día",
+    "hoursPerDay": "horas por día",
+    "capacityHint": "Sirve para ver cuán lleno está cada día.",
+    "deleteTitle": "Eliminar puesto de trabajo",
+    "deleteDescription": "¿Eliminar «{name}»? Los trabajos reservados en él siguen programados y pasan a la columna sin puesto.",
+    "addBay": "Añadir puesto",
+    "saveFailed": "No se pudo guardar el puesto de trabajo"
+  },
+  "lane": {
+    "booked": "Programado",
+    "capacity": "Capacidad",
+    "free": "Libre",
+    "over": "Excedido en",
+    "jobs": "Trabajos",
+    "utilisation": "{pct} % de la capacidad",
+    "placeholder": "Trabajo aún sin asignar aquí."
+  },
+  "job": {
+    "time": "Hora",
+    "duration": "Duración",
+    "technician": "Técnico",
+    "workBay": "Puesto de trabajo",
+    "status": "Estado",
+    "noTime": "Sin hora todavía",
+    "none": "Nadie todavía"
   }
   }
 }
 }

+ 8 - 2
messages/fr/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Jeu de pneus mis au rebut",
     "tire_set_dispose": "Jeu de pneus mis au rebut",
     "tire_set_attach": "Fichiers ajoutés à un jeu de pneus",
     "tire_set_attach": "Fichiers ajoutés à un jeu de pneus",
     "tire_set_detach": "Fichier retiré d’un jeu de pneus",
     "tire_set_detach": "Fichier retiré d’un jeu de pneus",
-    "whatsapp_send": "Message WhatsApp envoyé"
+    "whatsapp_send": "Message WhatsApp envoyé",
+    "work_bay_create": "Poste de travail créé",
+    "work_bay_update": "Poste de travail mis à jour",
+    "work_bay_delete": "Poste de travail supprimé"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -256,6 +259,9 @@
     "vehicle_update": "Véhicule {name} mis à jour, modifié : {fields}",
     "vehicle_update": "Véhicule {name} mis à jour, modifié : {fields}",
     "vehicle_updateNoChanges": "Véhicule {name} mis à jour, aucun changement",
     "vehicle_updateNoChanges": "Véhicule {name} mis à jour, aucun changement",
     "webhook_create": "Webhook {name} créé",
     "webhook_create": "Webhook {name} créé",
-    "whatsapp_send": "Message WhatsApp envoyé à {name}"
+    "whatsapp_send": "Message WhatsApp envoyé à {name}",
+    "work_bay_create": "Poste de travail « {name} » créé",
+    "work_bay_update": "Poste de travail « {name} » mis à jour",
+    "work_bay_delete": "Poste de travail {id} supprimé"
   }
   }
 }
 }

+ 4 - 1
messages/fr/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "En savoir plus",
     "readMore": "En savoir plus",
-    "setDefaults": "Paramètres par défaut"
+    "setDefaults": "Paramètres par défaut",
+    "workBay": "Poste de travail",
+    "selectWorkBay": "Choisir un poste…",
+    "noWorkBay": "Aucun poste"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Notifications client",
     "title": "Notifications client",

+ 79 - 4
messages/fr/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Colonnes",
+    "byTechnician": "Une colonne par technicien",
+    "byBay": "Une colonne par poste",
+    "byNothing": "Une colonne par jour",
+    "weekends": "Week-end",
+    "add": "Ajouter",
+    "addBay": "Ajouter un poste",
+    "weekNumber": "Sem. {number}",
+    "lanes": "Colonnes",
+    "showAllLanes": "Tout afficher",
+    "onlyBusyLanes": "Seulement avec du travail",
+    "layout": "Affichage",
+    "layoutTimeline": "Chronologie",
+    "layoutCards": "Vue d'ensemble",
+    "rows": "Lignes",
+    "rowPerTechnician": "Une ligne par technicien",
+    "rowPerBay": "Une ligne par poste",
+    "rowNothing": "Une seule ligne",
+    "technicians": "Techniciens",
+    "workBays": "Postes de travail",
+    "weekendsDayHint": "Ne s'applique qu'à une semaine"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Impossible de déplacer l'assignation",
     "failedMove": "Impossible de déplacer l'assignation",
     "failedAssign": "Impossible d'assigner le travail",
     "failedAssign": "Impossible d'assigner le travail",
     "failedRemove": "Impossible de supprimer l'assignation",
     "failedRemove": "Impossible de supprimer l'assignation",
-    "createNewWorkorder": "Créer un nouvel ordre de travail"
+    "createNewWorkorder": "Créer un nouvel ordre de travail",
+    "noBays": "Aucun poste de travail. Ajoutez-en un pour planifier la semaine par poste.",
+    "addFirstBay": "Ajouter un poste",
+    "lanes": {
+      "noBay": "Sans poste",
+      "noTechnician": "Sans technicien",
+      "everyone": "Tout le travail",
+      "thisWeek": "Cette semaine"
+    },
+    "dropNeedsLane": "Groupez le tableau par technicien ou par poste avant d'y déposer une tâche."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Travaux Non Assignés ({count})",
     "title": "Travaux Non Assignés ({count})",
@@ -62,7 +92,6 @@
     "status": "Statut",
     "status": "Statut",
     "week": "Semaine",
     "week": "Semaine",
     "today": "Aujourd'hui",
     "today": "Aujourd'hui",
-    "timeline": "Chronologie",
     "live": "En Direct",
     "live": "En Direct",
     "disconnected": "Mises à jour en direct déconnectées",
     "disconnected": "Mises à jour en direct déconnectées",
     "noTechnicians": "Aucun technicien configuré",
     "noTechnicians": "Aucun technicien configuré",
@@ -76,6 +105,52 @@
       "completed": "Complété"
       "completed": "Complété"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Le tableau de statut regroupe par statut",
+    "connecting": "Connexion…"
+  },
+  "week": {
+    "createAt": "Nouvel ordre de travail à {time}",
+    "unscheduled": "Non planifié ({count})",
+    "hint": "Faites glisser une tâche pour la déplacer. Faites glisser son bord haut ou bas pour changer sa durée.",
+    "hiddenDays": "{count} planifié(s) le week-end. Afficher le week-end.",
+    "panLeft": "Plus tôt dans la semaine",
+    "panRight": "Plus tard dans la semaine",
+    "hintPan": "Faites glisser le fond pour parcourir la semaine.",
+    "wholeWeek": "Voir toute la semaine",
+    "hintZoom": "Maintenez Ctrl et faites défiler pour zoomer."
+  },
+  "bay": {
+    "addTitle": "Ajouter un poste de travail",
+    "editTitle": "Modifier le poste de travail",
+    "description": "Un endroit où le travail se fait : un pont, une rampe, une aire de lavage.",
+    "name": "Nom",
+    "namePlaceholder": "ex. Pont 1",
+    "color": "Couleur",
+    "dailyCapacity": "Heures réservables par jour",
+    "hoursPerDay": "heures par jour",
+    "capacityHint": "Sert à montrer le taux de remplissage de chaque journée.",
+    "deleteTitle": "Supprimer le poste de travail",
+    "deleteDescription": "Supprimer « {name} » ? Les tâches qui y sont réservées restent planifiées et passent dans la colonne sans poste.",
+    "addBay": "Ajouter un poste",
+    "saveFailed": "Impossible d'enregistrer le poste de travail"
+  },
+  "lane": {
+    "booked": "Planifié",
+    "capacity": "Capacité",
+    "free": "Libre",
+    "over": "Dépassement de",
+    "jobs": "Tâches",
+    "utilisation": "{pct} % de la capacité",
+    "placeholder": "Travail encore non attribué ici."
+  },
+  "job": {
+    "time": "Heure",
+    "duration": "Durée",
+    "technician": "Technicien",
+    "workBay": "Poste de travail",
+    "status": "Statut",
+    "noTime": "Pas encore d'horaire",
+    "none": "Personne pour l'instant"
   }
   }
 }
 }

+ 8 - 2
messages/it/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Treno di pneumatici dismesso",
     "tire_set_dispose": "Treno di pneumatici dismesso",
     "tire_set_attach": "File aggiunti a un treno di pneumatici",
     "tire_set_attach": "File aggiunti a un treno di pneumatici",
     "tire_set_detach": "File rimosso da un treno di pneumatici",
     "tire_set_detach": "File rimosso da un treno di pneumatici",
-    "whatsapp_send": "Messaggio WhatsApp inviato"
+    "whatsapp_send": "Messaggio WhatsApp inviato",
+    "work_bay_create": "Postazione creata",
+    "work_bay_update": "Postazione aggiornata",
+    "work_bay_delete": "Postazione eliminata"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -256,6 +259,9 @@
     "vehicle_update": "Veicolo {name} aggiornato, modifiche: {fields}",
     "vehicle_update": "Veicolo {name} aggiornato, modifiche: {fields}",
     "vehicle_updateNoChanges": "Veicolo {name} aggiornato, nessuna modifica",
     "vehicle_updateNoChanges": "Veicolo {name} aggiornato, nessuna modifica",
     "webhook_create": "Webhook {name} creato",
     "webhook_create": "Webhook {name} creato",
-    "whatsapp_send": "Inviato un messaggio WhatsApp a {name}"
+    "whatsapp_send": "Inviato un messaggio WhatsApp a {name}",
+    "work_bay_create": "Postazione «{name}» creata",
+    "work_bay_update": "Postazione «{name}» aggiornata",
+    "work_bay_delete": "Postazione {id} eliminata"
   }
   }
 }
 }

+ 4 - 1
messages/it/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Scopri di più",
     "readMore": "Scopri di più",
-    "setDefaults": "Valori predefiniti"
+    "setDefaults": "Valori predefiniti",
+    "workBay": "Postazione",
+    "selectWorkBay": "Seleziona postazione…",
+    "noWorkBay": "Nessuna postazione"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Notifiche al cliente",
     "title": "Notifiche al cliente",

+ 79 - 4
messages/it/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Colonne",
+    "byTechnician": "Una colonna per tecnico",
+    "byBay": "Una colonna per postazione",
+    "byNothing": "Una colonna per giorno",
+    "weekends": "Fine settimana",
+    "add": "Aggiungi",
+    "addBay": "Aggiungi postazione",
+    "weekNumber": "Sett. {number}",
+    "lanes": "Colonne",
+    "showAllLanes": "Mostra tutte",
+    "onlyBusyLanes": "Solo con lavoro",
+    "layout": "Vista",
+    "layoutTimeline": "Cronologia",
+    "layoutCards": "Panoramica",
+    "rows": "Righe",
+    "rowPerTechnician": "Una riga per tecnico",
+    "rowPerBay": "Una riga per postazione",
+    "rowNothing": "Una sola riga",
+    "technicians": "Tecnici",
+    "workBays": "Postazioni",
+    "weekendsDayHint": "Vale solo quando è mostrata una settimana"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Impossibile spostare l'incarico",
     "failedMove": "Impossibile spostare l'incarico",
     "failedAssign": "Impossibile assegnare il lavoro",
     "failedAssign": "Impossibile assegnare il lavoro",
     "failedRemove": "Impossibile rimuovere l'incarico",
     "failedRemove": "Impossibile rimuovere l'incarico",
-    "createNewWorkorder": "Crea nuovo ordine di lavoro"
+    "createNewWorkorder": "Crea nuovo ordine di lavoro",
+    "noBays": "Nessuna postazione. Aggiungine una per pianificare la settimana per postazione.",
+    "addFirstBay": "Aggiungi postazione",
+    "lanes": {
+      "noBay": "Senza postazione",
+      "noTechnician": "Senza tecnico",
+      "everyone": "Tutto il lavoro",
+      "thisWeek": "Questa settimana"
+    },
+    "dropNeedsLane": "Raggruppa la lavagna per tecnico o postazione prima di trascinarci un lavoro."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Lavori Non Assegnati ({count})",
     "title": "Lavori Non Assegnati ({count})",
@@ -62,7 +92,6 @@
     "status": "Stato",
     "status": "Stato",
     "week": "Settimana",
     "week": "Settimana",
     "today": "Oggi",
     "today": "Oggi",
-    "timeline": "Cronologia",
     "live": "Dal Vivo",
     "live": "Dal Vivo",
     "disconnected": "Aggiornamenti dal vivo disconnessi",
     "disconnected": "Aggiornamenti dal vivo disconnessi",
     "noTechnicians": "Nessun tecnico configurato",
     "noTechnicians": "Nessun tecnico configurato",
@@ -76,6 +105,52 @@
       "completed": "Completato"
       "completed": "Completato"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "La lavagna di stato raggruppa per stato",
+    "connecting": "Connessione…"
+  },
+  "week": {
+    "createAt": "Nuovo ordine di lavoro alle {time}",
+    "unscheduled": "Non pianificato ({count})",
+    "hint": "Trascina un lavoro per spostarlo. Trascina il bordo superiore o inferiore per cambiarne la durata.",
+    "hiddenDays": "{count} pianificato/i nel fine settimana. Mostra il fine settimana.",
+    "panLeft": "Prima nella settimana",
+    "panRight": "Più avanti nella settimana",
+    "hintPan": "Trascina lo sfondo per scorrere la settimana.",
+    "wholeWeek": "Vedi tutta la settimana",
+    "hintZoom": "Tieni premuto Ctrl e scorri per ingrandire."
+  },
+  "bay": {
+    "addTitle": "Aggiungi postazione",
+    "editTitle": "Modifica postazione",
+    "description": "Un luogo dove si lavora: un ponte, una rampa, un'area di lavaggio.",
+    "name": "Nome",
+    "namePlaceholder": "es. Ponte 1",
+    "color": "Colore",
+    "dailyCapacity": "Ore prenotabili al giorno",
+    "hoursPerDay": "ore al giorno",
+    "capacityHint": "Serve a mostrare quanto è pieno ogni giorno.",
+    "deleteTitle": "Elimina postazione",
+    "deleteDescription": "Rimuovere «{name}»? I lavori prenotati restano pianificati e passano alla colonna senza postazione.",
+    "addBay": "Aggiungi postazione",
+    "saveFailed": "Impossibile salvare la postazione"
+  },
+  "lane": {
+    "booked": "Pianificato",
+    "capacity": "Capacità",
+    "free": "Libero",
+    "over": "Eccedenza di",
+    "jobs": "Lavori",
+    "utilisation": "{pct}% della capacità",
+    "placeholder": "Lavoro non ancora assegnato qui."
+  },
+  "job": {
+    "time": "Orario",
+    "duration": "Durata",
+    "technician": "Tecnico",
+    "workBay": "Postazione",
+    "status": "Stato",
+    "noTime": "Nessun orario ancora",
+    "none": "Ancora nessuno"
   }
   }
 }
 }

+ 8 - 2
messages/lt/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Padangų komplektas nurašytas",
     "tire_set_dispose": "Padangų komplektas nurašytas",
     "tire_set_attach": "Prie padangų komplekto pridėta failų",
     "tire_set_attach": "Prie padangų komplekto pridėta failų",
     "tire_set_detach": "Failas pašalintas iš padangų komplekto",
     "tire_set_detach": "Failas pašalintas iš padangų komplekto",
-    "whatsapp_send": "Išsiųsta „WhatsApp“ žinutė"
+    "whatsapp_send": "Išsiųsta „WhatsApp“ žinutė",
+    "work_bay_create": "Sukurta darbo vieta",
+    "work_bay_update": "Atnaujinta darbo vieta",
+    "work_bay_delete": "Ištrinta darbo vieta"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -256,6 +259,9 @@
     "vehicle_update": "Atnaujinta transporto priemonė {name}, pakeista: {fields}",
     "vehicle_update": "Atnaujinta transporto priemonė {name}, pakeista: {fields}",
     "vehicle_updateNoChanges": "Atnaujinta transporto priemonė {name}, niekas nepasikeitė",
     "vehicle_updateNoChanges": "Atnaujinta transporto priemonė {name}, niekas nepasikeitė",
     "webhook_create": "Sukurtas webhook {name}",
     "webhook_create": "Sukurtas webhook {name}",
-    "whatsapp_send": "Klientui {name} išsiųsta „WhatsApp“ žinutė"
+    "whatsapp_send": "Klientui {name} išsiųsta „WhatsApp“ žinutė",
+    "work_bay_create": "Sukurta darbo vieta „{name}“",
+    "work_bay_update": "Atnaujinta darbo vieta „{name}“",
+    "work_bay_delete": "Ištrinta darbo vieta {id}"
   }
   }
 }
 }

+ 4 - 1
messages/lt/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platformos naudotojai",
     "platformUsers": "Platformos naudotojai",
     "customTechnicians": "Pasirinktiniai",
     "customTechnicians": "Pasirinktiniai",
     "readMore": "Skaityti daugiau",
     "readMore": "Skaityti daugiau",
-    "setDefaults": "Nustatyti numatytuosius"
+    "setDefaults": "Nustatyti numatytuosius",
+    "workBay": "Darbo vieta",
+    "selectWorkBay": "Pasirinkite darbo vietą…",
+    "noWorkBay": "Be darbo vietos"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Kliento pranešimai",
     "title": "Kliento pranešimai",

+ 79 - 4
messages/lt/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Stulpeliai",
+    "byTechnician": "Stulpelis kiekvienam meistrui",
+    "byBay": "Stulpelis kiekvienai darbo vietai",
+    "byNothing": "Stulpelis kiekvienai dienai",
+    "weekends": "Savaitgalis",
+    "add": "Pridėti",
+    "addBay": "Pridėti darbo vietą",
+    "weekNumber": "{number} sav.",
+    "lanes": "Stulpeliai",
+    "showAllLanes": "Rodyti visus",
+    "onlyBusyLanes": "Tik su darbais",
+    "layout": "Rodinys",
+    "layoutTimeline": "Laiko juosta",
+    "layoutCards": "Apžvalga",
+    "rows": "Eilutės",
+    "rowPerTechnician": "Eilutė kiekvienam meistrui",
+    "rowPerBay": "Eilutė kiekvienai darbo vietai",
+    "rowNothing": "Viena eilutė",
+    "technicians": "Meistrai",
+    "workBays": "Darbo vietos",
+    "weekendsDayHint": "Taikoma tik rodant savaitę"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Nepavyko perkelti priskyrimo",
     "failedMove": "Nepavyko perkelti priskyrimo",
     "failedAssign": "Nepavyko priskirti darbo",
     "failedAssign": "Nepavyko priskirti darbo",
     "failedRemove": "Nepavyko pašalinti priskyrimo",
     "failedRemove": "Nepavyko pašalinti priskyrimo",
-    "createNewWorkorder": "Sukurti naują darbo užsakymą"
+    "createNewWorkorder": "Sukurti naują darbo užsakymą",
+    "noBays": "Darbo vietų dar nėra. Pridėkite bent vieną, kad planuotumėte savaitę pagal darbo vietas.",
+    "addFirstBay": "Pridėti darbo vietą",
+    "lanes": {
+      "noBay": "Be darbo vietos",
+      "noTechnician": "Be meistro",
+      "everyone": "Visi darbai",
+      "thisWeek": "Ši savaitė"
+    },
+    "dropNeedsLane": "Prieš vilkdami darbą, sugrupuokite lentą pagal meistrą arba darbo vietą."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Nepriskirti darbai ({count})",
     "title": "Nepriskirti darbai ({count})",
@@ -62,7 +92,6 @@
     "status": "Būsena",
     "status": "Būsena",
     "week": "Savaitė",
     "week": "Savaitė",
     "today": "Šiandien",
     "today": "Šiandien",
-    "timeline": "Laiko juosta",
     "live": "Tiesiogiai",
     "live": "Tiesiogiai",
     "disconnected": "Tiesioginiai atnaujinimai atjungti",
     "disconnected": "Tiesioginiai atnaujinimai atjungti",
     "noTechnicians": "Technikai nesukonfigūruoti",
     "noTechnicians": "Technikai nesukonfigūruoti",
@@ -76,6 +105,52 @@
       "completed": "Užbaigta"
       "completed": "Užbaigta"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Būsenų lenta grupuoja pagal būseną",
+    "connecting": "Jungiamasi…"
+  },
+  "week": {
+    "createAt": "Naujas darbo užsakymas {time}",
+    "unscheduled": "Nesuplanuota ({count})",
+    "hint": "Vilkite darbą, kad jį perkeltumėte. Vilkite viršutinį arba apatinį kraštą, kad pakeistumėte trukmę.",
+    "hiddenDays": "{count} suplanuota savaitgalį. Rodyti savaitgalį.",
+    "panLeft": "Anksčiau savaitėje",
+    "panRight": "Vėliau savaitėje",
+    "hintPan": "Vilkite foną, kad judėtumėte per savaitę.",
+    "wholeWeek": "Rodyti visą savaitę",
+    "hintZoom": "Laikykite Ctrl ir slinkite, kad priartintumėte."
+  },
+  "bay": {
+    "addTitle": "Pridėti darbo vietą",
+    "editTitle": "Redaguoti darbo vietą",
+    "description": "Vieta, kurioje atliekami darbai: keltuvas, estakada ar plovykla.",
+    "name": "Pavadinimas",
+    "namePlaceholder": "pvz. Keltuvas 1",
+    "color": "Spalva",
+    "dailyCapacity": "Rezervuojamos valandos per dieną",
+    "hoursPerDay": "val. per dieną",
+    "capacityHint": "Rodo, kiek užpildyta kiekviena diena.",
+    "deleteTitle": "Ištrinti darbo vietą",
+    "deleteDescription": "Pašalinti „{name}“? Jai priskirti darbai lieka suplanuoti ir persikelia į stulpelį be darbo vietos.",
+    "addBay": "Pridėti darbo vietą",
+    "saveFailed": "Nepavyko išsaugoti darbo vietos"
+  },
+  "lane": {
+    "booked": "Suplanuota",
+    "capacity": "Pajėgumas",
+    "free": "Laisva",
+    "over": "Viršyta",
+    "jobs": "Darbai",
+    "utilisation": "{pct} % pajėgumo",
+    "placeholder": "Darbai, kurie dar niekam nepriskirti."
+  },
+  "job": {
+    "time": "Laikas",
+    "duration": "Trukmė",
+    "technician": "Meistras",
+    "workBay": "Darbo vieta",
+    "status": "Būsena",
+    "noTime": "Laikas dar nenustatytas",
+    "none": "Kol kas niekas"
   }
   }
 }
 }

+ 8 - 2
messages/nb/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Kasserte et dekksett",
     "tire_set_dispose": "Kasserte et dekksett",
     "tire_set_attach": "La til filer på et dekksett",
     "tire_set_attach": "La til filer på et dekksett",
     "tire_set_detach": "Fjernet en fil fra et dekksett",
     "tire_set_detach": "Fjernet en fil fra et dekksett",
-    "whatsapp_send": "WhatsApp-melding sendt"
+    "whatsapp_send": "WhatsApp-melding sendt",
+    "work_bay_create": "Opprettet arbeidsplass",
+    "work_bay_update": "Oppdaterte arbeidsplass",
+    "work_bay_delete": "Slettet arbeidsplass"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -256,6 +259,9 @@
     "vehicle_update": "Oppdaterte kjøretøy {name}, endret: {fields}",
     "vehicle_update": "Oppdaterte kjøretøy {name}, endret: {fields}",
     "vehicle_updateNoChanges": "Oppdaterte kjøretøy {name}, ingenting endret",
     "vehicle_updateNoChanges": "Oppdaterte kjøretøy {name}, ingenting endret",
     "webhook_create": "Opprettet webhook {name}",
     "webhook_create": "Opprettet webhook {name}",
-    "whatsapp_send": "Sendte en WhatsApp-melding til {name}"
+    "whatsapp_send": "Sendte en WhatsApp-melding til {name}",
+    "work_bay_create": "Opprettet arbeidsplass «{name}»",
+    "work_bay_update": "Oppdaterte arbeidsplass «{name}»",
+    "work_bay_delete": "Slettet arbeidsplass {id}"
   }
   }
 }
 }

+ 4 - 1
messages/nb/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Les mer",
     "readMore": "Les mer",
-    "setDefaults": "Standardinnstillinger"
+    "setDefaults": "Standardinnstillinger",
+    "workBay": "Arbeidsplass",
+    "selectWorkBay": "Velg arbeidsplass …",
+    "noWorkBay": "Ingen arbeidsplass"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Kundevarsler",
     "title": "Kundevarsler",

+ 79 - 4
messages/nb/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Kolonner",
+    "byTechnician": "Én kolonne per mekaniker",
+    "byBay": "Én kolonne per arbeidsplass",
+    "byNothing": "Én kolonne per dag",
+    "weekends": "Helg",
+    "add": "Legg til",
+    "addBay": "Legg til arbeidsplass",
+    "weekNumber": "Uke {number}",
+    "lanes": "Kolonner",
+    "showAllLanes": "Vis alle",
+    "onlyBusyLanes": "Bare med jobber",
+    "layout": "Visning",
+    "layoutTimeline": "Tidslinje",
+    "layoutCards": "Oversikt",
+    "rows": "Rader",
+    "rowPerTechnician": "Én rad per mekaniker",
+    "rowPerBay": "Én rad per arbeidsplass",
+    "rowNothing": "Én enkelt rad",
+    "technicians": "Mekanikere",
+    "workBays": "Arbeidsplasser",
+    "weekendsDayHint": "Gjelder bare når en uke vises"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Kunne ikke flytte tilordning",
     "failedMove": "Kunne ikke flytte tilordning",
     "failedAssign": "Kunne ikke tilordne jobb",
     "failedAssign": "Kunne ikke tilordne jobb",
     "failedRemove": "Kunne ikke fjerne tilordning",
     "failedRemove": "Kunne ikke fjerne tilordning",
-    "createNewWorkorder": "Opprett ny arbeidsordre"
+    "createNewWorkorder": "Opprett ny arbeidsordre",
+    "noBays": "Ingen arbeidsplasser ennå. Legg til én for å planlegge uken per plass.",
+    "addFirstBay": "Legg til arbeidsplass",
+    "lanes": {
+      "noBay": "Uten plass",
+      "noTechnician": "Uten mekaniker",
+      "everyone": "Alt arbeid",
+      "thisWeek": "Denne uken"
+    },
+    "dropNeedsLane": "Grupper tavlen etter mekaniker eller arbeidsplass før du slipper en jobb på den."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Utildelte jobber ({count})",
     "title": "Utildelte jobber ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "status": "Status",
     "week": "Uke",
     "week": "Uke",
     "today": "I dag",
     "today": "I dag",
-    "timeline": "Tidslinje",
     "live": "Direkte",
     "live": "Direkte",
     "disconnected": "Direkteoppdateringer frakoblet",
     "disconnected": "Direkteoppdateringer frakoblet",
     "noTechnicians": "Ingen teknikere konfigurert",
     "noTechnicians": "Ingen teknikere konfigurert",
@@ -76,6 +105,52 @@
       "completed": "Fullført"
       "completed": "Fullført"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Statustavlen grupperer etter status",
+    "connecting": "Kobler til …"
+  },
+  "week": {
+    "createAt": "Ny arbeidsordre kl. {time}",
+    "unscheduled": "Ikke planlagt ({count})",
+    "hint": "Dra en jobb for å flytte den. Dra øverste eller nederste kant for å endre varigheten.",
+    "hiddenDays": "{count} planlagt i helgen. Vis helg.",
+    "panLeft": "Tidligere i uken",
+    "panRight": "Senere i uken",
+    "hintPan": "Dra bakgrunnen for å bevege deg gjennom uken.",
+    "wholeWeek": "Vis hele uken",
+    "hintZoom": "Hold Ctrl og rull for å zoome."
+  },
+  "bay": {
+    "addTitle": "Legg til arbeidsplass",
+    "editTitle": "Rediger arbeidsplass",
+    "description": "Et sted arbeidet skjer: en løftebukk, en rampe eller en vaskeplass.",
+    "name": "Navn",
+    "namePlaceholder": "f.eks. Løftebukk 1",
+    "color": "Farge",
+    "dailyCapacity": "Bookbare timer per dag",
+    "hoursPerDay": "timer per dag",
+    "capacityHint": "Brukes til å vise hvor full hver dag er.",
+    "deleteTitle": "Slett arbeidsplass",
+    "deleteDescription": "Fjerne «{name}»? Jobber som er booket der forblir planlagt og flyttes til kolonnen uten plass.",
+    "addBay": "Legg til arbeidsplass",
+    "saveFailed": "Kunne ikke lagre arbeidsplassen"
+  },
+  "lane": {
+    "booked": "Booket",
+    "capacity": "Kapasitet",
+    "free": "Ledig",
+    "over": "Overbooket med",
+    "jobs": "Jobber",
+    "utilisation": "{pct} % av kapasiteten",
+    "placeholder": "Arbeid som ennå ikke er tildelt her."
+  },
+  "job": {
+    "time": "Tid",
+    "duration": "Varighet",
+    "technician": "Mekaniker",
+    "workBay": "Arbeidsplass",
+    "status": "Status",
+    "noTime": "Ingen tid satt ennå",
+    "none": "Ingen ennå"
   }
   }
 }
 }

+ 8 - 2
messages/nl/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Bandenset afgeschreven",
     "tire_set_dispose": "Bandenset afgeschreven",
     "tire_set_attach": "Bestanden aan een bandenset toegevoegd",
     "tire_set_attach": "Bestanden aan een bandenset toegevoegd",
     "tire_set_detach": "Bestand van een bandenset verwijderd",
     "tire_set_detach": "Bestand van een bandenset verwijderd",
-    "whatsapp_send": "WhatsApp-bericht verzonden"
+    "whatsapp_send": "WhatsApp-bericht verzonden",
+    "work_bay_create": "Werkplek aangemaakt",
+    "work_bay_update": "Werkplek bijgewerkt",
+    "work_bay_delete": "Werkplek verwijderd"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -256,6 +259,9 @@
     "vehicle_update": "Voertuig {name} bijgewerkt, gewijzigd: {fields}",
     "vehicle_update": "Voertuig {name} bijgewerkt, gewijzigd: {fields}",
     "vehicle_updateNoChanges": "Voertuig {name} bijgewerkt, niets gewijzigd",
     "vehicle_updateNoChanges": "Voertuig {name} bijgewerkt, niets gewijzigd",
     "webhook_create": "Webhook {name} aangemaakt",
     "webhook_create": "Webhook {name} aangemaakt",
-    "whatsapp_send": "WhatsApp-bericht verzonden naar {name}"
+    "whatsapp_send": "WhatsApp-bericht verzonden naar {name}",
+    "work_bay_create": "Werkplek \"{name}\" aangemaakt",
+    "work_bay_update": "Werkplek \"{name}\" bijgewerkt",
+    "work_bay_delete": "Werkplek {id} verwijderd"
   }
   }
 }
 }

+ 4 - 1
messages/nl/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Meer lezen",
     "readMore": "Meer lezen",
-    "setDefaults": "Standaardwaarden"
+    "setDefaults": "Standaardwaarden",
+    "workBay": "Werkplek",
+    "selectWorkBay": "Werkplek kiezen…",
+    "noWorkBay": "Geen werkplek"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Klantmeldingen",
     "title": "Klantmeldingen",

+ 79 - 4
messages/nl/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Kolommen",
+    "byTechnician": "Een kolom per monteur",
+    "byBay": "Een kolom per werkplek",
+    "byNothing": "Een kolom per dag",
+    "weekends": "Weekend",
+    "add": "Toevoegen",
+    "addBay": "Werkplek toevoegen",
+    "weekNumber": "Week {number}",
+    "lanes": "Kolommen",
+    "showAllLanes": "Alles tonen",
+    "onlyBusyLanes": "Alleen met werk",
+    "layout": "Weergave",
+    "layoutTimeline": "Tijdlijn",
+    "layoutCards": "Overzicht",
+    "rows": "Rijen",
+    "rowPerTechnician": "Een rij per monteur",
+    "rowPerBay": "Een rij per werkplek",
+    "rowNothing": "Eén enkele rij",
+    "technicians": "Monteurs",
+    "workBays": "Werkplekken",
+    "weekendsDayHint": "Geldt alleen bij een week"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Toewijzing verplaatsen mislukt",
     "failedMove": "Toewijzing verplaatsen mislukt",
     "failedAssign": "Taak toewijzen mislukt",
     "failedAssign": "Taak toewijzen mislukt",
     "failedRemove": "Toewijzing verwijderen mislukt",
     "failedRemove": "Toewijzing verwijderen mislukt",
-    "createNewWorkorder": "Nieuwe werkorder aanmaken"
+    "createNewWorkorder": "Nieuwe werkorder aanmaken",
+    "noBays": "Nog geen werkplekken. Voeg er een toe om de week per werkplek te plannen.",
+    "addFirstBay": "Werkplek toevoegen",
+    "lanes": {
+      "noBay": "Zonder werkplek",
+      "noTechnician": "Zonder monteur",
+      "everyone": "Al het werk",
+      "thisWeek": "Deze week"
+    },
+    "dropNeedsLane": "Groepeer het bord op monteur of werkplek voordat je een klus erop sleept."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Niet-toegewezen taken ({count})",
     "title": "Niet-toegewezen taken ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "status": "Status",
     "week": "Week",
     "week": "Week",
     "today": "Vandaag",
     "today": "Vandaag",
-    "timeline": "Tijdlijn",
     "live": "Live",
     "live": "Live",
     "disconnected": "Live-updates verbroken",
     "disconnected": "Live-updates verbroken",
     "noTechnicians": "Geen technici geconfigureerd",
     "noTechnicians": "Geen technici geconfigureerd",
@@ -76,6 +105,52 @@
       "completed": "Voltooid"
       "completed": "Voltooid"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Het statusbord groepeert op status",
+    "connecting": "Verbinden…"
+  },
+  "week": {
+    "createAt": "Nieuwe werkorder om {time}",
+    "unscheduled": "Niet ingepland ({count})",
+    "hint": "Sleep een klus om die te verplaatsen. Sleep de boven- of onderrand om de duur te wijzigen.",
+    "hiddenDays": "{count} ingepland in het weekend. Weekend tonen.",
+    "panLeft": "Eerder in de week",
+    "panRight": "Later in de week",
+    "hintPan": "Sleep de achtergrond om door de week te bewegen.",
+    "wholeWeek": "Hele week tonen",
+    "hintZoom": "Houd Ctrl ingedrukt en scroll om te zoomen."
+  },
+  "bay": {
+    "addTitle": "Werkplek toevoegen",
+    "editTitle": "Werkplek bewerken",
+    "description": "Een plek waar gewerkt wordt: een brug, een oprit of een wasplaats.",
+    "name": "Naam",
+    "namePlaceholder": "bijv. Brug 1",
+    "color": "Kleur",
+    "dailyCapacity": "Boekbare uren per dag",
+    "hoursPerDay": "uur per dag",
+    "capacityHint": "Laat zien hoe vol elke dag is.",
+    "deleteTitle": "Werkplek verwijderen",
+    "deleteDescription": "\"{name}\" verwijderen? Klussen die erop staan blijven ingepland en gaan naar de kolom zonder werkplek.",
+    "addBay": "Werkplek toevoegen",
+    "saveFailed": "Kon de werkplek niet opslaan"
+  },
+  "lane": {
+    "booked": "Ingepland",
+    "capacity": "Capaciteit",
+    "free": "Vrij",
+    "over": "Over met",
+    "jobs": "Klussen",
+    "utilisation": "{pct}% van de capaciteit",
+    "placeholder": "Werk dat hier nog niet is toegewezen."
+  },
+  "job": {
+    "time": "Tijd",
+    "duration": "Duur",
+    "technician": "Monteur",
+    "workBay": "Werkplek",
+    "status": "Status",
+    "noTime": "Nog geen tijd",
+    "none": "Nog niemand"
   }
   }
 }
 }

+ 8 - 2
messages/pl/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Spisano komplet opon",
     "tire_set_dispose": "Spisano komplet opon",
     "tire_set_attach": "Dodano pliki do kompletu opon",
     "tire_set_attach": "Dodano pliki do kompletu opon",
     "tire_set_detach": "Usunięto plik z kompletu opon",
     "tire_set_detach": "Usunięto plik z kompletu opon",
-    "whatsapp_send": "Wysłano wiadomość WhatsApp"
+    "whatsapp_send": "Wysłano wiadomość WhatsApp",
+    "work_bay_create": "Utworzono stanowisko",
+    "work_bay_update": "Zaktualizowano stanowisko",
+    "work_bay_delete": "Usunięto stanowisko"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -256,6 +259,9 @@
     "vehicle_update": "Zaktualizowano pojazd {name}, zmieniono: {fields}",
     "vehicle_update": "Zaktualizowano pojazd {name}, zmieniono: {fields}",
     "vehicle_updateNoChanges": "Zaktualizowano pojazd {name}, nic nie zmieniono",
     "vehicle_updateNoChanges": "Zaktualizowano pojazd {name}, nic nie zmieniono",
     "webhook_create": "Utworzono webhook {name}",
     "webhook_create": "Utworzono webhook {name}",
-    "whatsapp_send": "Wysłano wiadomość WhatsApp do {name}"
+    "whatsapp_send": "Wysłano wiadomość WhatsApp do {name}",
+    "work_bay_create": "Utworzono stanowisko „{name}”",
+    "work_bay_update": "Zaktualizowano stanowisko „{name}”",
+    "work_bay_delete": "Usunięto stanowisko {id}"
   }
   }
 }
 }

+ 4 - 1
messages/pl/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Czytaj więcej",
     "readMore": "Czytaj więcej",
-    "setDefaults": "Ustaw domyślne"
+    "setDefaults": "Ustaw domyślne",
+    "workBay": "Stanowisko",
+    "selectWorkBay": "Wybierz stanowisko…",
+    "noWorkBay": "Bez stanowiska"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Powiadomienia klienta",
     "title": "Powiadomienia klienta",

+ 79 - 4
messages/pl/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Kolumny",
+    "byTechnician": "Kolumna na mechanika",
+    "byBay": "Kolumna na stanowisko",
+    "byNothing": "Kolumna na dzień",
+    "weekends": "Weekend",
+    "add": "Dodaj",
+    "addBay": "Dodaj stanowisko",
+    "weekNumber": "Tydz. {number}",
+    "lanes": "Kolumny",
+    "showAllLanes": "Pokaż wszystkie",
+    "onlyBusyLanes": "Tylko ze zleceniami",
+    "layout": "Widok",
+    "layoutTimeline": "Oś czasu",
+    "layoutCards": "Przegląd",
+    "rows": "Wiersze",
+    "rowPerTechnician": "Wiersz na mechanika",
+    "rowPerBay": "Wiersz na stanowisko",
+    "rowNothing": "Jeden wiersz",
+    "technicians": "Mechanicy",
+    "workBays": "Stanowiska",
+    "weekendsDayHint": "Dotyczy tylko widoku tygodnia"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Nie udało się przenieść przydzielenia",
     "failedMove": "Nie udało się przenieść przydzielenia",
     "failedAssign": "Nie udało się przydzielić zadania",
     "failedAssign": "Nie udało się przydzielić zadania",
     "failedRemove": "Nie udało się usunąć przydzielenia",
     "failedRemove": "Nie udało się usunąć przydzielenia",
-    "createNewWorkorder": "Utwórz nowe zlecenie pracy"
+    "createNewWorkorder": "Utwórz nowe zlecenie pracy",
+    "noBays": "Brak stanowisk. Dodaj jedno, aby planować tydzień według stanowisk.",
+    "addFirstBay": "Dodaj stanowisko",
+    "lanes": {
+      "noBay": "Bez stanowiska",
+      "noTechnician": "Bez mechanika",
+      "everyone": "Cała praca",
+      "thisWeek": "Ten tydzień"
+    },
+    "dropNeedsLane": "Pogrupuj tablicę według mechanika lub stanowiska, zanim przeciągniesz na nią zlecenie."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Nieprzyzdzielone Zadania ({count})",
     "title": "Nieprzyzdzielone Zadania ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "status": "Status",
     "week": "Tydzień",
     "week": "Tydzień",
     "today": "Dzisiaj",
     "today": "Dzisiaj",
-    "timeline": "Oś czasu",
     "live": "Na Żywo",
     "live": "Na Żywo",
     "disconnected": "Aktualizacje na żywo rozłączone",
     "disconnected": "Aktualizacje na żywo rozłączone",
     "noTechnicians": "Brak skonfigurowanych techników",
     "noTechnicians": "Brak skonfigurowanych techników",
@@ -76,6 +105,52 @@
       "completed": "Ukończone"
       "completed": "Ukończone"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Tablica statusów grupuje według statusu",
+    "connecting": "Łączenie…"
+  },
+  "week": {
+    "createAt": "Nowe zlecenie o {time}",
+    "unscheduled": "Niezaplanowane ({count})",
+    "hint": "Przeciągnij zlecenie, aby je przenieść. Przeciągnij górną lub dolną krawędź, aby zmienić czas trwania.",
+    "hiddenDays": "{count} zaplanowano w weekend. Pokaż weekend.",
+    "panLeft": "Wcześniej w tygodniu",
+    "panRight": "Później w tygodniu",
+    "hintPan": "Przeciągnij tło, aby przesuwać się po tygodniu.",
+    "wholeWeek": "Pokaż cały tydzień",
+    "hintZoom": "Przytrzymaj Ctrl i przewiń, aby przybliżyć."
+  },
+  "bay": {
+    "addTitle": "Dodaj stanowisko",
+    "editTitle": "Edytuj stanowisko",
+    "description": "Miejsce, w którym wykonywana jest praca: podnośnik, rampa lub myjnia.",
+    "name": "Nazwa",
+    "namePlaceholder": "np. Podnośnik 1",
+    "color": "Kolor",
+    "dailyCapacity": "Godziny do zaplanowania dziennie",
+    "hoursPerDay": "godzin dziennie",
+    "capacityHint": "Pokazuje, jak zapełniony jest każdy dzień.",
+    "deleteTitle": "Usuń stanowisko",
+    "deleteDescription": "Usunąć „{name}”? Zlecenia na tym stanowisku pozostaną zaplanowane i trafią do kolumny bez stanowiska.",
+    "addBay": "Dodaj stanowisko",
+    "saveFailed": "Nie udało się zapisać stanowiska"
+  },
+  "lane": {
+    "booked": "Zaplanowane",
+    "capacity": "Wydajność",
+    "free": "Wolne",
+    "over": "Przekroczono o",
+    "jobs": "Zlecenia",
+    "utilisation": "{pct}% wydajności",
+    "placeholder": "Praca jeszcze nieprzypisana."
+  },
+  "job": {
+    "time": "Godzina",
+    "duration": "Czas trwania",
+    "technician": "Mechanik",
+    "workBay": "Stanowisko",
+    "status": "Status",
+    "noTime": "Brak godziny",
+    "none": "Jeszcze nikt"
   }
   }
 }
 }

+ 8 - 2
messages/pt-BR/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Jogo de pneus baixado",
     "tire_set_dispose": "Jogo de pneus baixado",
     "tire_set_attach": "Arquivos adicionados a um jogo de pneus",
     "tire_set_attach": "Arquivos adicionados a um jogo de pneus",
     "tire_set_detach": "Arquivo removido de um jogo de pneus",
     "tire_set_detach": "Arquivo removido de um jogo de pneus",
-    "whatsapp_send": "Mensagem de WhatsApp enviada"
+    "whatsapp_send": "Mensagem de WhatsApp enviada",
+    "work_bay_create": "Baia criada",
+    "work_bay_update": "Baia atualizada",
+    "work_bay_delete": "Baia excluída"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -256,6 +259,9 @@
     "vehicle_update": "Veículo {name} atualizado, alterado: {fields}",
     "vehicle_update": "Veículo {name} atualizado, alterado: {fields}",
     "vehicle_updateNoChanges": "Veículo {name} atualizado, nada alterado",
     "vehicle_updateNoChanges": "Veículo {name} atualizado, nada alterado",
     "webhook_create": "Webhook {name} criado",
     "webhook_create": "Webhook {name} criado",
-    "whatsapp_send": "Enviou uma mensagem de WhatsApp para {name}"
+    "whatsapp_send": "Enviou uma mensagem de WhatsApp para {name}",
+    "work_bay_create": "Baia \"{name}\" criada",
+    "work_bay_update": "Baia \"{name}\" atualizada",
+    "work_bay_delete": "Baia {id} excluída"
   }
   }
 }
 }

+ 4 - 1
messages/pt-BR/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Saiba mais",
     "readMore": "Saiba mais",
-    "setDefaults": "Valores padrão"
+    "setDefaults": "Valores padrão",
+    "workBay": "Baia",
+    "selectWorkBay": "Selecionar baia…",
+    "noWorkBay": "Sem baia"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Notificações ao cliente",
     "title": "Notificações ao cliente",

+ 79 - 4
messages/pt-BR/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Colunas",
+    "byTechnician": "Uma coluna por técnico",
+    "byBay": "Uma coluna por baia",
+    "byNothing": "Uma coluna por dia",
+    "weekends": "Fim de semana",
+    "add": "Adicionar",
+    "addBay": "Adicionar baia",
+    "weekNumber": "Sem. {number}",
+    "lanes": "Colunas",
+    "showAllLanes": "Mostrar todas",
+    "onlyBusyLanes": "Somente com serviço",
+    "layout": "Exibição",
+    "layoutTimeline": "Linha do tempo",
+    "layoutCards": "Visão geral",
+    "rows": "Linhas",
+    "rowPerTechnician": "Uma linha por técnico",
+    "rowPerBay": "Uma linha por baia",
+    "rowNothing": "Uma única linha",
+    "technicians": "Técnicos",
+    "workBays": "Baias",
+    "weekendsDayHint": "Só se aplica ao ver uma semana"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Falha ao mover atribuição",
     "failedMove": "Falha ao mover atribuição",
     "failedAssign": "Falha ao atribuir trabalho",
     "failedAssign": "Falha ao atribuir trabalho",
     "failedRemove": "Falha ao remover atribuição",
     "failedRemove": "Falha ao remover atribuição",
-    "createNewWorkorder": "Criar nova ordem de serviço"
+    "createNewWorkorder": "Criar nova ordem de serviço",
+    "noBays": "Nenhuma baia ainda. Adicione uma para planejar a semana por baia.",
+    "addFirstBay": "Adicionar baia",
+    "lanes": {
+      "noBay": "Sem baia",
+      "noTechnician": "Sem técnico",
+      "everyone": "Todo o trabalho",
+      "thisWeek": "Esta semana"
+    },
+    "dropNeedsLane": "Agrupe o quadro por técnico ou baia antes de soltar um serviço nele."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Trabalhos Não Atribuídos ({count})",
     "title": "Trabalhos Não Atribuídos ({count})",
@@ -62,7 +92,6 @@
     "status": "Status",
     "status": "Status",
     "week": "Semana",
     "week": "Semana",
     "today": "Hoje",
     "today": "Hoje",
-    "timeline": "Linha do tempo",
     "live": "Ao Vivo",
     "live": "Ao Vivo",
     "disconnected": "Atualizações ao vivo desconectadas",
     "disconnected": "Atualizações ao vivo desconectadas",
     "noTechnicians": "Nenhum técnico configurado",
     "noTechnicians": "Nenhum técnico configurado",
@@ -76,6 +105,52 @@
       "completed": "Concluído"
       "completed": "Concluído"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "O quadro de status agrupa por status",
+    "connecting": "Conectando…"
+  },
+  "week": {
+    "createAt": "Nova ordem de serviço às {time}",
+    "unscheduled": "Não agendado ({count})",
+    "hint": "Arraste um serviço para movê-lo. Arraste a borda superior ou inferior para mudar a duração.",
+    "hiddenDays": "{count} agendado(s) no fim de semana. Mostrar fim de semana.",
+    "panLeft": "Antes na semana",
+    "panRight": "Depois na semana",
+    "hintPan": "Arraste o fundo para percorrer a semana.",
+    "wholeWeek": "Ver a semana inteira",
+    "hintZoom": "Segure Ctrl e role para dar zoom."
+  },
+  "bay": {
+    "addTitle": "Adicionar baia",
+    "editTitle": "Editar baia",
+    "description": "Um lugar onde o trabalho acontece: um elevador, uma rampa ou uma área de lavagem.",
+    "name": "Nome",
+    "namePlaceholder": "ex.: Elevador 1",
+    "color": "Cor",
+    "dailyCapacity": "Horas agendáveis por dia",
+    "hoursPerDay": "horas por dia",
+    "capacityHint": "Mostra o quanto cada dia está cheio.",
+    "deleteTitle": "Excluir baia",
+    "deleteDescription": "Remover \"{name}\"? Os serviços agendados nela continuam agendados e vão para a coluna sem baia.",
+    "addBay": "Adicionar baia",
+    "saveFailed": "Não foi possível salvar a baia"
+  },
+  "lane": {
+    "booked": "Agendado",
+    "capacity": "Capacidade",
+    "free": "Livre",
+    "over": "Excedido em",
+    "jobs": "Serviços",
+    "utilisation": "{pct}% da capacidade",
+    "placeholder": "Serviço ainda sem atribuição aqui."
+  },
+  "job": {
+    "time": "Horário",
+    "duration": "Duração",
+    "technician": "Técnico",
+    "workBay": "Baia",
+    "status": "Status",
+    "noTime": "Ainda sem horário",
+    "none": "Ainda ninguém"
   }
   }
 }
 }

+ 8 - 2
messages/ru/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Комплект шин списан",
     "tire_set_dispose": "Комплект шин списан",
     "tire_set_attach": "Файлы добавлены к комплекту шин",
     "tire_set_attach": "Файлы добавлены к комплекту шин",
     "tire_set_detach": "Файл удалён из комплекта шин",
     "tire_set_detach": "Файл удалён из комплекта шин",
-    "whatsapp_send": "Отправлено сообщение WhatsApp"
+    "whatsapp_send": "Отправлено сообщение WhatsApp",
+    "work_bay_create": "Создан пост",
+    "work_bay_update": "Обновлён пост",
+    "work_bay_delete": "Удалён пост"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -256,6 +259,9 @@
     "vehicle_update": "Обновлён автомобиль {name}, изменено: {fields}",
     "vehicle_update": "Обновлён автомобиль {name}, изменено: {fields}",
     "vehicle_updateNoChanges": "Обновлён автомобиль {name}, изменений нет",
     "vehicle_updateNoChanges": "Обновлён автомобиль {name}, изменений нет",
     "webhook_create": "Создан вебхук {name}",
     "webhook_create": "Создан вебхук {name}",
-    "whatsapp_send": "Отправлено сообщение WhatsApp клиенту {name}"
+    "whatsapp_send": "Отправлено сообщение WhatsApp клиенту {name}",
+    "work_bay_create": "Создан пост «{name}»",
+    "work_bay_update": "Обновлён пост «{name}»",
+    "work_bay_delete": "Удалён пост {id}"
   }
   }
 }
 }

+ 4 - 1
messages/ru/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Подробнее",
     "readMore": "Подробнее",
-    "setDefaults": "Настройки по умолчанию"
+    "setDefaults": "Настройки по умолчанию",
+    "workBay": "Пост",
+    "selectWorkBay": "Выберите пост…",
+    "noWorkBay": "Без поста"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Уведомления клиента",
     "title": "Уведомления клиента",

+ 79 - 4
messages/ru/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Колонки",
+    "byTechnician": "Колонка на механика",
+    "byBay": "Колонка на пост",
+    "byNothing": "Колонка на день",
+    "weekends": "Выходные",
+    "add": "Добавить",
+    "addBay": "Добавить пост",
+    "weekNumber": "Нед. {number}",
+    "lanes": "Колонки",
+    "showAllLanes": "Показать все",
+    "onlyBusyLanes": "Только с работой",
+    "layout": "Вид",
+    "layoutTimeline": "Шкала времени",
+    "layoutCards": "Обзор",
+    "rows": "Строки",
+    "rowPerTechnician": "Строка на механика",
+    "rowPerBay": "Строка на пост",
+    "rowNothing": "Одна строка",
+    "technicians": "Механики",
+    "workBays": "Посты",
+    "weekendsDayHint": "Действует только при показе недели"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Не удалось переместить назначение",
     "failedMove": "Не удалось переместить назначение",
     "failedAssign": "Не удалось назначить работу",
     "failedAssign": "Не удалось назначить работу",
     "failedRemove": "Не удалось удалить назначение",
     "failedRemove": "Не удалось удалить назначение",
-    "createNewWorkorder": "Создать новый заказ-наряд"
+    "createNewWorkorder": "Создать новый заказ-наряд",
+    "noBays": "Постов пока нет. Добавьте пост, чтобы планировать неделю по постам.",
+    "addFirstBay": "Добавить пост",
+    "lanes": {
+      "noBay": "Без поста",
+      "noTechnician": "Без механика",
+      "everyone": "Вся работа",
+      "thisWeek": "Эта неделя"
+    },
+    "dropNeedsLane": "Сгруппируйте доску по механику или посту, прежде чем переносить на неё работу."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Неназначенные работы ({count})",
     "title": "Неназначенные работы ({count})",
@@ -62,7 +92,6 @@
     "status": "Статус",
     "status": "Статус",
     "week": "Неделя",
     "week": "Неделя",
     "today": "Сегодня",
     "today": "Сегодня",
-    "timeline": "Хронология",
     "live": "Онлайн",
     "live": "Онлайн",
     "disconnected": "Обновления в реальном времени отключены",
     "disconnected": "Обновления в реальном времени отключены",
     "noTechnicians": "Механики не настроены",
     "noTechnicians": "Механики не настроены",
@@ -76,6 +105,52 @@
       "completed": "Завершено"
       "completed": "Завершено"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Доска статусов группирует по статусу",
+    "connecting": "Подключение…"
+  },
+  "week": {
+    "createAt": "Новый заказ-наряд в {time}",
+    "unscheduled": "Без времени ({count})",
+    "hint": "Перетащите работу, чтобы перенести её. Потяните верхний или нижний край, чтобы изменить длительность.",
+    "hiddenDays": "{count} запланировано на выходные. Показать выходные.",
+    "panLeft": "Раньше на неделе",
+    "panRight": "Позже на неделе",
+    "hintPan": "Перетащите фон, чтобы двигаться по неделе.",
+    "wholeWeek": "Показать всю неделю",
+    "hintZoom": "Удерживайте Ctrl и прокручивайте для масштаба."
+  },
+  "bay": {
+    "addTitle": "Добавить пост",
+    "editTitle": "Изменить пост",
+    "description": "Место, где выполняется работа: подъёмник, эстакада или мойка.",
+    "name": "Название",
+    "namePlaceholder": "напр. Подъёмник 1",
+    "color": "Цвет",
+    "dailyCapacity": "Часов в день под запись",
+    "hoursPerDay": "часов в день",
+    "capacityHint": "Показывает, насколько заполнен каждый день.",
+    "deleteTitle": "Удалить пост",
+    "deleteDescription": "Удалить «{name}»? Работы на нём останутся запланированными и перейдут в колонку без поста.",
+    "addBay": "Добавить пост",
+    "saveFailed": "Не удалось сохранить пост"
+  },
+  "lane": {
+    "booked": "Запланировано",
+    "capacity": "Ёмкость",
+    "free": "Свободно",
+    "over": "Превышение на",
+    "jobs": "Работы",
+    "utilisation": "{pct} % ёмкости",
+    "placeholder": "Работа, ещё никому не назначенная."
+  },
+  "job": {
+    "time": "Время",
+    "duration": "Длительность",
+    "technician": "Механик",
+    "workBay": "Пост",
+    "status": "Статус",
+    "noTime": "Время не назначено",
+    "none": "Пока никто"
   }
   }
 }
 }

+ 8 - 2
messages/tr/audit.json

@@ -136,7 +136,10 @@
     "tire_set_dispose": "Lastik takımı hurdaya ayrıldı",
     "tire_set_dispose": "Lastik takımı hurdaya ayrıldı",
     "tire_set_attach": "Lastik takımına dosya eklendi",
     "tire_set_attach": "Lastik takımına dosya eklendi",
     "tire_set_detach": "Lastik takımından dosya kaldırıldı",
     "tire_set_detach": "Lastik takımından dosya kaldırıldı",
-    "whatsapp_send": "WhatsApp mesajı gönderildi"
+    "whatsapp_send": "WhatsApp mesajı gönderildi",
+    "work_bay_create": "Çalışma alanı oluşturuldu",
+    "work_bay_update": "Çalışma alanı güncellendi",
+    "work_bay_delete": "Çalışma alanı silindi"
   },
   },
   "summary": {
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -256,6 +259,9 @@
     "vehicle_update": "{name} aracı güncellendi, değişenler: {fields}",
     "vehicle_update": "{name} aracı güncellendi, değişenler: {fields}",
     "vehicle_updateNoChanges": "{name} aracı güncellendi, hiçbir şey değişmedi",
     "vehicle_updateNoChanges": "{name} aracı güncellendi, hiçbir şey değişmedi",
     "webhook_create": "{name} webhook'u oluşturuldu",
     "webhook_create": "{name} webhook'u oluşturuldu",
-    "whatsapp_send": "{name} kişisine WhatsApp mesajı gönderildi"
+    "whatsapp_send": "{name} kişisine WhatsApp mesajı gönderildi",
+    "work_bay_create": "\"{name}\" çalışma alanı oluşturuldu",
+    "work_bay_update": "\"{name}\" çalışma alanı güncellendi",
+    "work_bay_delete": "{id} çalışma alanı silindi"
   }
   }
 }
 }

+ 4 - 1
messages/tr/service.json

@@ -341,7 +341,10 @@
     "platformUsers": "Platform Users",
     "platformUsers": "Platform Users",
     "customTechnicians": "Custom",
     "customTechnicians": "Custom",
     "readMore": "Daha fazla",
     "readMore": "Daha fazla",
-    "setDefaults": "Varsayılanlar"
+    "setDefaults": "Varsayılanlar",
+    "workBay": "Çalışma alanı",
+    "selectWorkBay": "Çalışma alanı seçin…",
+    "noWorkBay": "Çalışma alanı yok"
   },
   },
   "notifications": {
   "notifications": {
     "title": "Müşteri bildirimleri",
     "title": "Müşteri bildirimleri",

+ 79 - 4
messages/tr/workBoard.json

@@ -11,7 +11,28 @@
     "previousWeek": "Previous week",
     "previousWeek": "Previous week",
     "nextWeek": "Next week",
     "nextWeek": "Next week",
     "previousDay": "Previous day",
     "previousDay": "Previous day",
-    "nextDay": "Next day"
+    "nextDay": "Next day",
+    "groupBy": "Sütunlar",
+    "byTechnician": "Teknisyen başına bir sütun",
+    "byBay": "Çalışma alanı başına bir sütun",
+    "byNothing": "Gün başına bir sütun",
+    "weekends": "Hafta sonu",
+    "add": "Ekle",
+    "addBay": "Çalışma alanı ekle",
+    "weekNumber": "{number}. hafta",
+    "lanes": "Sütunlar",
+    "showAllLanes": "Tümünü göster",
+    "onlyBusyLanes": "Yalnızca işi olanlar",
+    "layout": "Görünüm",
+    "layoutTimeline": "Zaman çizelgesi",
+    "layoutCards": "Genel bakış",
+    "rows": "Satırlar",
+    "rowPerTechnician": "Teknisyen başına bir satır",
+    "rowPerBay": "Çalışma alanı başına bir satır",
+    "rowNothing": "Tek satır",
+    "technicians": "Teknisyenler",
+    "workBays": "Çalışma alanları",
+    "weekendsDayHint": "Yalnızca hafta görünümünde geçerlidir"
   },
   },
   "board": {
   "board": {
     "days": {
     "days": {
@@ -28,7 +49,16 @@
     "failedMove": "Atama taşınamadı",
     "failedMove": "Atama taşınamadı",
     "failedAssign": "İş atanmadı",
     "failedAssign": "İş atanmadı",
     "failedRemove": "Atama kaldırılamadı",
     "failedRemove": "Atama kaldırılamadı",
-    "createNewWorkorder": "Yeni iş emri oluştur"
+    "createNewWorkorder": "Yeni iş emri oluştur",
+    "noBays": "Henüz çalışma alanı yok. Haftayı alana göre planlamak için bir tane ekleyin.",
+    "addFirstBay": "Çalışma alanı ekle",
+    "lanes": {
+      "noBay": "Alansız",
+      "noTechnician": "Teknisyensiz",
+      "everyone": "Tüm işler",
+      "thisWeek": "Bu hafta"
+    },
+    "dropNeedsLane": "Bir işi panoya bırakmadan önce panoyu teknisyene veya çalışma alanına göre gruplayın."
   },
   },
   "unassigned": {
   "unassigned": {
     "title": "Atanmamış İşler ({count})",
     "title": "Atanmamış İşler ({count})",
@@ -62,7 +92,6 @@
     "status": "Durum",
     "status": "Durum",
     "week": "Hafta",
     "week": "Hafta",
     "today": "Bugün",
     "today": "Bugün",
-    "timeline": "Zaman çizelgesi",
     "live": "Canlı",
     "live": "Canlı",
     "disconnected": "Canlı güncellemeler kesildi",
     "disconnected": "Canlı güncellemeler kesildi",
     "noTechnicians": "Hiçbir teknisyen yapılandırılmadı",
     "noTechnicians": "Hiçbir teknisyen yapılandırılmadı",
@@ -76,6 +105,52 @@
       "completed": "Tamamlandı"
       "completed": "Tamamlandı"
     },
     },
     "previous": "Previous",
     "previous": "Previous",
-    "next": "Next"
+    "next": "Next",
+    "groupingUnused": "Durum panosu duruma göre gruplar",
+    "connecting": "Bağlanıyor…"
+  },
+  "week": {
+    "createAt": "{time} için yeni iş emri",
+    "unscheduled": "Planlanmamış ({count})",
+    "hint": "Bir işi taşımak için sürükleyin. Süresini değiştirmek için üst veya alt kenarını sürükleyin.",
+    "hiddenDays": "{count} iş hafta sonuna planlandı. Hafta sonunu göster.",
+    "panLeft": "Haftanın başına doğru",
+    "panRight": "Haftanın sonuna doğru",
+    "hintPan": "Hafta boyunca ilerlemek için arka planı sürükleyin.",
+    "wholeWeek": "Tüm haftayı gör",
+    "hintZoom": "Yakınlaştırmak için Ctrl tuşuna basılı tutup kaydırın."
+  },
+  "bay": {
+    "addTitle": "Çalışma alanı ekle",
+    "editTitle": "Çalışma alanını düzenle",
+    "description": "İşin yapıldığı yer: bir lift, bir rampa veya yıkama alanı.",
+    "name": "Ad",
+    "namePlaceholder": "örn. Lift 1",
+    "color": "Renk",
+    "dailyCapacity": "Günlük planlanabilir saat",
+    "hoursPerDay": "saat/gün",
+    "capacityHint": "Her günün ne kadar dolu olduğunu gösterir.",
+    "deleteTitle": "Çalışma alanını sil",
+    "deleteDescription": "\"{name}\" kaldırılsın mı? Buraya planlanan işler planlı kalır ve alansız sütuna geçer.",
+    "addBay": "Çalışma alanı ekle",
+    "saveFailed": "Çalışma alanı kaydedilemedi"
+  },
+  "lane": {
+    "booked": "Planlanan",
+    "capacity": "Kapasite",
+    "free": "Boş",
+    "over": "Aşım",
+    "jobs": "İşler",
+    "utilisation": "Kapasitenin %{pct}'i",
+    "placeholder": "Henüz buraya atanmamış işler."
+  },
+  "job": {
+    "time": "Saat",
+    "duration": "Süre",
+    "technician": "Teknisyen",
+    "workBay": "Çalışma alanı",
+    "status": "Durum",
+    "noTime": "Henüz saat yok",
+    "none": "Henüz kimse"
   }
   }
 }
 }

+ 38 - 0
prisma/migrations/20260825120000_work_bays/migration.sql

@@ -0,0 +1,38 @@
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "workBayId" TEXT;
+
+-- AlterTable
+ALTER TABLE "inspections" ADD COLUMN     "workBayId" TEXT;
+
+-- CreateTable
+CREATE TABLE "work_bays" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "color" TEXT NOT NULL DEFAULT '#64748b',
+    "isActive" BOOLEAN NOT NULL DEFAULT true,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "dailyCapacity" INTEGER NOT NULL DEFAULT 480,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "work_bays_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "work_bays_organizationId_idx" ON "work_bays"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "service_records_workBayId_idx" ON "service_records"("workBayId");
+
+-- CreateIndex
+CREATE INDEX "inspections_workBayId_idx" ON "inspections"("workBayId");
+
+-- AddForeignKey
+ALTER TABLE "work_bays" ADD CONSTRAINT "work_bays_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "service_records" ADD CONSTRAINT "service_records_workBayId_fkey" FOREIGN KEY ("workBayId") REFERENCES "work_bays"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "inspections" ADD CONSTRAINT "inspections_workBayId_fkey" FOREIGN KEY ("workBayId") REFERENCES "work_bays"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 64 - 24
prisma/schema.prisma

@@ -266,7 +266,13 @@ model ServiceRecord {
 
 
   technicianId String?
   technicianId String?
   technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
   technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
-  sortOrder    Int         @default(0)
+
+  /// Where in the shop the job is booked in: a lift, a ramp, an alignment
+  /// stand. Independent of the technician, because a shop plans its bays and
+  /// its people separately and the work board can lane by either.
+  workBayId String?
+  workBay   WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
+  sortOrder Int      @default(0)
 
 
   partItems      ServicePart[]
   partItems      ServicePart[]
   laborItems     ServiceLabor[]
   laborItems     ServiceLabor[]
@@ -280,6 +286,7 @@ model ServiceRecord {
 
 
   @@index([vehicleId])
   @@index([vehicleId])
   @@index([technicianId])
   @@index([technicianId])
+  @@index([workBayId])
   @@index([organizationId])
   @@index([organizationId])
   @@index([customerId])
   @@index([customerId])
   @@index([inspectionId])
   @@index([inspectionId])
@@ -835,6 +842,7 @@ model Organization {
   telegramMessages       TelegramMessage[]
   telegramMessages       TelegramMessage[]
   whatsappMessages       WhatsappMessage[]
   whatsappMessages       WhatsappMessage[]
   technicians            Technician[]
   technicians            Technician[]
+  workBays               WorkBay[]
   auditLogs              AuditLog[]
   auditLogs              AuditLog[]
   statusReports          StatusReport[]
   statusReports          StatusReport[]
   reportSchedules        ReportSchedule[]
   reportSchedules        ReportSchedule[]
@@ -1129,7 +1137,12 @@ model Inspection {
 
 
   technicianId String?
   technicianId String?
   technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
   technician   Technician? @relation(fields: [technicianId], references: [id], onDelete: SetNull)
-  sortOrder    Int         @default(0)
+
+  /// See ServiceRecord.workBayId — an inspection occupies a bay just as a
+  /// repair does, and the work board lanes both the same way.
+  workBayId String?
+  workBay   WorkBay? @relation(fields: [workBayId], references: [id], onDelete: SetNull)
+  sortOrder Int      @default(0)
 
 
   // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
   // Roadworthiness certificate fields required by Directive 2014/45/EU Annex IV.
   // `inspectorName` and `certificateNumber` are snapshots taken at completion so
   // `inspectorName` and `certificateNumber` are snapshots taken at completion so
@@ -1158,6 +1171,7 @@ model Inspection {
   @@index([vehicleId])
   @@index([vehicleId])
   @@index([organizationId])
   @@index([organizationId])
   @@index([technicianId])
   @@index([technicianId])
+  @@index([workBayId])
   @@map("inspections")
   @@map("inspections")
 }
 }
 
 
@@ -1285,7 +1299,7 @@ model TelegramMessage {
 /// provider without its history becoming unreadable, and a status callback
 /// provider without its history becoming unreadable, and a status callback
 /// arriving later has to find the row it belongs to.
 /// arriving later has to find the row it belongs to.
 model WhatsappMessage {
 model WhatsappMessage {
-  id        String  @id @default(cuid())
+  id        String @id @default(cuid())
   direction String // "inbound" | "outbound"
   direction String // "inbound" | "outbound"
   provider  String // "meta" | "twilio" | any adapter registered later
   provider  String // "meta" | "twilio" | any adapter registered later
 
 
@@ -1327,7 +1341,6 @@ model WhatsappMessage {
   @@map("whatsapp_messages")
   @@map("whatsapp_messages")
 }
 }
 
 
-
 model Technician {
 model Technician {
   id            String  @id @default(cuid())
   id            String  @id @default(cuid())
   name          String
   name          String
@@ -1353,6 +1366,35 @@ model Technician {
   @@map("technicians")
   @@map("technicians")
 }
 }
 
 
+/// A physical place work happens: a lift, a wash bay, an alignment rack. Shops
+/// plan by bay as often as by person — the bay is the scarce resource, and a
+/// technician moves between them during a day. The work board can group its
+/// lanes by either, so both are first-class.
+model WorkBay {
+  id            String  @id @default(cuid())
+  name          String
+  color         String  @default("#64748b")
+  isActive      Boolean @default(true)
+  /// Ordering on the board, left to right. Shops name bays by position
+  /// ("Bay 1", "Lift 2") and expect them in that order, not alphabetically.
+  sortOrder     Int     @default(0)
+  /// Bookable minutes per day, mirroring Technician.dailyCapacity so the board
+  /// can show utilisation for whichever lane grouping is on.
+  dailyCapacity Int     @default(480)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  serviceRecords ServiceRecord[]
+  inspections    Inspection[]
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([organizationId])
+  @@map("work_bays")
+}
+
 model AuditLog {
 model AuditLog {
   id        String   @id @default(cuid())
   id        String   @id @default(cuid())
   timestamp DateTime @default(now())
   timestamp DateTime @default(now())
@@ -1646,12 +1688,12 @@ model WebhookDelivery {
 /// A physical building or room holding tire storage. Most workshops have one;
 /// A physical building or room holding tire storage. Most workshops have one;
 /// multi-site operators have several.
 /// multi-site operators have several.
 model TireWarehouse {
 model TireWarehouse {
-  id      String  @id @default(cuid())
-  name    String
-  address String?
-  notes   String?
+  id         String  @id @default(cuid())
+  name       String
+  address    String?
+  notes      String?
   /// Preselected when checking tires in. Exactly one per organization.
   /// Preselected when checking tires in. Exactly one per organization.
-  isDefault Boolean @default(false)
+  isDefault  Boolean @default(false)
   isArchived Boolean @default(false)
   isArchived Boolean @default(false)
 
 
   organizationId String
   organizationId String
@@ -1721,25 +1763,25 @@ model TireSet {
 
 
   /// summer | winter | all_season | other — see TIRE_SEASONS in
   /// summer | winter | all_season | other — see TIRE_SEASONS in
   /// src/features/tire-hotel/Lib/tireConstants.ts
   /// src/features/tire-hotel/Lib/tireConstants.ts
-  season String @default("summer")
+  season  String  @default("summer")
   /// Studded winter tires are legal in some markets and banned in others, so
   /// Studded winter tires are legal in some markets and banned in others, so
   /// this is a flag on the season rather than a season of its own.
   /// this is a flag on the season rather than a season of its own.
   studded Boolean @default(false)
   studded Boolean @default(false)
 
 
-  brand String?
-  model String?
+  brand          String?
+  model          String?
   /// Free text so every regional notation fits: "225/45R17", "P225/45R17 94V",
   /// Free text so every regional notation fits: "225/45R17", "P225/45R17 94V",
   /// "31x10.5R15".
   /// "31x10.5R15".
-  size String?
+  size           String?
   /// DOT / TIN week-year code used to judge tire age.
   /// DOT / TIN week-year code used to judge tire age.
-  dotCode String?
+  dotCode        String?
   loadSpeedIndex String?
   loadSpeedIndex String?
 
 
   /// Mounted on rims, which changes both handling and what a shelf holds.
   /// Mounted on rims, which changes both handling and what a shelf holds.
   withRims Boolean @default(false)
   withRims Boolean @default(false)
   rimType  String?
   rimType  String?
   /// Tire pressure monitoring sensors present — they need care on refit.
   /// Tire pressure monitoring sensors present — they need care on refit.
-  hasTpms Boolean @default(false)
+  hasTpms  Boolean @default(false)
 
 
   /// Individual tires in this set. Drives every capacity calculation.
   /// Individual tires in this set. Drives every capacity calculation.
   quantity Int @default(4)
   quantity Int @default(4)
@@ -1768,11 +1810,11 @@ model TireSet {
   checkedInAt  DateTime?
   checkedInAt  DateTime?
   checkedOutAt DateTime?
   checkedOutAt DateTime?
 
 
-  measurements TireMeasurement[]
-  movements    TireMovement[]
-  treatments   TireTreatment[]
-  attachments  TireSetAttachment[]
-  quotes       Quote[]
+  measurements   TireMeasurement[]
+  movements      TireMovement[]
+  treatments     TireTreatment[]
+  attachments    TireSetAttachment[]
+  quotes         Quote[]
   serviceRecords ServiceRecord[]
   serviceRecords ServiceRecord[]
 
 
   createdAt DateTime @default(now())
   createdAt DateTime @default(now())
@@ -1840,10 +1882,10 @@ model TireMeasurement {
   /// every market.
   /// every market.
   treadDepthMm Float?
   treadDepthMm Float?
   /// Always stored in bar. Converted to psi/kPa for display.
   /// Always stored in bar. Converted to psi/kPa for display.
-  pressureBar Float?
+  pressureBar  Float?
 
 
   /// good | fair | replace — see TIRE_CONDITIONS
   /// good | fair | replace — see TIRE_CONDITIONS
-  condition String @default("good")
+  condition String  @default("good")
   damage    String?
   damage    String?
   notes     String?
   notes     String?
 
 
@@ -1907,8 +1949,6 @@ model TireMovement {
   @@map("tire_movements")
   @@map("tire_movements")
 }
 }
 
 
-
-
 /// Work a set needs before it goes back on the car, or before it goes on the
 /// Work a set needs before it goes back on the car, or before it goes on the
 /// shelf: washing, balancing, a TPMS service.
 /// shelf: washing, balancing, a TPMS service.
 ///
 ///

+ 2 - 0
src/__tests__/features/workboard/datetime.test.ts

@@ -17,6 +17,8 @@ function makeJob(startDateTime: string | null, endDateTime: string | null): Work
     id: 'a1',
     id: 'a1',
     type: 'serviceRecord',
     type: 'serviceRecord',
     technicianId: 't1',
     technicianId: 't1',
+    workBayId: null,
+    customerName: null,
     sortOrder: 0,
     sortOrder: 0,
     title: 'Test',
     title: 'Test',
     status: 'pending',
     status: 'pending',

+ 416 - 0
src/__tests__/features/workboard/week-layout.test.ts

@@ -0,0 +1,416 @@
+import { describe, expect, it } from 'vitest'
+import type { WorkBoardJob } from '@/features/workboard/Actions/boardActions'
+import {
+  bookedMinutesOnDay,
+  computeTimeWindow,
+  hourMarks,
+  isUnscheduled,
+  layoutLaneDay,
+  resolveJobOnDay,
+  snapToStep,
+} from '@/features/workboard/utils/layout'
+import {
+  ALL_LANE,
+  UNLANED,
+  buildLanes,
+  groupJobsByLane,
+  isLaneGrouping,
+  laneAssignment,
+  laneIdForJob,
+} from '@/features/workboard/utils/lanes'
+
+/**
+ * Times are written without a zone so they parse as local, the same way the
+ * board reads them from the API. A test that pinned them to UTC would pass or
+ * fail depending on where it ran.
+ */
+function job(
+  id: string,
+  start: string | null,
+  end: string | null,
+  extra: Partial<WorkBoardJob> = {}
+): WorkBoardJob {
+  return {
+    id,
+    type: 'serviceRecord',
+    technicianId: 't1',
+    workBayId: null,
+    customerName: null,
+    sortOrder: 0,
+    title: id,
+    status: 'pending',
+    startDateTime: start,
+    endDateTime: end,
+    vehicle: null,
+    ...extra,
+  }
+}
+
+const DAY = '2026-08-25'
+const NEXT_DAY = '2026-08-26'
+const WINDOW = { startMins: 8 * 60, endMins: 18 * 60 }
+
+describe('resolveJobOnDay', () => {
+  it('places a job at its own hours', () => {
+    const result = resolveJobOnDay(job('a', `${DAY}T09:00:00`, `${DAY}T10:30:00`), DAY, WINDOW)
+    expect(result).toMatchObject({
+      startMins: 540,
+      endMins: 630,
+      continuesBefore: false,
+      continuesAfter: false,
+    })
+  })
+
+  it('ignores a job with no times', () => {
+    expect(resolveJobOnDay(job('a', null, null), DAY, WINDOW)).toBeNull()
+  })
+
+  it('ignores a job on another day', () => {
+    const other = job('a', `${NEXT_DAY}T09:00:00`, `${NEXT_DAY}T10:00:00`)
+    expect(resolveJobOnDay(other, DAY, WINDOW)).toBeNull()
+  })
+
+  it('marks the first day of a job that runs into the next', () => {
+    const long = job('a', `${DAY}T16:00:00`, `${NEXT_DAY}T09:00:00`)
+    expect(resolveJobOnDay(long, DAY, WINDOW)).toMatchObject({
+      startMins: 960,
+      endMins: WINDOW.endMins,
+      continuesBefore: false,
+      continuesAfter: true,
+    })
+  })
+
+  it('marks the second day of the same job', () => {
+    const long = job('a', `${DAY}T16:00:00`, `${NEXT_DAY}T09:00:00`)
+    expect(resolveJobOnDay(long, NEXT_DAY, WINDOW)).toMatchObject({
+      startMins: WINDOW.startMins,
+      endMins: 540,
+      continuesBefore: true,
+      continuesAfter: false,
+    })
+  })
+
+  it('clamps a job that starts before the visible hours', () => {
+    const early = job('a', `${DAY}T06:00:00`, `${DAY}T09:00:00`)
+    expect(resolveJobOnDay(early, DAY, WINDOW)).toMatchObject({
+      startMins: WINDOW.startMins,
+      endMins: 540,
+      continuesBefore: true,
+    })
+  })
+
+  it('drops a job that falls entirely outside the visible hours', () => {
+    const night = job('a', `${DAY}T04:00:00`, `${DAY}T05:00:00`)
+    expect(resolveJobOnDay(night, DAY, WINDOW)).toBeNull()
+  })
+
+  it('does not carry a job that ends exactly at midnight into the next day', () => {
+    const untilMidnight = job('a', `${DAY}T22:00:00`, `${NEXT_DAY}T00:00:00`)
+    expect(resolveJobOnDay(untilMidnight, NEXT_DAY, { startMins: 0, endMins: 1440 })).toBeNull()
+  })
+})
+
+describe('layoutLaneDay', () => {
+  it('gives a lone job the full width', () => {
+    const laid = layoutLaneDay([job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`)], DAY, WINDOW)
+    expect(laid).toHaveLength(1)
+    expect(laid[0]).toMatchObject({ column: 0, columns: 1 })
+  })
+
+  it('keeps consecutive jobs full width', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`),
+        job('b', `${DAY}T10:00:00`, `${DAY}T11:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.columns)).toEqual([1, 1])
+    expect(laid.map((item) => item.column)).toEqual([0, 0])
+  })
+
+  it('splits two overlapping jobs side by side', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T11:00:00`),
+        job('b', `${DAY}T10:00:00`, `${DAY}T12:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.columns)).toEqual([2, 2])
+    expect(laid.map((item) => item.column)).toEqual([0, 1])
+  })
+
+  it('widens the whole cluster when three jobs overlap', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T12:00:00`),
+        job('b', `${DAY}T09:30:00`, `${DAY}T11:00:00`),
+        job('c', `${DAY}T10:00:00`, `${DAY}T10:30:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.columns)).toEqual([3, 3, 3])
+    expect(new Set(laid.map((item) => item.column)).size).toBe(3)
+  })
+
+  it('starts a fresh cluster once the lane is clear again', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T11:00:00`),
+        job('b', `${DAY}T10:00:00`, `${DAY}T12:00:00`),
+        job('c', `${DAY}T13:00:00`, `${DAY}T14:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.columns)).toEqual([2, 2, 1])
+  })
+
+  it('reuses a freed column inside a cluster instead of narrowing everything', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`),
+        job('b', `${DAY}T09:00:00`, `${DAY}T11:00:00`),
+        job('c', `${DAY}T10:30:00`, `${DAY}T12:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    // Only two jobs ever run at once, so the cluster stays two columns wide
+    // and c takes the column a has finished with.
+    const columnById = Object.fromEntries(laid.map((item) => [item.job.id, item.column]))
+    expect(laid.map((item) => item.columns)).toEqual([2, 2, 2])
+    expect(columnById).toEqual({ a: 1, b: 0, c: 1 })
+  })
+
+  it('leaves out jobs that belong to another day', () => {
+    const laid = layoutLaneDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`),
+        job('b', `${NEXT_DAY}T09:00:00`, `${NEXT_DAY}T10:00:00`),
+      ],
+      DAY,
+      WINDOW
+    )
+    expect(laid.map((item) => item.job.id)).toEqual(['a'])
+  })
+})
+
+describe('computeTimeWindow', () => {
+  const days = [DAY, NEXT_DAY]
+
+  it('falls back to the configured work day', () => {
+    expect(computeTimeWindow([], days, 420, 900)).toEqual({ startMins: 420, endMins: 900 })
+  })
+
+  it('stretches to show a job that runs late', () => {
+    const late = job('a', `${DAY}T14:00:00`, `${DAY}T18:30:00`)
+    expect(computeTimeWindow([late], days, 420, 900)).toEqual({ startMins: 420, endMins: 1140 })
+  })
+
+  it('stretches to show a job that starts early', () => {
+    const early = job('a', `${DAY}T05:30:00`, `${DAY}T08:00:00`)
+    expect(computeTimeWindow([early], days, 420, 900)).toEqual({ startMins: 300, endMins: 900 })
+  })
+
+  it('does not flatten the week for a job that spans several days', () => {
+    const long = job('a', `${DAY}T13:00:00`, `${NEXT_DAY}T14:00:00`)
+    expect(computeTimeWindow([long], days, 420, 900)).toEqual({ startMins: 420, endMins: 900 })
+  })
+
+  it('opens the axis for the tail of a job carried in from before the week', () => {
+    const carried = job('a', '2026-08-24T20:00:00', `${DAY}T06:30:00`)
+    expect(computeTimeWindow([carried], days, 420, 900)).toMatchObject({ startMins: 360 })
+  })
+
+  it('ignores jobs that never touch the shown days', () => {
+    const elsewhere = job('a', '2026-09-10T05:00:00', '2026-09-10T06:00:00')
+    expect(computeTimeWindow([elsewhere], days, 420, 900)).toEqual({
+      startMins: 420,
+      endMins: 900,
+    })
+  })
+})
+
+describe('bookedMinutesOnDay', () => {
+  it('adds up the jobs on a day', () => {
+    const total = bookedMinutesOnDay(
+      [
+        job('a', `${DAY}T09:00:00`, `${DAY}T10:30:00`),
+        job('b', `${DAY}T13:00:00`, `${DAY}T14:00:00`),
+      ],
+      DAY
+    )
+    expect(total).toBe(150)
+  })
+
+  it('counts only the part of a multi-day job that falls on the day', () => {
+    const total = bookedMinutesOnDay([job('a', `${DAY}T22:00:00`, `${NEXT_DAY}T02:00:00`)], DAY)
+    expect(total).toBe(120)
+  })
+
+  it('counts work booked outside the visible hours', () => {
+    const total = bookedMinutesOnDay([job('a', `${DAY}T19:00:00`, `${DAY}T21:00:00`)], DAY)
+    expect(total).toBe(120)
+  })
+
+  it('ignores jobs with no times', () => {
+    expect(bookedMinutesOnDay([job('a', null, null)], DAY)).toBe(0)
+  })
+})
+
+describe('hourMarks', () => {
+  it('includes both ends of the window', () => {
+    expect(hourMarks({ startMins: 480, endMins: 660 })).toEqual([480, 540, 600, 660])
+  })
+
+  it('skips a partial hour before the window opens', () => {
+    expect(hourMarks({ startMins: 510, endMins: 660 })).toEqual([540, 600, 660])
+  })
+})
+
+describe('snapToStep', () => {
+  it('snaps to the nearest step', () => {
+    expect(snapToStep(547, 15, WINDOW)).toBe(540)
+    expect(snapToStep(553, 15, WINDOW)).toBe(555)
+  })
+
+  it('keeps the result inside the window', () => {
+    expect(snapToStep(60, 15, WINDOW)).toBe(WINDOW.startMins)
+    expect(snapToStep(2000, 15, WINDOW)).toBe(WINDOW.endMins)
+  })
+})
+
+describe('isUnscheduled', () => {
+  it('is true when either end is missing', () => {
+    expect(isUnscheduled(job('a', null, null))).toBe(true)
+    expect(isUnscheduled(job('a', `${DAY}T09:00:00`, null))).toBe(true)
+    expect(isUnscheduled(job('a', `${DAY}T09:00:00`, `${DAY}T10:00:00`))).toBe(false)
+  })
+})
+
+describe('lanes', () => {
+  const technicians = [
+    {
+      id: 't1',
+      name: 'Ada',
+      color: '#111111',
+      isActive: true,
+      sortOrder: 0,
+      dailyCapacity: 480,
+      userId: null,
+      organizationId: 'org',
+    },
+  ]
+  const bays = [
+    {
+      id: 'b1',
+      name: 'Lift 1',
+      color: '#222222',
+      isActive: true,
+      sortOrder: 0,
+      dailyCapacity: 600,
+      organizationId: 'org',
+    },
+  ]
+  const labels = { unlaned: 'None', all: 'All work' }
+
+  it('reads the lane a job belongs to for each grouping', () => {
+    const withBay = job('a', null, null, { workBayId: 'b1' })
+    expect(laneIdForJob(withBay, 'technician')).toBe('t1')
+    expect(laneIdForJob(withBay, 'bay')).toBe('b1')
+    expect(laneIdForJob(withBay, 'none')).toBe(ALL_LANE)
+  })
+
+  it('puts a job with no value for the grouping in the catch-all lane', () => {
+    expect(laneIdForJob(job('a', null, null), 'bay')).toBe(UNLANED)
+  })
+
+  it('hides the catch-all lane when every job has a value', () => {
+    const jobs = [job('a', null, null, { workBayId: 'b1' })]
+    const lanes = buildLanes({ grouping: 'bay', technicians, workBays: bays, jobs, labels })
+    expect(lanes.map((lane) => lane.id)).toEqual(['b1'])
+  })
+
+  it('shows the catch-all lane when something would otherwise vanish', () => {
+    const jobs = [job('a', null, null)]
+    const lanes = buildLanes({ grouping: 'bay', technicians, workBays: bays, jobs, labels })
+    expect(lanes.map((lane) => lane.id)).toEqual(['b1', UNLANED])
+    expect(lanes[1].isPlaceholder).toBe(true)
+  })
+
+  it('sums capacity into the single lane when nothing is grouped', () => {
+    const lanes = buildLanes({
+      grouping: 'none',
+      technicians,
+      workBays: bays,
+      jobs: [],
+      labels,
+    })
+    expect(lanes).toHaveLength(1)
+    expect(lanes[0]).toMatchObject({ id: ALL_LANE, dailyCapacity: 480 })
+  })
+
+  it('writes the field the grouping owns, and nothing else', () => {
+    expect(laneAssignment('t2', 'technician')).toEqual({ technicianId: 't2' })
+    expect(laneAssignment('b2', 'bay')).toEqual({ workBayId: 'b2' })
+    expect(laneAssignment(UNLANED, 'bay')).toEqual({ workBayId: null })
+    expect(laneAssignment(ALL_LANE, 'none')).toEqual({})
+  })
+
+  it('buckets jobs by lane', () => {
+    const jobs = [
+      job('a', null, null, { workBayId: 'b1' }),
+      job('b', null, null, { workBayId: 'b1' }),
+      job('c', null, null),
+    ]
+    const grouped = groupJobsByLane(jobs, 'bay')
+    expect(grouped.get('b1')?.map((item) => item.id)).toEqual(['a', 'b'])
+    expect(grouped.get(UNLANED)?.map((item) => item.id)).toEqual(['c'])
+  })
+
+  it('validates a grouping coming in from the URL', () => {
+    expect(isLaneGrouping('bay')).toBe(true)
+    expect(isLaneGrouping('bays')).toBe(false)
+    expect(isLaneGrouping(null)).toBe(false)
+  })
+})
+
+describe('lanes that are no longer on the board', () => {
+  const bays = [
+    {
+      id: 'b1',
+      name: 'Lift 1',
+      color: '#222222',
+      isActive: true,
+      sortOrder: 0,
+      dailyCapacity: 600,
+      organizationId: 'org',
+    },
+  ]
+
+  it('folds work pointing at an unknown lane into the catch-all', () => {
+    const orphan = job('a', null, null, { workBayId: 'deleted-bay' })
+    expect(laneIdForJob(orphan, 'bay')).toBe('deleted-bay')
+    expect(laneIdForJob(orphan, 'bay', new Set(['b1']))).toBe(UNLANED)
+  })
+
+  it('shows the catch-all lane for it, so the job stays visible', () => {
+    const jobs = [job('a', null, null, { workBayId: 'deleted-bay' })]
+    const lanes = buildLanes({
+      grouping: 'bay',
+      technicians: [],
+      workBays: bays,
+      jobs,
+      labels: { unlaned: 'None', all: 'All work' },
+    })
+    expect(lanes.map((lane) => lane.id)).toEqual(['b1', UNLANED])
+
+    const grouped = groupJobsByLane(jobs, 'bay', new Set(['b1']))
+    expect(grouped.get(UNLANED)?.map((item) => item.id)).toEqual(['a'])
+  })
+})

+ 50 - 0
src/__tests__/features/workboard/week-number.test.ts

@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { isoWeekNumber } from '@/features/workboard/Components/WorkBoardToolbar'
+import { formatClock } from '@/features/workboard/utils/clock'
+
+/**
+ * The workshop that asked for this view schedules by ISO week number, and the
+ * planner it uses shows "KW 35" for the week of Monday 24 August 2026. An
+ * off-by-one here would be wrong in the one place the number is read aloud.
+ */
+describe('isoWeekNumber', () => {
+  it('matches the reference planner', () => {
+    expect(isoWeekNumber('2026-08-24')).toBe(35)
+  })
+
+  it('gives the same number whichever weekday the shop starts on', () => {
+    expect(isoWeekNumber('2026-08-23')).toBe(35)
+  })
+
+  it('counts the week that straddles New Year as week one', () => {
+    expect(isoWeekNumber('2025-12-29')).toBe(1)
+    expect(isoWeekNumber('2026-01-01')).toBe(1)
+  })
+
+  it('knows a 53-week year', () => {
+    expect(isoWeekNumber('2026-12-28')).toBe(53)
+    expect(isoWeekNumber('2027-01-04')).toBe(1)
+  })
+})
+
+describe('formatClock', () => {
+  it('pads a 24-hour clock', () => {
+    expect(formatClock(7 * 60, '24h')).toBe('07:00')
+    expect(formatClock(13 * 60 + 5, '24h')).toBe('13:05')
+  })
+
+  it('drops the minutes on the hour in 12-hour time, where the gutter is tight', () => {
+    expect(formatClock(7 * 60, '12h')).toBe('7 AM')
+    expect(formatClock(13 * 60, '12h')).toBe('1 PM')
+  })
+
+  it('keeps them off the hour', () => {
+    expect(formatClock(13 * 60 + 30, '12h')).toBe('1:30 PM')
+  })
+
+  it('calls the ends of the day midnight and noon', () => {
+    expect(formatClock(0, '12h')).toBe('12 AM')
+    expect(formatClock(12 * 60, '12h')).toBe('12 PM')
+    expect(formatClock(1440, '12h')).toBe('12 AM')
+  })
+})

+ 6 - 1
src/app/(authenticated)/layout.tsx

@@ -171,7 +171,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
                       isAdminOrOwner={isOwnerOrAdmin}
                       isAdminOrOwner={isOwnerOrAdmin}
                     />
                     />
                     <SidebarInset>
                     <SidebarInset>
-                      <div className="pb-14 md:pb-0">{children}</div>
+                      {/* A flex column with a real height, so the `flex-1` every
+                          page already writes on its wrapper actually resolves.
+                          Without it a page that wants to fill the window (the
+                          work board's week timeline) stopped at its content and
+                          left the rest of the screen blank. */}
+                      <div className="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">{children}</div>
                     </SidebarInset>
                     </SidebarInset>
                     <SearchCommand />
                     <SearchCommand />
                     {isOwnerOrAdmin && <NotificationInitializer />}
                     {isOwnerOrAdmin && <NotificationInitializer />}

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

@@ -61,7 +61,14 @@ export default async function NewServicePage({
   }
   }
 
 
   const boardTechId = query.boardTech
   const boardTechId = query.boardTech
-  const result = await createDraftServiceRecord(id, startDateTime, endDateTime, boardTechId)
+  const boardBayId = query.boardBay
+  const result = await createDraftServiceRecord(
+    id,
+    startDateTime,
+    endDateTime,
+    boardTechId,
+    boardBayId
+  )
 
 
   if (result.success && result.data?.id) {
   if (result.success && result.data?.id) {
     redirect(`/vehicles/${id}/service/${result.data.id}`)
     redirect(`/vehicles/${id}/service/${result.data.id}`)

+ 45 - 31
src/app/(authenticated)/work-board/page.tsx

@@ -1,33 +1,40 @@
-import { getTranslations } from "next-intl/server";
-import { PageHeader } from "@/components/page-header";
-import { getTechnicians } from "@/features/workboard/Actions/technicianActions";
-import { getBoardJobs, getUnassignedJobs, getWorkBoardSettings } from "@/features/workboard/Actions/boardActions";
-import { WorkBoardClient } from "@/features/workboard/Components/WorkBoardClient";
+import { getTranslations } from 'next-intl/server'
+import { PageHeader } from '@/components/page-header'
+import { getTechnicians } from '@/features/workboard/Actions/technicianActions'
+import { getWorkBays } from '@/features/workboard/Actions/workBayActions'
+import {
+  getBoardJobs,
+  getUnassignedJobs,
+  getWorkBoardSettings,
+} from '@/features/workboard/Actions/boardActions'
+import { WorkBoardClient } from '@/features/workboard/Components/WorkBoardClient'
 
 
 function getWeekStart(date: Date, weekStartDay: number): string {
 function getWeekStart(date: Date, weekStartDay: number): string {
-  const d = new Date(date);
-  const currentDay = d.getDay();
-  const diff = (currentDay - weekStartDay + 7) % 7;
-  d.setDate(d.getDate() - 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}`;
+  const d = new Date(date)
+  const currentDay = d.getDay()
+  const diff = (currentDay - weekStartDay + 7) % 7
+  d.setDate(d.getDate() - 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() {
 export default async function WorkBoardPage() {
-  const settingsResult = await getWorkBoardSettings();
-  const boardSettings = settingsResult.success && settingsResult.data
-    ? settingsResult.data
-    : { weekStartDay: 1, workDayStart: "07:00", workDayEnd: "15:00" };
+  const settingsResult = await getWorkBoardSettings()
+  const boardSettings =
+    settingsResult.success && settingsResult.data
+      ? settingsResult.data
+      : { weekStartDay: 1, workDayStart: '07:00', workDayEnd: '15:00' }
 
 
-  const weekStart = getWeekStart(new Date(), boardSettings.weekStartDay);
+  const weekStart = getWeekStart(new Date(), boardSettings.weekStartDay)
 
 
-  const [techResult, assignResult, unassignedResult] = await Promise.all([
+  const [techResult, bayResult, assignResult, unassignedResult] = await Promise.all([
     getTechnicians(),
     getTechnicians(),
+    getWorkBays(),
     getBoardJobs(weekStart),
     getBoardJobs(weekStart),
     getUnassignedJobs(),
     getUnassignedJobs(),
-  ]);
+  ])
 
 
   if (!techResult.success) {
   if (!techResult.success) {
     return (
     return (
@@ -35,31 +42,38 @@ export default async function WorkBoardPage() {
         <PageHeader />
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
         <div className="flex h-[50vh] items-center justify-center">
           <p className="text-muted-foreground">
           <p className="text-muted-foreground">
-            {techResult.error || (await getTranslations("workBoard.page"))("error")}
+            {techResult.error || (await getTranslations('workBoard.page'))('error')}
           </p>
           </p>
         </div>
         </div>
       </>
       </>
-    );
+    )
   }
   }
 
 
-  const technicians = techResult.data ?? [];
-  const assignments = assignResult.success && assignResult.data ? assignResult.data : [];
-  const unassigned = unassignedResult.success && unassignedResult.data
-    ? unassignedResult.data
-    : { serviceRecords: [], inspections: [] };
+  const technicians = techResult.data ?? []
+  const workBays = bayResult.success && bayResult.data ? bayResult.data : []
+  const assignments = assignResult.success && assignResult.data ? assignResult.data : []
+  const unassigned =
+    unassignedResult.success && unassignedResult.data
+      ? unassignedResult.data
+      : { serviceRecords: [], inspections: [] }
 
 
   return (
   return (
     <>
     <>
       <PageHeader />
       <PageHeader />
-      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+      <div className="flex min-h-0 flex-1 flex-col gap-4 p-4 pt-0">
         <WorkBoardClient
         <WorkBoardClient
-          initialTechnicians={technicians as Parameters<typeof WorkBoardClient>[0]["initialTechnicians"]}
-          initialAssignments={assignments as Parameters<typeof WorkBoardClient>[0]["initialAssignments"]}
+          initialTechnicians={
+            technicians as Parameters<typeof WorkBoardClient>[0]['initialTechnicians']
+          }
+          initialWorkBays={workBays}
+          initialAssignments={
+            assignments as Parameters<typeof WorkBoardClient>[0]['initialAssignments']
+          }
           initialUnassigned={unassigned}
           initialUnassigned={unassigned}
           initialWeekStart={weekStart}
           initialWeekStart={weekStart}
           boardSettings={boardSettings}
           boardSettings={boardSettings}
         />
         />
       </div>
       </div>
     </>
     </>
-  );
+  )
 }
 }

+ 36 - 32
src/app/(presenter)/work-board/presenter/page.tsx

@@ -1,70 +1,74 @@
-import { Suspense } from "react";
-import { getTranslations } from "next-intl/server";
-import { getTechnicians } from "@/features/workboard/Actions/technicianActions";
-import { getBoardJobs, getWorkBoardSettings } from "@/features/workboard/Actions/boardActions";
-import { WorkBoardPresenter } from "@/features/workboard/Components/WorkBoardPresenter";
+import { Suspense } from 'react'
+import { getTranslations } from 'next-intl/server'
+import { getTechnicians } from '@/features/workboard/Actions/technicianActions'
+import { getBoardJobs, getWorkBoardSettings } from '@/features/workboard/Actions/boardActions'
+import { getWorkBays } from '@/features/workboard/Actions/workBayActions'
+import { WorkBoardPresenter } from '@/features/workboard/Components/WorkBoardPresenter'
 
 
-export const dynamic = "force-dynamic";
+export const dynamic = 'force-dynamic'
 
 
 function getWeekStart(date: Date, weekStartDay: number): string {
 function getWeekStart(date: Date, weekStartDay: number): string {
-  const d = new Date(date);
-  const diff = (d.getDay() - weekStartDay + 7) % 7;
-  d.setDate(d.getDate() - 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}`;
+  const d = new Date(date)
+  const diff = (d.getDay() - weekStartDay + 7) % 7
+  d.setDate(d.getDate() - 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({
 export default async function WorkBoardPresenterPage({
   searchParams,
   searchParams,
 }: {
 }: {
-  searchParams: Promise<{ date?: string }>;
+  searchParams: Promise<{ date?: string }>
 }) {
 }) {
-  const params = await searchParams;
-  const baseDate = params.date ? new Date(params.date + "T12:00:00") : new Date();
+  const params = await searchParams
+  const baseDate = params.date ? new Date(params.date + 'T12:00:00') : new Date()
 
 
-  const techPromise = getTechnicians();
-  const settingsResult = await getWorkBoardSettings();
+  const techPromise = getTechnicians()
+  const settingsResult = await getWorkBoardSettings()
 
 
-  const boardSettings = settingsResult.success && settingsResult.data
-    ? settingsResult.data
-    : { weekStartDay: 1, workDayStart: "07:00", workDayEnd: "15:00" };
+  const boardSettings =
+    settingsResult.success && settingsResult.data
+      ? settingsResult.data
+      : { weekStartDay: 1, workDayStart: '07:00', workDayEnd: '15:00' }
 
 
-  const weekStart = getWeekStart(baseDate, boardSettings.weekStartDay);
-  const [techResult, assignResult] = await Promise.all([
+  const weekStart = getWeekStart(baseDate, boardSettings.weekStartDay)
+  const [techResult, assignResult, bayResult] = await Promise.all([
     techPromise,
     techPromise,
     getBoardJobs(weekStart),
     getBoardJobs(weekStart),
-  ]);
+    getWorkBays(),
+  ])
 
 
   if (!techResult.success) {
   if (!techResult.success) {
     return (
     return (
       <div className="flex h-screen items-center justify-center">
       <div className="flex h-screen items-center justify-center">
         <p className="text-muted-foreground text-lg">
         <p className="text-muted-foreground text-lg">
-          {techResult.error || (await getTranslations("workBoard.page"))("error")}
+          {techResult.error || (await getTranslations('workBoard.page'))('error')}
         </p>
         </p>
       </div>
       </div>
-    );
+    )
   }
   }
 
 
-  const technicians = techResult.data ?? [];
-  const assignments =
-    assignResult.success && assignResult.data ? assignResult.data : [];
+  const technicians = techResult.data ?? []
+  const workBays = bayResult.success && bayResult.data ? bayResult.data : []
+  const assignments = assignResult.success && assignResult.data ? assignResult.data : []
 
 
   return (
   return (
     <Suspense>
     <Suspense>
       <WorkBoardPresenter
       <WorkBoardPresenter
         initialTechnicians={
         initialTechnicians={
-          technicians as Parameters<typeof WorkBoardPresenter>[0]["initialTechnicians"]
+          technicians as Parameters<typeof WorkBoardPresenter>[0]['initialTechnicians']
         }
         }
         initialAssignments={
         initialAssignments={
-          assignments as Parameters<typeof WorkBoardPresenter>[0]["initialAssignments"]
+          assignments as Parameters<typeof WorkBoardPresenter>[0]['initialAssignments']
         }
         }
+        initialWorkBays={workBays}
         initialWeekStart={weekStart}
         initialWeekStart={weekStart}
         workDayStart={boardSettings.workDayStart}
         workDayStart={boardSettings.workDayStart}
         workDayEnd={boardSettings.workDayEnd}
         workDayEnd={boardSettings.workDayEnd}
         weekStartDay={boardSettings.weekStartDay}
         weekStartDay={boardSettings.weekStartDay}
       />
       />
     </Suspense>
     </Suspense>
-  );
+  )
 }
 }

+ 5 - 0
src/app/api/protected/backup/export/route.ts

@@ -198,6 +198,11 @@ export async function POST(request: NextRequest) {
         data.technicians = result
         data.technicians = result
       })
       })
     )
     )
+    queries.push(
+      db.workBay.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {
+        data.workBays = result
+      })
+    )
   }
   }
 
 
   if (options.inspections) {
   if (options.inspections) {

+ 21 - 0
src/app/api/protected/backup/import/route.ts

@@ -119,6 +119,7 @@ async function importServiceRecordTree(
       discountAmount: (sr.discountAmount as number) || 0,
       discountAmount: (sr.discountAmount as number) || 0,
       publicToken: (sr.publicToken as string) || null,
       publicToken: (sr.publicToken as string) || null,
       technicianId: (sr.technicianId as string) || null,
       technicianId: (sr.technicianId as string) || null,
+      workBayId: (sr.workBayId as string) || null,
       sortOrder: (sr.sortOrder as number) || 0,
       sortOrder: (sr.sortOrder as number) || 0,
       createdAt: toSafeDate(sr.createdAt as string),
       createdAt: toSafeDate(sr.createdAt as string),
       updatedAt: toSafeDate(sr.updatedAt as string),
       updatedAt: toSafeDate(sr.updatedAt as string),
@@ -359,6 +360,7 @@ export async function POST(request: NextRequest) {
           tx.customFieldDefinition.deleteMany({ where: { organizationId } }),
           tx.customFieldDefinition.deleteMany({ where: { organizationId } }),
         InventoryPart: () => tx.inventoryPart.deleteMany({ where: { organizationId } }),
         InventoryPart: () => tx.inventoryPart.deleteMany({ where: { organizationId } }),
         Technician: () => tx.technician.deleteMany({ where: { organizationId } }),
         Technician: () => tx.technician.deleteMany({ where: { organizationId } }),
+        WorkBay: () => tx.workBay.deleteMany({ where: { organizationId } }),
         Customer: () => tx.customer.deleteMany({ where: { organizationId } }),
         Customer: () => tx.customer.deleteMany({ where: { organizationId } }),
         LaborPreset: () => tx.laborPreset.deleteMany({ where: { organizationId } }),
         LaborPreset: () => tx.laborPreset.deleteMany({ where: { organizationId } }),
         Webhook: () => tx.webhook.deleteMany({ where: { organizationId } }),
         Webhook: () => tx.webhook.deleteMany({ where: { organizationId } }),
@@ -430,6 +432,24 @@ export async function POST(request: NextRequest) {
         })
         })
       }
       }
 
 
+      // 4b. Insert work bays. Service records and inspections point at them, so
+      // they have to exist before either is restored.
+      if (data.workBays?.length) {
+        await tx.workBay.createMany({
+          data: (data.workBays as Record<string, unknown>[]).map((b: Record<string, unknown>) => ({
+            id: b.id as string,
+            name: b.name as string,
+            color: (b.color as string) || '#64748b',
+            isActive: b.isActive !== false,
+            sortOrder: (b.sortOrder as number) || 0,
+            dailyCapacity: (b.dailyCapacity as number) || 480,
+            createdAt: toSafeDate(b.createdAt as string),
+            updatedAt: toSafeDate(b.updatedAt as string),
+            organizationId: ctx.organizationId,
+          })),
+        })
+      }
+
       // 5. Insert custom field definitions
       // 5. Insert custom field definitions
       if (data.customFieldDefinitions?.length) {
       if (data.customFieldDefinitions?.length) {
         for (const def of data.customFieldDefinitions as Record<string, unknown>[]) {
         for (const def of data.customFieldDefinitions as Record<string, unknown>[]) {
@@ -843,6 +863,7 @@ export async function POST(request: NextRequest) {
               vehicleId: insp.vehicleId as string,
               vehicleId: insp.vehicleId as string,
               templateId: insp.templateId as string,
               templateId: insp.templateId as string,
               technicianId: (insp.technicianId as string) || null,
               technicianId: (insp.technicianId as string) || null,
+              workBayId: (insp.workBayId as string) || null,
               organizationId: ctx.organizationId,
               organizationId: ctx.organizationId,
             },
             },
           })
           })

+ 7 - 2
src/features/vehicles/Actions/createDraftServiceRecord.ts

@@ -9,7 +9,8 @@ export async function createDraftServiceRecord(
   vehicleId: string,
   vehicleId: string,
   startDateTime?: Date,
   startDateTime?: Date,
   endDateTime?: Date,
   endDateTime?: Date,
-  technicianId?: string
+  technicianId?: string,
+  workBayId?: string
 ) {
 ) {
   return withAuth(
   return withAuth(
     async ({ organizationId, userId }) => {
     async ({ organizationId, userId }) => {
@@ -29,6 +30,7 @@ export async function createDraftServiceRecord(
           startDateTime,
           startDateTime,
           endDateTime,
           endDateTime,
           technicianId,
           technicianId,
+          workBayId,
         }
         }
       )
       )
     },
     },
@@ -80,7 +82,10 @@ export async function createDraftCounterSale(customerId: string) {
         action: 'service.create',
         action: 'service.create',
         entity: 'ServiceRecord',
         entity: 'ServiceRecord',
         entityId: result.id,
         entityId: result.id,
-        details: { key: 'service_createCounterSale', params: { ref: result.invoiceNumber || result.id } },
+        details: {
+          key: 'service_createCounterSale',
+          params: { ref: result.invoiceNumber || result.id },
+        },
         metadata: { serviceRecordId: result.id, customerId: result.customerId },
         metadata: { serviceRecordId: result.id, customerId: result.customerId },
       }),
       }),
     }
     }

+ 1 - 0
src/features/vehicles/Components/service-detail/types.ts

@@ -34,6 +34,7 @@ export interface ServiceDetail {
   shopName: string | null
   shopName: string | null
   techName: string | null
   techName: string | null
   technicianId: string | null
   technicianId: string | null
+  workBayId: string | null
   parts: string | null
   parts: string | null
   laborHours: number | null
   laborHours: number | null
   diagnosticNotes: string | null
   diagnosticNotes: string | null

+ 60 - 1
src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx

@@ -5,6 +5,13 @@ import { useTranslations } from 'next-intl'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
 import {
 import {
   Command,
   Command,
   CommandEmpty,
   CommandEmpty,
@@ -18,19 +25,31 @@ import { Input } from '@/components/ui/input'
 import { Check, ChevronsUpDown, Clock, Plus, Settings } from 'lucide-react'
 import { Check, ChevronsUpDown, Clock, Plus, Settings } from 'lucide-react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { DateTimePicker } from '@/components/ui/datetime-picker'
 import { DateTimePicker } from '@/components/ui/datetime-picker'
-import { assignTechnician, updateServiceTimes } from '@/features/workboard/Actions/boardActions'
+import {
+  assignTechnician,
+  scheduleJob,
+  updateServiceTimes,
+} from '@/features/workboard/Actions/boardActions'
 import { createTechnician } from '@/features/workboard/Actions/technicianActions'
 import { createTechnician } from '@/features/workboard/Actions/technicianActions'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import Link from 'next/link'
 import Link from 'next/link'
 
 
 const HOUR_PRESETS = [1, 2, 4, 5, 7]
 const HOUR_PRESETS = [1, 2, 4, 5, 7]
 
 
+/** Sentinel for the "no bay" option: a Select item cannot carry an empty value. */
+const NO_BAY = '__none__'
+
 interface Technician {
 interface Technician {
   id: string
   id: string
   name: string
   name: string
   userId?: string | null
   userId?: string | null
 }
 }
 
 
+interface WorkBay {
+  id: string
+  name: string
+}
+
 interface OrgMember {
 interface OrgMember {
   id: string
   id: string
   name: string | null
   name: string | null
@@ -40,24 +59,29 @@ interface OrgMember {
 interface ScheduleTimesSectionProps {
 interface ScheduleTimesSectionProps {
   serviceRecordId: string
   serviceRecordId: string
   technicians?: Technician[]
   technicians?: Technician[]
+  workBays?: WorkBay[]
   orgMembers?: OrgMember[]
   orgMembers?: OrgMember[]
   initialStartDateTime?: string | null
   initialStartDateTime?: string | null
   initialEndDateTime?: string | null
   initialEndDateTime?: string | null
   initialTechnicianId?: string | null
   initialTechnicianId?: string | null
+  initialWorkBayId?: string | null
   onSaved?: () => void
   onSaved?: () => void
 }
 }
 
 
 export function ScheduleTimesSection({
 export function ScheduleTimesSection({
   serviceRecordId,
   serviceRecordId,
   technicians: initialTechnicians = [],
   technicians: initialTechnicians = [],
+  workBays = [],
   orgMembers = [],
   orgMembers = [],
   initialStartDateTime,
   initialStartDateTime,
   initialEndDateTime,
   initialEndDateTime,
   initialTechnicianId,
   initialTechnicianId,
+  initialWorkBayId,
   onSaved,
   onSaved,
 }: ScheduleTimesSectionProps) {
 }: ScheduleTimesSectionProps) {
   const t = useTranslations('service.schedule')
   const t = useTranslations('service.schedule')
   const [selectedTechId, setSelectedTechId] = useState(initialTechnicianId || '')
   const [selectedTechId, setSelectedTechId] = useState(initialTechnicianId || '')
+  const [selectedBayId, setSelectedBayId] = useState(initialWorkBayId || NO_BAY)
   const [techOpen, setTechOpen] = useState(false)
   const [techOpen, setTechOpen] = useState(false)
   const [technicians, setTechnicians] = useState<Technician[]>(initialTechnicians)
   const [technicians, setTechnicians] = useState<Technician[]>(initialTechnicians)
   const [techSearch, setTechSearch] = useState('')
   const [techSearch, setTechSearch] = useState('')
@@ -152,6 +176,22 @@ export function ScheduleTimesSection({
     }
     }
   }
   }
 
 
+  const handleBaySelect = async (bayId: string) => {
+    const previous = selectedBayId
+    setSelectedBayId(bayId)
+    const res = await scheduleJob({
+      id: serviceRecordId,
+      type: 'serviceRecord',
+      workBayId: bayId === NO_BAY ? null : bayId,
+    })
+    if (res.success) {
+      onSaved?.()
+    } else {
+      toast.error(res.error || t('failedUpdate'))
+      setSelectedBayId(previous)
+    }
+  }
+
   const selectedTechName = technicians.find((t) => t.id === selectedTechId)?.name
   const selectedTechName = technicians.find((t) => t.id === selectedTechId)?.name
 
 
   // Platform users that already have a linked technician
   // Platform users that already have a linked technician
@@ -337,6 +377,25 @@ export function ScheduleTimesSection({
         </Popover>
         </Popover>
       </div>
       </div>
 
 
+      {workBays.length > 0 && (
+        <div className="space-y-1">
+          <Label className="text-xs">{t('workBay')}</Label>
+          <Select value={selectedBayId} onValueChange={handleBaySelect}>
+            <SelectTrigger className="w-full">
+              <SelectValue placeholder={t('selectWorkBay')} />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value={NO_BAY}>{t('noWorkBay')}</SelectItem>
+              {workBays.map((bay) => (
+                <SelectItem key={bay.id} value={bay.id}>
+                  {bay.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+      )}
+
       <div className="space-y-1.5">
       <div className="space-y-1.5">
         <Label className="text-xs">{t('startTime')}</Label>
         <Label className="text-xs">{t('startTime')}</Label>
         {mounted ? (
         {mounted ? (

+ 5 - 1
src/features/vehicles/Components/service-page/DetailsRightColumn.tsx

@@ -13,7 +13,7 @@ import { revokePublicLink } from '@/features/vehicles/Actions/serviceActions'
 import type { useServiceFormState } from './useServiceFormState'
 import type { useServiceFormState } from './useServiceFormState'
 import type { useServiceActions } from './useServiceActions'
 import type { useServiceActions } from './useServiceActions'
 import type { ServiceDetail } from '../service-detail/types'
 import type { ServiceDetail } from '../service-detail/types'
-import type { BoardTechnicianOption, OrgMemberOption } from './service-page-types'
+import type { BoardTechnicianOption, OrgMemberOption, WorkBayOption } from './service-page-types'
 
 
 interface DetailsRightColumnProps {
 interface DetailsRightColumnProps {
   formState: ReturnType<typeof useServiceFormState>
   formState: ReturnType<typeof useServiceFormState>
@@ -31,6 +31,7 @@ interface DetailsRightColumnProps {
     licensePlate: string | null
     licensePlate: string | null
   } | null
   } | null
   boardTechnicians: BoardTechnicianOption[]
   boardTechnicians: BoardTechnicianOption[]
+  workBays?: WorkBayOption[]
   orgMembers?: OrgMemberOption[]
   orgMembers?: OrgMemberOption[]
   notificationHistory?: {
   notificationHistory?: {
     id: string
     id: string
@@ -51,6 +52,7 @@ export function DetailsRightColumn({
   taxEnabled,
   taxEnabled,
   initialVehicle,
   initialVehicle,
   boardTechnicians,
   boardTechnicians,
+  workBays,
   orgMembers,
   orgMembers,
   notificationHistory = [],
   notificationHistory = [],
 }: DetailsRightColumnProps) {
 }: DetailsRightColumnProps) {
@@ -97,10 +99,12 @@ export function DetailsRightColumn({
       <ScheduleTimesSection
       <ScheduleTimesSection
         serviceRecordId={record.id}
         serviceRecordId={record.id}
         technicians={boardTechnicians}
         technicians={boardTechnicians}
+        workBays={workBays}
         orgMembers={orgMembers}
         orgMembers={orgMembers}
         initialStartDateTime={formState.initialData.startDateTime}
         initialStartDateTime={formState.initialData.startDateTime}
         initialEndDateTime={formState.initialData.endDateTime}
         initialEndDateTime={formState.initialData.endDateTime}
         initialTechnicianId={record.technicianId}
         initialTechnicianId={record.technicianId}
+        initialWorkBayId={record.workBayId}
         onSaved={formState.flashSaved}
         onSaved={formState.flashSaved}
       />
       />
       <TotalsSection
       <TotalsSection

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

@@ -66,6 +66,7 @@ export function ServicePageClient({
   inventoryParts,
   inventoryParts,
   initialVehicle,
   initialVehicle,
   boardTechnicians = [],
   boardTechnicians = [],
+  workBays = [],
   orgMembers = [],
   orgMembers = [],
   currentUserName,
   currentUserName,
   imageAttachmentsForManager,
   imageAttachmentsForManager,
@@ -379,6 +380,7 @@ export function ServicePageClient({
                   taxEnabled={taxEnabled}
                   taxEnabled={taxEnabled}
                   initialVehicle={initialVehicle}
                   initialVehicle={initialVehicle}
                   boardTechnicians={boardTechnicians}
                   boardTechnicians={boardTechnicians}
+                  workBays={workBays}
                   orgMembers={orgMembers}
                   orgMembers={orgMembers}
                   notificationHistory={notificationHistory}
                   notificationHistory={notificationHistory}
                 />
                 />

+ 7 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -1,4 +1,5 @@
 import { getServiceRecord } from '@/features/vehicles/Actions/serviceActions'
 import { getServiceRecord } from '@/features/vehicles/Actions/serviceActions'
+import { getWorkBays } from '@/features/workboard/Actions/workBayActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { readPartsPricingSettings } from '@/features/inventory/Lib/partPricing'
 import { readPartsPricingSettings } from '@/features/inventory/Lib/partPricing'
@@ -40,6 +41,7 @@ export async function ServiceRecordPage({
     orgMembersResult,
     orgMembersResult,
     statusReportsResult,
     statusReportsResult,
     findingsResult,
     findingsResult,
+    workBaysResult,
   ] = await Promise.all([
   ] = await Promise.all([
     getServiceRecord(serviceId),
     getServiceRecord(serviceId),
     getSettings([
     getSettings([
@@ -60,6 +62,7 @@ export async function ServiceRecordPage({
     getOrgMembers(),
     getOrgMembers(),
     getStatusReportsForService(serviceId),
     getStatusReportsForService(serviceId),
     getServiceFindings(serviceId),
     getServiceFindings(serviceId),
+    getWorkBays(),
   ])
   ])
 
 
   if (!result.success || !result.data) {
   if (!result.success || !result.data) {
@@ -102,6 +105,9 @@ export async function ServiceRecordPage({
   const boardTechnicians = (
   const boardTechnicians = (
     techniciansResult.success && techniciansResult.data ? techniciansResult.data : []
     techniciansResult.success && techniciansResult.data ? techniciansResult.data : []
   ).map((t) => ({ id: t.id, name: t.name, userId: t.userId }))
   ).map((t) => ({ id: t.id, name: t.name, userId: t.userId }))
+  const workBays = (workBaysResult.success && workBaysResult.data ? workBaysResult.data : []).map(
+    (b) => ({ id: b.id, name: b.name })
+  )
   const organizationId = authContext?.organizationId || ''
   const organizationId = authContext?.organizationId || ''
 
 
   // Fetch team members and features
   // Fetch team members and features
@@ -258,6 +264,7 @@ export async function ServiceRecordPage({
         laborPresets={laborPresets}
         laborPresets={laborPresets}
         initialVehicle={initialVehicle}
         initialVehicle={initialVehicle}
         boardTechnicians={boardTechnicians}
         boardTechnicians={boardTechnicians}
+        workBays={workBays}
         orgMembers={orgMembersResult.success && orgMembersResult.data ? orgMembersResult.data : []}
         orgMembers={orgMembersResult.success && orgMembersResult.data ? orgMembersResult.data : []}
         currentUserName={currentUserName}
         currentUserName={currentUserName}
         imageAttachmentsForManager={imageAttachmentsForManager}
         imageAttachmentsForManager={imageAttachmentsForManager}

+ 6 - 0
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -9,6 +9,11 @@ export interface BoardTechnicianOption {
   userId?: string | null
   userId?: string | null
 }
 }
 
 
+export interface WorkBayOption {
+  id: string
+  name: string
+}
+
 export interface OrgMemberOption {
 export interface OrgMemberOption {
   id: string
   id: string
   name: string | null
   name: string | null
@@ -46,6 +51,7 @@ export interface ServicePageClientProps {
     licensePlate: string | null
     licensePlate: string | null
   } | null
   } | null
   boardTechnicians?: BoardTechnicianOption[]
   boardTechnicians?: BoardTechnicianOption[]
+  workBays?: WorkBayOption[]
   orgMembers?: OrgMemberOption[]
   orgMembers?: OrgMemberOption[]
   currentUserName: string
   currentUserName: string
   imageAttachmentsForManager: Attachment[]
   imageAttachmentsForManager: Attachment[]

+ 3 - 0
src/features/vehicles/Lib/createDraftRecord.ts

@@ -15,6 +15,8 @@ export async function createDraftRecord(
     startDateTime?: Date
     startDateTime?: Date
     endDateTime?: Date
     endDateTime?: Date
     technicianId?: string
     technicianId?: string
+    /** Bay the job was booked into, when it was created from the work board. */
+    workBayId?: string
   }
   }
 ) {
 ) {
   const [settings, org, currentUser] = await Promise.all([
   const [settings, org, currentUser] = await Promise.all([
@@ -149,6 +151,7 @@ export async function createDraftRecord(
       shopName,
       shopName,
       techName,
       techName,
       technicianId: resolvedTechId || undefined,
       technicianId: resolvedTechId || undefined,
+      workBayId: opts.workBayId || undefined,
       invoiceNumber,
       invoiceNumber,
       taxRate: defaultTaxRate,
       taxRate: defaultTaxRate,
       taxInclusive,
       taxInclusive,

+ 138 - 237
src/features/workboard/Actions/boardActions/assignments.ts

@@ -1,154 +1,85 @@
-"use server";
+'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 { 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 {
 import {
   assignTechnicianSchema,
   assignTechnicianSchema,
   moveJobSchema,
   moveJobSchema,
   unassignJobSchema,
   unassignJobSchema,
-} from "../../Schema/workboardSchema";
-import type { WorkBoardJob, WorkBoardSettings } from "./types";
-
-function serviceRecordToJob(sr: {
-  id: string;
-  title: string;
-  status: string;
-  startDateTime: Date | null;
-  endDateTime: Date | null;
-  technicianId: string | null;
-  sortOrder: number;
-  vehicle: {
-    id: string;
-    make: string;
-    model: string;
-    year: number;
-    licensePlate: string | null;
-  } | null;
-}): WorkBoardJob {
-  return {
-    id: sr.id,
-    type: "serviceRecord",
-    technicianId: sr.technicianId,
-    sortOrder: sr.sortOrder,
-    title: sr.title,
-    status: sr.status,
-    startDateTime: sr.startDateTime?.toISOString() ?? null,
-    endDateTime: sr.endDateTime?.toISOString() ?? null,
-    vehicle: sr.vehicle,
-  };
-}
-
-function inspectionToJob(insp: {
-  id: string;
-  status: string;
-  startDateTime: Date | null;
-  endDateTime: Date | null;
-  technicianId: string | null;
-  sortOrder: number;
-  vehicle: {
-    id: string;
-    make: string;
-    model: string;
-    year: number;
-    licensePlate: string | null;
-  };
-  template: { name: string };
-}): WorkBoardJob {
-  return {
-    id: insp.id,
-    type: "inspection",
-    technicianId: insp.technicianId,
-    sortOrder: insp.sortOrder,
-    title: insp.template.name,
-    status: insp.status,
-    startDateTime: insp.startDateTime?.toISOString() ?? null,
-    endDateTime: insp.endDateTime?.toISOString() ?? null,
-    vehicle: insp.vehicle,
-    templateName: insp.template.name,
-  };
-}
-
-const VEHICLE_SELECT = {
-  id: true,
-  make: true,
-  model: true,
-  year: true,
-  licensePlate: true,
-} as const;
+} from '../../Schema/workboardSchema'
+import {
+  INSPECTION_JOB_SELECT,
+  ON_BOARD,
+  SERVICE_JOB_SELECT,
+  VEHICLE_SELECT,
+  inspectionToJob,
+  serviceRecordToJob,
+} from './mappers'
+import type { WorkBoardJob, WorkBoardSettings } from './types'
 
 
 export async function getBoardJobs(weekStart: string) {
 export async function getBoardJobs(weekStart: string) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      const start = new Date(weekStart);
-      const end = new Date(start);
-      end.setDate(end.getDate() + 7);
+      const start = new Date(weekStart)
+      const end = new Date(start)
+      end.setDate(end.getDate() + 7)
 
 
       const [serviceRecords, inspections] = await Promise.all([
       const [serviceRecords, inspections] = await Promise.all([
         db.serviceRecord.findMany({
         db.serviceRecord.findMany({
           where: {
           where: {
             organizationId,
             organizationId,
-            technicianId: { not: null },
-            OR: [
-              { startDateTime: { gte: start, lt: end } },
-              { endDateTime: { gt: start, lte: end } },
-              { startDateTime: { lte: start }, endDateTime: { gte: end } },
-              { startDateTime: null },
+            AND: [
+              ON_BOARD,
+              {
+                OR: [
+                  { startDateTime: { gte: start, lt: end } },
+                  { endDateTime: { gt: start, lte: end } },
+                  { startDateTime: { lte: start }, endDateTime: { gte: end } },
+                  { startDateTime: null },
+                ],
+              },
             ],
             ],
           },
           },
-          select: {
-            id: true,
-            title: true,
-            status: true,
-            startDateTime: true,
-            endDateTime: true,
-            technicianId: true,
-            sortOrder: true,
-            vehicle: { select: VEHICLE_SELECT },
-          },
-          orderBy: { sortOrder: "asc" },
+          select: SERVICE_JOB_SELECT,
+          orderBy: { sortOrder: 'asc' },
         }),
         }),
         db.inspection.findMany({
         db.inspection.findMany({
           where: {
           where: {
             organizationId,
             organizationId,
-            technicianId: { not: null },
-            OR: [
-              { startDateTime: { gte: start, lt: end } },
-              { endDateTime: { gt: start, lte: end } },
-              { startDateTime: { lte: start }, endDateTime: { gte: end } },
-              { startDateTime: null },
+            AND: [
+              ON_BOARD,
+              {
+                OR: [
+                  { startDateTime: { gte: start, lt: end } },
+                  { endDateTime: { gt: start, lte: end } },
+                  { startDateTime: { lte: start }, endDateTime: { gte: end } },
+                  { startDateTime: null },
+                ],
+              },
             ],
             ],
           },
           },
-          select: {
-            id: true,
-            status: true,
-            startDateTime: true,
-            endDateTime: true,
-            technicianId: true,
-            sortOrder: true,
-            vehicle: { select: VEHICLE_SELECT },
-            template: { select: { name: true } },
-          },
-          orderBy: { sortOrder: "asc" },
+          select: INSPECTION_JOB_SELECT,
+          orderBy: { sortOrder: 'asc' },
         }),
         }),
-      ]);
+      ])
 
 
       const jobs: WorkBoardJob[] = [
       const jobs: WorkBoardJob[] = [
         ...serviceRecords.map(serviceRecordToJob),
         ...serviceRecords.map(serviceRecordToJob),
         ...inspections.map(inspectionToJob),
         ...inspections.map(inspectionToJob),
-      ];
-      jobs.sort((a, b) => a.sortOrder - b.sortOrder);
+      ]
+      jobs.sort((a, b) => a.sortOrder - b.sortOrder)
 
 
-      return jobs;
+      return jobs
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
         { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function getUnassignedJobs() {
 export async function getUnassignedJobs() {
@@ -159,7 +90,8 @@ export async function getUnassignedJobs() {
           where: {
           where: {
             organizationId,
             organizationId,
             technicianId: null,
             technicianId: null,
-            status: { in: ["pending", "in-progress", "waiting-parts", "scheduled"] },
+            workBayId: null,
+            status: { in: ['pending', 'in-progress', 'waiting-parts', 'scheduled'] },
           },
           },
           select: {
           select: {
             id: true,
             id: true,
@@ -167,14 +99,15 @@ export async function getUnassignedJobs() {
             status: true,
             status: true,
             vehicle: { select: VEHICLE_SELECT },
             vehicle: { select: VEHICLE_SELECT },
           },
           },
-          orderBy: { createdAt: "desc" },
+          orderBy: { createdAt: 'desc' },
           take: 100,
           take: 100,
         }),
         }),
         db.inspection.findMany({
         db.inspection.findMany({
           where: {
           where: {
             organizationId,
             organizationId,
             technicianId: null,
             technicianId: null,
-            status: { in: ["in_progress", "pending"] },
+            workBayId: null,
+            status: { in: ['in_progress', 'pending'] },
           },
           },
           select: {
           select: {
             id: true,
             id: true,
@@ -182,118 +115,101 @@ export async function getUnassignedJobs() {
             vehicle: { select: VEHICLE_SELECT },
             vehicle: { select: VEHICLE_SELECT },
             template: { select: { name: true } },
             template: { select: { name: true } },
           },
           },
-          orderBy: { createdAt: "desc" },
+          orderBy: { createdAt: 'desc' },
           take: 100,
           take: 100,
         }),
         }),
-      ]);
+      ])
 
 
-      return { serviceRecords, inspections };
+      return { serviceRecords, inspections }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
         { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function assignTechnician(input: unknown) {
 export async function assignTechnician(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      const data = assignTechnicianSchema.parse(input);
+      const data = assignTechnicianSchema.parse(input)
 
 
       const tech = await db.technician.findFirst({
       const tech = await db.technician.findFirst({
         where: { id: data.technicianId, organizationId },
         where: { id: data.technicianId, organizationId },
-      });
-      if (!tech) throw new Error("Technician not found");
+      })
+      if (!tech) throw new Error('Technician not found')
 
 
-      let job: WorkBoardJob;
+      let job: WorkBoardJob
 
 
-      const timeData = data.startDateTime && data.endDateTime
-        ? { startDateTime: data.startDateTime, endDateTime: data.endDateTime }
-        : {};
+      const timeData =
+        data.startDateTime && data.endDateTime
+          ? { startDateTime: data.startDateTime, endDateTime: data.endDateTime }
+          : {}
 
 
-      if (data.type === "serviceRecord") {
+      if (data.type === 'serviceRecord') {
         const owned = await db.serviceRecord.findFirst({
         const owned = await db.serviceRecord.findFirst({
           where: { id: data.id, organizationId },
           where: { id: data.id, organizationId },
           select: { id: true },
           select: { id: true },
-        });
-        if (!owned) throw new Error("Service record not found");
+        })
+        if (!owned) throw new Error('Service record not found')
 
 
         const sr = await db.serviceRecord.update({
         const sr = await db.serviceRecord.update({
           where: { id: data.id },
           where: { id: data.id },
           data: { technicianId: data.technicianId, techName: tech.name, ...timeData },
           data: { technicianId: data.technicianId, techName: tech.name, ...timeData },
-          select: {
-            id: true,
-            title: true,
-            status: true,
-            startDateTime: true,
-            endDateTime: true,
-            technicianId: true,
-            sortOrder: true,
-            vehicle: { select: VEHICLE_SELECT },
-          },
-        });
-        job = serviceRecordToJob(sr);
+          select: SERVICE_JOB_SELECT,
+        })
+        job = serviceRecordToJob(sr)
       } else {
       } else {
         const owned = await db.inspection.findFirst({
         const owned = await db.inspection.findFirst({
           where: { id: data.id, organizationId },
           where: { id: data.id, organizationId },
           select: { id: true },
           select: { id: true },
-        });
-        if (!owned) throw new Error("Inspection not found");
+        })
+        if (!owned) throw new Error('Inspection not found')
 
 
         const insp = await db.inspection.update({
         const insp = await db.inspection.update({
           where: { id: data.id },
           where: { id: data.id },
           data: { technicianId: data.technicianId, ...timeData },
           data: { technicianId: data.technicianId, ...timeData },
-          select: {
-            id: true,
-            status: true,
-            startDateTime: true,
-            endDateTime: true,
-            technicianId: true,
-            sortOrder: true,
-            vehicle: { select: VEHICLE_SELECT },
-            template: { select: { name: true } },
-          },
-        });
-        job = inspectionToJob(insp);
+          select: INSPECTION_JOB_SELECT,
+        })
+        job = inspectionToJob(insp)
       }
       }
 
 
-      notificationBus.emit("workboard", {
-        type: "job_assigned",
+      notificationBus.emit('workboard', {
+        type: 'job_assigned',
         organizationId,
         organizationId,
         job,
         job,
-      });
+      })
 
 
-      revalidatePath("/work-board");
-      return job;
+      revalidatePath('/work-board')
+      return job
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.CREATE, subject: PermissionSubject.WORK_BOARD },
         { action: PermissionAction.CREATE, subject: PermissionSubject.WORK_BOARD },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function moveJob(input: unknown) {
 export async function moveJob(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      const data = moveJobSchema.parse(input);
+      const data = moveJobSchema.parse(input)
 
 
       const tech = await db.technician.findFirst({
       const tech = await db.technician.findFirst({
         where: { id: data.technicianId, organizationId },
         where: { id: data.technicianId, organizationId },
-      });
-      if (!tech) throw new Error("Technician not found");
+      })
+      if (!tech) throw new Error('Technician not found')
 
 
-      let job: WorkBoardJob;
+      let job: WorkBoardJob
 
 
-      if (data.type === "serviceRecord") {
+      if (data.type === 'serviceRecord') {
         const owned = await db.serviceRecord.findFirst({
         const owned = await db.serviceRecord.findFirst({
           where: { id: data.id, organizationId },
           where: { id: data.id, organizationId },
           select: { id: true },
           select: { id: true },
-        });
-        if (!owned) throw new Error("Service record not found");
+        })
+        if (!owned) throw new Error('Service record not found')
 
 
         const sr = await db.serviceRecord.update({
         const sr = await db.serviceRecord.update({
           where: { id: data.id },
           where: { id: data.id },
@@ -302,24 +218,15 @@ export async function moveJob(input: unknown) {
             sortOrder: data.sortOrder,
             sortOrder: data.sortOrder,
             techName: tech.name,
             techName: tech.name,
           },
           },
-          select: {
-            id: true,
-            title: true,
-            status: true,
-            startDateTime: true,
-            endDateTime: true,
-            technicianId: true,
-            sortOrder: true,
-            vehicle: { select: VEHICLE_SELECT },
-          },
-        });
-        job = serviceRecordToJob(sr);
+          select: SERVICE_JOB_SELECT,
+        })
+        job = serviceRecordToJob(sr)
       } else {
       } else {
         const owned = await db.inspection.findFirst({
         const owned = await db.inspection.findFirst({
           where: { id: data.id, organizationId },
           where: { id: data.id, organizationId },
           select: { id: true },
           select: { id: true },
-        });
-        if (!owned) throw new Error("Inspection not found");
+        })
+        if (!owned) throw new Error('Inspection not found')
 
 
         const insp = await db.inspection.update({
         const insp = await db.inspection.update({
           where: { id: data.id },
           where: { id: data.id },
@@ -327,80 +234,71 @@ export async function moveJob(input: unknown) {
             technicianId: data.technicianId,
             technicianId: data.technicianId,
             sortOrder: data.sortOrder,
             sortOrder: data.sortOrder,
           },
           },
-          select: {
-            id: true,
-            status: true,
-            startDateTime: true,
-            endDateTime: true,
-            technicianId: true,
-            sortOrder: true,
-            vehicle: { select: VEHICLE_SELECT },
-            template: { select: { name: true } },
-          },
-        });
-        job = inspectionToJob(insp);
+          select: INSPECTION_JOB_SELECT,
+        })
+        job = inspectionToJob(insp)
       }
       }
 
 
-      notificationBus.emit("workboard", {
-        type: "job_moved",
+      notificationBus.emit('workboard', {
+        type: 'job_moved',
         organizationId,
         organizationId,
         job,
         job,
-      });
+      })
 
 
-      revalidatePath("/work-board");
-      return job;
+      revalidatePath('/work-board')
+      return job
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function unassignJob(input: unknown) {
 export async function unassignJob(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      const data = unassignJobSchema.parse(input);
+      const data = unassignJobSchema.parse(input)
 
 
-      if (data.type === "serviceRecord") {
+      if (data.type === 'serviceRecord') {
         const sr = await db.serviceRecord.findFirst({
         const sr = await db.serviceRecord.findFirst({
           where: { id: data.id, organizationId },
           where: { id: data.id, organizationId },
-        });
-        if (!sr) throw new Error("Service record not found");
+        })
+        if (!sr) throw new Error('Service record not found')
 
 
         await db.serviceRecord.update({
         await db.serviceRecord.update({
           where: { id: data.id },
           where: { id: data.id },
-          data: { technicianId: null, techName: null, sortOrder: 0 },
-        });
+          data: { technicianId: null, techName: null, workBayId: null, sortOrder: 0 },
+        })
       } else {
       } else {
         const insp = await db.inspection.findFirst({
         const insp = await db.inspection.findFirst({
           where: { id: data.id, organizationId },
           where: { id: data.id, organizationId },
-        });
-        if (!insp) throw new Error("Inspection not found");
+        })
+        if (!insp) throw new Error('Inspection not found')
 
 
         await db.inspection.update({
         await db.inspection.update({
           where: { id: data.id },
           where: { id: data.id },
-          data: { technicianId: null, sortOrder: 0 },
-        });
+          data: { technicianId: null, workBayId: null, sortOrder: 0 },
+        })
       }
       }
 
 
-      notificationBus.emit("workboard", {
-        type: "job_unassigned",
+      notificationBus.emit('workboard', {
+        type: 'job_unassigned',
         organizationId,
         organizationId,
         jobId: data.id,
         jobId: data.id,
         jobType: data.type,
         jobType: data.type,
-      });
+      })
 
 
-      revalidatePath("/work-board");
-      return { success: true };
+      revalidatePath('/work-board')
+      return { success: true }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.DELETE, subject: PermissionSubject.WORK_BOARD },
         { action: PermissionAction.DELETE, subject: PermissionSubject.WORK_BOARD },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function getWorkBoardSettings() {
 export async function getWorkBoardSettings() {
@@ -409,28 +307,31 @@ export async function getWorkBoardSettings() {
       const settings = await db.appSetting.findMany({
       const settings = await db.appSetting.findMany({
         where: {
         where: {
           organizationId,
           organizationId,
-          key: { in: ["workboard.weekStartDay", "workboard.workDayStart", "workboard.workDayEnd"] },
+          key: { in: ['workboard.weekStartDay', 'workboard.workDayStart', 'workboard.workDayEnd'] },
         },
         },
-      });
+      })
 
 
-      const map: Record<string, string> = {};
+      const map: Record<string, string> = {}
       for (const s of settings) {
       for (const s of settings) {
-        map[s.key] = s.value;
+        map[s.key] = s.value
       }
       }
 
 
       // Clamp so a corrupt stored value can never produce NaN week starts downstream
       // Clamp so a corrupt stored value can never produce NaN week starts downstream
-      const parsedWeekStart = parseInt(map["workboard.weekStartDay"] || "1", 10);
+      const parsedWeekStart = parseInt(map['workboard.weekStartDay'] || '1', 10)
 
 
       return {
       return {
-        weekStartDay: Number.isInteger(parsedWeekStart) && parsedWeekStart >= 0 && parsedWeekStart <= 6 ? parsedWeekStart : 1,
-        workDayStart: map["workboard.workDayStart"] || "07:00",
-        workDayEnd: map["workboard.workDayEnd"] || "15:00",
-      } as WorkBoardSettings;
+        weekStartDay:
+          Number.isInteger(parsedWeekStart) && parsedWeekStart >= 0 && parsedWeekStart <= 6
+            ? parsedWeekStart
+            : 1,
+        workDayStart: map['workboard.workDayStart'] || '07:00',
+        workDayEnd: map['workboard.workDayEnd'] || '15:00',
+      } as WorkBoardSettings
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
         { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }

+ 5 - 5
src/features/workboard/Actions/boardActions/index.ts

@@ -1,11 +1,11 @@
-export type { WorkBoardJob, WorkBoardSettings } from "./types";
-export { getWorkBoardSettings } from "./assignments";
+export type { WorkBay, WorkBoardJob, WorkBoardSettings } from './types'
+export { getWorkBoardSettings } from './assignments'
 export {
 export {
   getBoardJobs,
   getBoardJobs,
   getUnassignedJobs,
   getUnassignedJobs,
   assignTechnician,
   assignTechnician,
   moveJob,
   moveJob,
   unassignJob,
   unassignJob,
-} from "./assignments";
-export { updateServiceTimes, updateInspectionTimes } from "./scheduling";
-export { getServiceRecordTechnician } from "./queries";
+} from './assignments'
+export { scheduleJob, updateServiceTimes, updateInspectionTimes } from './scheduling'
+export { getServiceRecordTechnician } from './queries'

+ 121 - 0
src/features/workboard/Actions/boardActions/mappers.ts

@@ -0,0 +1,121 @@
+/**
+ * Row shapes and row-to-job mapping for the work board.
+ *
+ * Kept out of the "use server" action files: those may only export async
+ * functions, and the selects and mappers below are shared by all of them.
+ */
+
+import type { InspectionSelect, ServiceRecordSelect } from '@/generated/prisma/models'
+import type { WorkBoardJob } from './types'
+
+export const VEHICLE_SELECT = {
+  id: true,
+  make: true,
+  model: true,
+  year: true,
+  licensePlate: true,
+  customer: { select: { name: true } },
+} as const
+
+export function serviceRecordToJob(sr: {
+  id: string
+  title: string
+  status: string
+  startDateTime: Date | null
+  endDateTime: Date | null
+  technicianId: string | null
+  workBayId: string | null
+  sortOrder: number
+  vehicle: {
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+    customer: { name: string } | null
+  } | null
+  customer: { name: string } | null
+}): WorkBoardJob {
+  return {
+    id: sr.id,
+    type: 'serviceRecord',
+    technicianId: sr.technicianId,
+    workBayId: sr.workBayId,
+    sortOrder: sr.sortOrder,
+    title: sr.title,
+    status: sr.status,
+    startDateTime: sr.startDateTime?.toISOString() ?? null,
+    endDateTime: sr.endDateTime?.toISOString() ?? null,
+    vehicle: sr.vehicle,
+    // Counter sales carry the customer directly; everything else reaches it
+    // through the vehicle, so that a reassigned vehicle takes its owner along.
+    customerName: sr.customer?.name ?? sr.vehicle?.customer?.name ?? null,
+  }
+}
+
+export function inspectionToJob(insp: {
+  id: string
+  status: string
+  startDateTime: Date | null
+  endDateTime: Date | null
+  technicianId: string | null
+  workBayId: string | null
+  sortOrder: number
+  vehicle: {
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+    customer: { name: string } | null
+  }
+  template: { name: string }
+}): WorkBoardJob {
+  return {
+    id: insp.id,
+    type: 'inspection',
+    technicianId: insp.technicianId,
+    workBayId: insp.workBayId,
+    sortOrder: insp.sortOrder,
+    title: insp.template.name,
+    status: insp.status,
+    startDateTime: insp.startDateTime?.toISOString() ?? null,
+    endDateTime: insp.endDateTime?.toISOString() ?? null,
+    vehicle: insp.vehicle,
+    customerName: insp.vehicle.customer?.name ?? null,
+    templateName: insp.template.name,
+  }
+}
+
+/** Everything `serviceRecordToJob` needs, in one place so the four queries that
+ * return a board job cannot drift apart. */
+export const SERVICE_JOB_SELECT = {
+  id: true,
+  title: true,
+  status: true,
+  customer: { select: { name: true } },
+  startDateTime: true,
+  endDateTime: true,
+  technicianId: true,
+  workBayId: true,
+  sortOrder: true,
+  vehicle: { select: VEHICLE_SELECT },
+} satisfies ServiceRecordSelect
+
+export const INSPECTION_JOB_SELECT = {
+  id: true,
+  status: true,
+  startDateTime: true,
+  endDateTime: true,
+  technicianId: true,
+  workBayId: true,
+  sortOrder: true,
+  vehicle: { select: VEHICLE_SELECT },
+  template: { select: { name: true } },
+} satisfies InspectionSelect
+
+/** A job shows on the board once it has a lane, whichever grouping that lane
+ * belongs to. Bay-only jobs are planned work too. */
+export const ON_BOARD = {
+  OR: [{ technicianId: { not: null } }, { workBayId: { not: null } }],
+}

+ 138 - 31
src/features/workboard/Actions/boardActions/scheduling.ts

@@ -1,25 +1,32 @@
-"use server";
+'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 { updateServiceTimesSchema } from "../../Schema/workboardSchema";
+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 { scheduleJobSchema, updateServiceTimesSchema } from '../../Schema/workboardSchema'
+import {
+  INSPECTION_JOB_SELECT,
+  SERVICE_JOB_SELECT,
+  inspectionToJob,
+  serviceRecordToJob,
+} from './mappers'
+import type { WorkBoardJob } from './types'
 
 
 export async function updateServiceTimes(input: unknown) {
 export async function updateServiceTimes(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      const data = updateServiceTimesSchema.parse(input);
+      const data = updateServiceTimesSchema.parse(input)
 
 
       if (data.endDateTime <= data.startDateTime) {
       if (data.endDateTime <= data.startDateTime) {
-        throw new Error("End time must be after start time");
+        throw new Error('End time must be after start time')
       }
       }
 
 
       const record = await db.serviceRecord.findFirst({
       const record = await db.serviceRecord.findFirst({
         where: { id: data.id, organizationId },
         where: { id: data.id, organizationId },
-      });
-      if (!record) throw new Error("Service record not found");
+      })
+      if (!record) throw new Error('Service record not found')
 
 
       await db.serviceRecord.update({
       await db.serviceRecord.update({
         where: { id: data.id },
         where: { id: data.id },
@@ -27,45 +34,45 @@ export async function updateServiceTimes(input: unknown) {
           startDateTime: data.startDateTime,
           startDateTime: data.startDateTime,
           endDateTime: data.endDateTime,
           endDateTime: data.endDateTime,
         },
         },
-      });
+      })
 
 
-      notificationBus.emit("workboard", {
-        type: "service_times_updated",
+      notificationBus.emit('workboard', {
+        type: 'service_times_updated',
         organizationId,
         organizationId,
         serviceRecordId: data.id,
         serviceRecordId: data.id,
         startDateTime: data.startDateTime.toISOString(),
         startDateTime: data.startDateTime.toISOString(),
         endDateTime: data.endDateTime.toISOString(),
         endDateTime: data.endDateTime.toISOString(),
-      });
+      })
 
 
-      revalidatePath("/work-board");
-      revalidatePath("/vehicles");
+      revalidatePath('/work-board')
+      revalidatePath('/vehicles')
       return {
       return {
         id: data.id,
         id: data.id,
         startDateTime: data.startDateTime.toISOString(),
         startDateTime: data.startDateTime.toISOString(),
         endDateTime: data.endDateTime.toISOString(),
         endDateTime: data.endDateTime.toISOString(),
-      };
+      }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
       ],
       ],
-    },
-  );
+    }
+  )
 }
 }
 
 
 export async function updateInspectionTimes(input: unknown) {
 export async function updateInspectionTimes(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
-      const data = updateServiceTimesSchema.parse(input);
+      const data = updateServiceTimesSchema.parse(input)
 
 
       if (data.endDateTime <= data.startDateTime) {
       if (data.endDateTime <= data.startDateTime) {
-        throw new Error("End time must be after start time");
+        throw new Error('End time must be after start time')
       }
       }
 
 
       const inspection = await db.inspection.findFirst({
       const inspection = await db.inspection.findFirst({
         where: { id: data.id, organizationId },
         where: { id: data.id, organizationId },
-      });
-      if (!inspection) throw new Error("Inspection not found");
+      })
+      if (!inspection) throw new Error('Inspection not found')
 
 
       await db.inspection.update({
       await db.inspection.update({
         where: { id: data.id },
         where: { id: data.id },
@@ -73,27 +80,127 @@ export async function updateInspectionTimes(input: unknown) {
           startDateTime: data.startDateTime,
           startDateTime: data.startDateTime,
           endDateTime: data.endDateTime,
           endDateTime: data.endDateTime,
         },
         },
-      });
+      })
 
 
-      notificationBus.emit("workboard", {
-        type: "inspection_times_updated",
+      notificationBus.emit('workboard', {
+        type: 'inspection_times_updated',
         organizationId,
         organizationId,
         inspectionId: data.id,
         inspectionId: data.id,
         startDateTime: data.startDateTime.toISOString(),
         startDateTime: data.startDateTime.toISOString(),
         endDateTime: data.endDateTime.toISOString(),
         endDateTime: data.endDateTime.toISOString(),
-      });
+      })
 
 
-      revalidatePath("/work-board");
+      revalidatePath('/work-board')
       return {
       return {
         id: data.id,
         id: data.id,
         startDateTime: data.startDateTime.toISOString(),
         startDateTime: data.startDateTime.toISOString(),
         endDateTime: data.endDateTime.toISOString(),
         endDateTime: data.endDateTime.toISOString(),
-      };
+      }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
         { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
         { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
       ],
       ],
+    }
+  )
+}
+
+/**
+ * The single write behind every board drag: lane, bay and time in one update.
+ *
+ * Fields left out are untouched; an explicit `null` clears the lane. One
+ * database write means one websocket event, so a second viewer never sees the
+ * job land in its new lane a beat before it lands at its new time.
+ */
+export async function scheduleJob(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = scheduleJobSchema.parse(input)
+
+      // Resolve the lane targets first: pointing a job at another
+      // organisation's technician or bay must fail before anything is written.
+      let technicianName: string | null | undefined
+      if (data.technicianId !== undefined) {
+        if (data.technicianId === null) {
+          technicianName = null
+        } else {
+          const tech = await db.technician.findFirst({
+            where: { id: data.technicianId, organizationId },
+            select: { name: true },
+          })
+          if (!tech) throw new Error('Technician not found')
+          technicianName = tech.name
+        }
+      }
+
+      if (data.workBayId) {
+        const bay = await db.workBay.findFirst({
+          where: { id: data.workBayId, organizationId },
+          select: { id: true },
+        })
+        if (!bay) throw new Error('Work bay not found')
+      }
+
+      const times =
+        data.startDateTime && data.endDateTime
+          ? { startDateTime: data.startDateTime, endDateTime: data.endDateTime }
+          : {}
+      const lane = {
+        ...(data.technicianId !== undefined ? { technicianId: data.technicianId } : {}),
+        ...(data.workBayId !== undefined ? { workBayId: data.workBayId } : {}),
+        ...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
+      }
+
+      let job: WorkBoardJob
+
+      if (data.type === 'serviceRecord') {
+        const owned = await db.serviceRecord.findFirst({
+          where: { id: data.id, organizationId },
+          select: { id: true },
+        })
+        if (!owned) throw new Error('Service record not found')
+
+        const sr = await db.serviceRecord.update({
+          where: { id: data.id },
+          data: {
+            ...lane,
+            ...times,
+            // techName is the printed-invoice copy of the technician's name and
+            // has to follow the assignment, including when it is cleared.
+            ...(technicianName !== undefined ? { techName: technicianName } : {}),
+          },
+          select: SERVICE_JOB_SELECT,
+        })
+        job = serviceRecordToJob(sr)
+      } else {
+        const owned = await db.inspection.findFirst({
+          where: { id: data.id, organizationId },
+          select: { id: true },
+        })
+        if (!owned) throw new Error('Inspection not found')
+
+        const insp = await db.inspection.update({
+          where: { id: data.id },
+          data: { ...lane, ...times },
+          select: INSPECTION_JOB_SELECT,
+        })
+        job = inspectionToJob(insp)
+      }
+
+      notificationBus.emit('workboard', {
+        type: 'job_scheduled',
+        organizationId,
+        job,
+      })
+
+      revalidatePath('/work-board')
+      revalidatePath('/vehicles')
+      return job
     },
     },
-  );
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    }
+  )
 }
 }

+ 35 - 20
src/features/workboard/Actions/boardActions/types.ts

@@ -1,24 +1,39 @@
 export type WorkBoardJob = {
 export type WorkBoardJob = {
-  id: string;
-  type: "serviceRecord" | "inspection";
-  technicianId: string | null;
-  sortOrder: number;
-  title: string;
-  status: string;
-  startDateTime: string | null;
-  endDateTime: string | null;
+  id: string
+  type: 'serviceRecord' | 'inspection'
+  technicianId: string | null
+  /** Bay the job occupies, independent of who works on it. */
+  workBayId: string | null
+  sortOrder: number
+  title: string
+  status: string
+  startDateTime: string | null
+  endDateTime: string | null
   vehicle: {
   vehicle: {
-    id: string;
-    make: string;
-    model: string;
-    year: number;
-    licensePlate: string | null;
-  } | null;
-  templateName?: string;
-};
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+    customer: { name: string } | null
+  } | null
+  /** Whose car it is, resolved through the vehicle or set directly. */
+  customerName: string | null
+  templateName?: string
+}
+
+export type WorkBay = {
+  id: string
+  name: string
+  color: string
+  isActive: boolean
+  sortOrder: number
+  dailyCapacity: number
+  organizationId: string
+}
 
 
 export type WorkBoardSettings = {
 export type WorkBoardSettings = {
-  weekStartDay: number;
-  workDayStart: string;
-  workDayEnd: string;
-};
+  weekStartDay: number
+  workDayStart: string
+  workDayEnd: string
+}

+ 162 - 0
src/features/workboard/Actions/workBayActions.ts

@@ -0,0 +1,162 @@
+'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 {
+  createWorkBaySchema,
+  deleteWorkBaySchema,
+  updateWorkBaySchema,
+} from '../Schema/workboardSchema'
+
+const BAY_SELECT = {
+  id: true,
+  name: true,
+  color: true,
+  isActive: true,
+  sortOrder: true,
+  dailyCapacity: true,
+  organizationId: true,
+} as const
+
+export async function getWorkBays() {
+  return withAuth(
+    async ({ organizationId }) => {
+      return db.workBay.findMany({
+        where: { organizationId, isActive: true },
+        orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
+        select: BAY_SELECT,
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.WORK_BOARD },
+      ],
+    }
+  )
+}
+
+export async function createWorkBay(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = createWorkBaySchema.parse(input)
+
+      const maxOrder = await db.workBay.aggregate({
+        where: { organizationId },
+        _max: { sortOrder: true },
+      })
+
+      const bay = await db.workBay.create({
+        data: {
+          name: data.name,
+          color: data.color,
+          dailyCapacity: data.dailyCapacity ?? 480,
+          sortOrder: (maxOrder._max.sortOrder ?? -1) + 1,
+          organizationId,
+        },
+        select: BAY_SELECT,
+      })
+
+      notificationBus.emit('workboard', {
+        type: 'work_bay_created',
+        organizationId,
+        workBay: bay,
+      })
+
+      revalidatePath('/work-board')
+      return bay
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+      audit: ({ result }) => ({
+        action: 'work_bay.create',
+        entity: 'WorkBay',
+        entityId: result.id,
+        details: { key: 'work_bay_create', params: { name: result.name } },
+        metadata: { workBayId: result.id, workBayName: result.name },
+      }),
+    }
+  )
+}
+
+export async function updateWorkBay(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { id, ...updates } = updateWorkBaySchema.parse(input)
+
+      const owned = await db.workBay.findFirst({
+        where: { id, organizationId },
+        select: { id: true },
+      })
+      if (!owned) throw new Error('Work bay not found')
+
+      const bay = await db.workBay.update({
+        where: { id },
+        data: updates,
+        select: BAY_SELECT,
+      })
+
+      notificationBus.emit('workboard', {
+        type: 'work_bay_updated',
+        organizationId,
+        workBay: bay,
+      })
+
+      revalidatePath('/work-board')
+      return bay
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+      audit: ({ result }) => ({
+        action: 'work_bay.update',
+        entity: 'WorkBay',
+        entityId: result.id,
+        details: { key: 'work_bay_update', params: { name: result.name } },
+        metadata: { workBayId: result.id },
+      }),
+    }
+  )
+}
+
+/**
+ * Deleting a bay leaves its jobs where they are: the schema clears `workBayId`
+ * (SetNull), so the work stays scheduled and simply drops into the "no bay"
+ * lane rather than disappearing along with the bay.
+ */
+export async function deleteWorkBay(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const { id } = deleteWorkBaySchema.parse(input)
+
+      const deleted = await db.workBay.deleteMany({ where: { id, organizationId } })
+      if (deleted.count === 0) throw new Error('Work bay not found')
+
+      notificationBus.emit('workboard', {
+        type: 'work_bay_removed',
+        organizationId,
+        workBayId: id,
+      })
+
+      revalidatePath('/work-board')
+      return { success: true, workBayId: id }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.WORK_BOARD },
+      ],
+      audit: ({ result }) => ({
+        action: 'work_bay.delete',
+        entity: 'WorkBay',
+        entityId: result.workBayId,
+        details: { key: 'work_bay_delete', params: { id: result.workBayId } },
+        metadata: { workBayId: result.workBayId },
+      }),
+    }
+  )
+}

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

@@ -1,99 +0,0 @@
-"use client";
-
-import { useDroppable } from "@dnd-kit/core";
-import { cn } from "@/lib/utils";
-import type { WorkBoardJob } from "../Actions/boardActions";
-import { BoardJobCard } from "./BoardJobCard";
-import { formatDuration } from "./DurationSlider";
-import { getJobDateRange, getDurationMinutes } from "../utils/datetime";
-
-export function CapacityBar({
-  bookedMinutes,
-  capacityMinutes,
-}: {
-  bookedMinutes: number;
-  capacityMinutes: number;
-}) {
-  if (bookedMinutes === 0 && capacityMinutes === 0) return null;
-
-  const pct = capacityMinutes > 0 ? Math.min((bookedMinutes / capacityMinutes) * 100, 100) : 0;
-  const isOver = bookedMinutes > capacityMinutes;
-
-  return (
-    <div className="mt-auto space-y-0.5 border-t pt-1">
-      <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
-        <div
-          className={cn(
-            "h-full rounded-full transition-all",
-            isOver
-              ? "bg-red-500"
-              : pct >= 75
-                ? "bg-amber-500"
-                : "bg-emerald-500",
-          )}
-          style={{ width: `${Math.min(pct, 100)}%` }}
-        />
-      </div>
-      <p className={cn(
-        "text-center text-[10px] font-medium",
-        isOver ? "text-red-500" : "text-muted-foreground",
-      )}>
-        {formatDuration(bookedMinutes)} / {formatDuration(capacityMinutes)}
-      </p>
-    </div>
-  );
-}
-
-function getBookedMinutes(jobs: WorkBoardJob[]): number {
-  return jobs.reduce((sum, a) => {
-    const { start, end } = getJobDateRange(a);
-    if (start && end) return sum + getDurationMinutes(start, end);
-    return sum;
-  }, 0);
-}
-
-export function BoardDayCell({
-  technicianId,
-  date,
-  assignments,
-  dailyCapacity,
-  onCardClick,
-}: {
-  technicianId: string;
-  date: string;
-  assignments: WorkBoardJob[];
-  dailyCapacity: number;
-  onCardClick: (job: WorkBoardJob) => 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;
-
-  const bookedMinutes = getBookedMinutes(assignments);
-
-  return (
-    <div
-      ref={setNodeRef}
-      className={cn(
-        "flex min-h-[80px] flex-col gap-1 overflow-hidden 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}
-          job={a}
-          onClick={() => onCardClick(a)}
-        />
-      ))}
-      <CapacityBar bookedMinutes={bookedMinutes} capacityMinutes={dailyCapacity} />
-    </div>
-  );
-}

+ 0 - 109
src/features/workboard/Components/DayTimeline/TimelineCard.tsx

@@ -1,109 +0,0 @@
-"use client";
-
-import { useRef } from "react";
-import { cn } from "@/lib/utils";
-import { Wrench, ClipboardCheck } from "lucide-react";
-import type { WorkBoardJob } from "../../Actions/boardActions";
-import { minutesToTime } from "./utils";
-
-export function JobBar({
-  job,
-  leftPct,
-  widthPct,
-  colorClass,
-  unscheduled,
-  isDragging,
-  startMins,
-  endMins,
-  onClick,
-  onMoveStart,
-  onResizeStartLeft,
-  onResizeStartRight,
-}: {
-  job: WorkBoardJob;
-  leftPct: number;
-  widthPct: number;
-  colorClass: string;
-  unscheduled?: boolean;
-  isDragging?: boolean;
-  startMins: number;
-  endMins: number;
-  onClick: () => void;
-  onMoveStart: (e: React.MouseEvent) => void;
-  onResizeStartLeft: (e: React.MouseEvent) => void;
-  onResizeStartRight: (e: React.MouseEvent) => void;
-}) {
-  const isServiceRecord = job.type === "serviceRecord";
-
-  const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
-
-  return (
-    <div
-      className={cn(
-        "absolute top-1 bottom-1 z-[1] flex items-center overflow-hidden rounded shadow-sm select-none group",
-        colorClass,
-        unscheduled && "border border-dashed border-current/30 opacity-70",
-        isDragging && "opacity-80 ring-2 ring-primary z-[3]",
-      )}
-      style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
-      title={`${minutesToTime(startMins)}–${minutesToTime(endMins)} · ${job.title}${job.vehicle ? ` - ${job.vehicle.year} ${job.vehicle.make} ${job.vehicle.model}` : ""}`}
-      onMouseDown={(e) => {
-        if (e.button !== 0) return;
-        mouseDownPos.current = { x: e.clientX, y: e.clientY };
-        const onUp = (upEvent: MouseEvent) => {
-          window.removeEventListener("mouseup", onUp);
-          if (!mouseDownPos.current) return;
-          const dist = Math.abs(upEvent.clientX - mouseDownPos.current.x) + Math.abs(upEvent.clientY - mouseDownPos.current.y);
-          if (dist < 5) onClick();
-          mouseDownPos.current = null;
-        };
-        window.addEventListener("mouseup", onUp);
-        onMoveStart(e);
-      }}
-    >
-      <div
-        className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize z-[2] opacity-0 group-hover:opacity-100 bg-black/10 hover:bg-black/20 rounded-l transition-opacity"
-        onMouseDown={(e) => { e.stopPropagation(); if (e.button === 0) onResizeStartLeft(e); }}
-      />
-      <div className="flex items-center gap-1 px-3 min-w-0 cursor-grab active:cursor-grabbing text-[11px] font-medium">
-        {isServiceRecord ? <Wrench className="h-3 w-3 shrink-0" /> : <ClipboardCheck className="h-3 w-3 shrink-0" />}
-        {isDragging ? (
-          <span className="font-semibold whitespace-nowrap">{minutesToTime(startMins)} – {minutesToTime(endMins)}</span>
-        ) : (
-          <span className="truncate">
-            {job.title}
-            {job.vehicle ? ` - ${job.vehicle.licensePlate || `${job.vehicle.make} ${job.vehicle.model}`}` : ""}
-          </span>
-        )}
-      </div>
-      <div
-        className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize z-[2] opacity-0 group-hover:opacity-100 bg-black/10 hover:bg-black/20 rounded-r transition-opacity"
-        onMouseDown={(e) => { e.stopPropagation(); if (e.button === 0) onResizeStartRight(e); }}
-      />
-    </div>
-  );
-}
-
-export function CurrentTimeIndicator({
-  dayStartMins,
-  totalMinutes,
-  date,
-}: {
-  dayStartMins: number;
-  totalMinutes: number;
-  date: string;
-}) {
-  const now = new Date();
-  const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
-  if (date !== today) return null;
-
-  const nowMins = now.getHours() * 60 + now.getMinutes();
-  const leftPct = ((nowMins - dayStartMins) / totalMinutes) * 100;
-  if (leftPct < 0 || leftPct > 100) return null;
-
-  return (
-    <div className="absolute top-0 bottom-0 z-10 w-0.5 bg-red-500 pointer-events-none" style={{ left: `${leftPct}%` }}>
-      <div className="absolute -top-1 -left-1 h-2.5 w-2.5 rounded-full bg-red-500" />
-    </div>
-  );
-}

+ 0 - 166
src/features/workboard/Components/DayTimeline/TimelineColumn.tsx

@@ -1,166 +0,0 @@
-"use client";
-
-import { useRef, useCallback } from "react";
-import { useDroppable } from "@dnd-kit/core";
-import { cn } from "@/lib/utils";
-import type { WorkBoardJob } from "../../Actions/boardActions";
-import type { Technician } from "../../store/workboardStore";
-import { formatDuration } from "../DurationSlider";
-import { getJobDateRange, getDurationMinutes } from "../../utils/datetime";
-import { getResolvedRange, BAR_COLORS, minutesToTime } from "./utils";
-import { JobBar, CurrentTimeIndicator } from "./TimelineCard";
-import type { DragState, DragOverride, DragMode } from "./index";
-import {
-  ContextMenu,
-  ContextMenuContent,
-  ContextMenuItem,
-  ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { Plus } from "lucide-react";
-import { useTranslations } from "next-intl";
-
-export function TechTimelineRow({
-  tech,
-  date,
-  dayAssignments,
-  dayStartMins,
-  dayEndMins,
-  totalMinutes,
-  hourSlots,
-  drag,
-  dragOverride,
-  startDrag,
-  onCardClick,
-  onTechClick,
-  onCreateWorkOrder,
-  cancelPendingDrag,
-  registerRef,
-}: {
-  tech: Technician;
-  date: string;
-  dayAssignments: WorkBoardJob[];
-  dayStartMins: number;
-  dayEndMins: number;
-  totalMinutes: number;
-  hourSlots: string[];
-  drag: DragState | null;
-  dragOverride: DragOverride | null;
-  startDrag: (e: React.MouseEvent, a: WorkBoardJob, mode: DragMode, el: HTMLElement) => void;
-  onCardClick: (a: WorkBoardJob) => void;
-  onTechClick?: (t: Technician) => void;
-  onCreateWorkOrder?: (techId: string, startTime: string, endTime: string) => void;
-  cancelPendingDrag: () => void;
-  registerRef: (el: HTMLDivElement | null) => void;
-}) {
-  const t = useTranslations("workBoard.board");
-  const timelineRef = useRef<HTMLDivElement>(null);
-  const contextClickTimeRef = useRef<number>(dayStartMins);
-  const techJobs = dayAssignments.filter((a) => a.technicianId === tech.id);
-
-  const bookedMinutes = techJobs.reduce((sum, a) => {
-    const { start, end } = getJobDateRange(a);
-    return start && end ? sum + getDurationMinutes(start, end) : sum;
-  }, 0);
-
-  const isDropTarget = drag && dragOverride && dragOverride.techId === tech.id && drag.origTechId !== tech.id;
-  const droppableId = `${tech.id}::${date}`;
-  const { isOver, setNodeRef } = useDroppable({ id: droppableId, data: { technicianId: tech.id, date } });
-  const rowRef = useCallback((el: HTMLDivElement | null) => { setNodeRef(el); registerRef(el); }, [setNodeRef, registerRef]);
-
-  const ghostBar = drag && dragOverride && dragOverride.techId === tech.id && drag.origTechId !== tech.id ? dragOverride : null;
-
-  return (
-    <div ref={rowRef} className={cn("flex border-b last:border-b-0 transition-colors", (isOver || isDropTarget) && "bg-primary/5")}>
-      <button type="button" className="flex w-[160px] shrink-0 flex-col justify-center gap-1 border-r p-2 text-left transition-colors hover:bg-muted/50" onClick={() => onTechClick?.(tech)}>
-        <div className="flex items-center gap-2">
-          <div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: tech.color }} />
-          <span className="text-sm font-medium leading-tight">{tech.name}</span>
-        </div>
-        <span className="text-[10px] text-muted-foreground">
-          {formatDuration(bookedMinutes)} / {formatDuration(tech.dailyCapacity)}
-        </span>
-      </button>
-
-      <ContextMenu>
-        <ContextMenuTrigger asChild>
-          <div
-            ref={timelineRef}
-            className="relative flex-1"
-            style={{ minHeight: "56px" }}
-            onContextMenu={(e) => {
-              if (timelineRef.current) {
-                const rect = timelineRef.current.getBoundingClientRect();
-                const xPct = (e.clientX - rect.left) / rect.width;
-                contextClickTimeRef.current = Math.round((dayStartMins + xPct * totalMinutes) / 5) * 5;
-              }
-            }}
-          >
-            <div className="absolute inset-0 flex">
-              {hourSlots.map((slot) => (
-                <div key={slot} className="flex-1 border-r border-dashed border-border/50 transition-colors duration-150 hover:bg-muted/40" />
-              ))}
-            </div>
-            <CurrentTimeIndicator dayStartMins={dayStartMins} totalMinutes={totalMinutes} date={date} />
-
-            {techJobs.map((job, jobIdx) => {
-              const isDragged = drag?.assignmentId === job.id;
-              const resolved = getResolvedRange(job.startDateTime ?? null, job.endDateTime ?? null, dayStartMins);
-              let { startMins, endMins } = resolved;
-
-              if (isDragged && dragOverride) {
-                if (dragOverride.techId !== tech.id) return null;
-                startMins = dragOverride.startMins;
-                endMins = dragOverride.endMins;
-              }
-
-              const leftPct = ((startMins - dayStartMins) / totalMinutes) * 100;
-              const widthPct = ((endMins - startMins) / totalMinutes) * 100;
-
-              return (
-                <JobBar
-                  key={job.id}
-                  job={job}
-                  leftPct={Math.max(leftPct, 0)}
-                  widthPct={Math.max(widthPct, 1)}
-                  colorClass={BAR_COLORS[jobIdx % BAR_COLORS.length]}
-                  unscheduled={resolved.isUnscheduled && !isDragged}
-                  isDragging={isDragged}
-                  startMins={startMins}
-                  endMins={endMins}
-                  onClick={() => { cancelPendingDrag(); if (!isDragged) onCardClick(job); }}
-                  onMoveStart={(e) => { if (timelineRef.current) startDrag(e, job, "move", timelineRef.current); }}
-                  onResizeStartLeft={(e) => { if (timelineRef.current) startDrag(e, job, "resize-start", timelineRef.current); }}
-                  onResizeStartRight={(e) => { if (timelineRef.current) startDrag(e, job, "resize-end", timelineRef.current); }}
-                />
-              );
-            })}
-
-            {ghostBar && drag && (
-              <div
-                className={cn("absolute top-1 bottom-1 z-[2] rounded shadow-sm border-2 border-dashed pointer-events-none", "border-primary bg-primary/10")}
-                style={{
-                  left: `${Math.max(((ghostBar.startMins - dayStartMins) / totalMinutes) * 100, 0)}%`,
-                  width: `${Math.max(((ghostBar.endMins - ghostBar.startMins) / totalMinutes) * 100, 1)}%`,
-                }}
-              >
-                <div className="flex items-center justify-center h-full text-[11px] font-semibold text-primary whitespace-nowrap">
-                  {minutesToTime(ghostBar.startMins)} – {minutesToTime(ghostBar.endMins)}
-                </div>
-              </div>
-            )}
-          </div>
-        </ContextMenuTrigger>
-        <ContextMenuContent>
-          <ContextMenuItem onClick={() => {
-            const startMins = contextClickTimeRef.current ?? dayStartMins;
-            const endMins = Math.min(startMins + 60, dayStartMins + totalMinutes);
-            onCreateWorkOrder?.(tech.id, minutesToTime(startMins), minutesToTime(endMins));
-          }}>
-            <Plus className="mr-2 h-4 w-4" />
-            {t("createNewWorkorder")}
-          </ContextMenuItem>
-        </ContextMenuContent>
-      </ContextMenu>
-    </div>
-  );
-}

+ 0 - 236
src/features/workboard/Components/DayTimeline/index.tsx

@@ -1,236 +0,0 @@
-"use client";
-
-import { useRef, useMemo, useState, useCallback, useEffect } from "react";
-import type { WorkBoardJob } from "../../Actions/boardActions";
-import type { Technician } from "../../store/workboardStore";
-import { useWorkBoardStore } from "../../store/workboardStore";
-import { jobOverlapsDate } from "../../utils/datetime";
-import { timeToMinutes, minutesToTime } from "./utils";
-import { TechTimelineRow } from "./TimelineColumn";
-
-const SNAP_MINUTES = 5;
-
-function snapMinutes(mins: number): number {
-  return Math.round(mins / SNAP_MINUTES) * SNAP_MINUTES;
-}
-
-function getHourSlots(startTime: string, endTime: string): string[] {
-  const startMins = timeToMinutes(startTime);
-  const endMins = timeToMinutes(endTime);
-  const slots: string[] = [];
-  for (let m = startMins; m < endMins; m += 60) {
-    slots.push(minutesToTime(m));
-  }
-  return slots;
-}
-
-type DragMode = "move" | "resize-start" | "resize-end";
-
-type DragState = {
-  assignmentId: string;
-  mode: DragMode;
-  origStartMins: number;
-  origEndMins: number;
-  origTechId: string;
-  anchorX: number;
-  anchorY: number;
-  timelineWidth: number;
-};
-
-type DragOverride = {
-  startMins: number;
-  endMins: number;
-  techId: string;
-};
-
-export { type DragMode, type DragState, type DragOverride };
-
-export function DayTimeline({
-  date,
-  technicians,
-  assignments,
-  workDayStart,
-  workDayEnd,
-  onCardClick,
-  onTechClick,
-  onCreateWorkOrder,
-}: {
-  date: string;
-  technicians: Technician[];
-  assignments: WorkBoardJob[];
-  workDayStart: string;
-  workDayEnd: string;
-  onCardClick: (job: WorkBoardJob) => void;
-  onTechClick?: (technician: Technician) => void;
-  onCreateWorkOrder?: (techId: string, startTime: string, endTime: string) => void;
-}) {
-  const store = useWorkBoardStore();
-  const dayStartMins = timeToMinutes(workDayStart);
-  const baseEndMins = timeToMinutes(workDayEnd);
-
-  const dayAssignments = useMemo(
-    () => assignments.filter((a) => jobOverlapsDate(a, date)),
-    [assignments, date],
-  );
-
-  const dayEndMins = useMemo(() => {
-    let max = baseEndMins;
-    for (const a of dayAssignments) {
-      const endStr = a.endDateTime;
-      if (endStr) {
-        const end = new Date(endStr);
-        const endMins = end.getHours() * 60 + end.getMinutes();
-        if (endMins > max) max = endMins;
-      }
-    }
-    return max > baseEndMins ? Math.ceil(max / 60) * 60 : baseEndMins;
-  }, [dayAssignments, baseEndMins]);
-
-  const totalMinutes = dayEndMins - dayStartMins;
-  const hourSlots = useMemo(
-    () => getHourSlots(workDayStart, minutesToTime(dayEndMins)),
-    [workDayStart, dayEndMins],
-  );
-
-  const techRowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
-  const [drag, setDrag] = useState<DragState | null>(null);
-  const [dragOverride, setDragOverride] = useState<DragOverride | null>(null);
-  const dragOverrideRef = useRef<DragOverride | null>(null);
-  const dragRef = useRef<DragState | null>(null);
-  const pendingDragRef = useRef<DragState | null>(null);
-  const [hasPending, setHasPending] = useState(false);
-
-  const cancelPendingDrag = useCallback(() => {
-    pendingDragRef.current = null;
-    dragRef.current = null;
-    setHasPending(false);
-    setDrag(null);
-    setDragOverride(null);
-    dragOverrideRef.current = null;
-  }, []);
-
-  const getTechAtY = useCallback((clientY: number): string | null => {
-    for (const [techId, el] of techRowRefs.current) {
-      const rect = el.getBoundingClientRect();
-      if (clientY >= rect.top && clientY <= rect.bottom) return techId;
-    }
-    return null;
-  }, []);
-
-  const handleMouseMove = useCallback(
-    (e: MouseEvent) => {
-      const pending = pendingDragRef.current;
-      if (pending && !dragRef.current) {
-        const dist = Math.abs(e.clientX - pending.anchorX) + Math.abs(e.clientY - pending.anchorY);
-        if (dist < 5) return;
-        dragRef.current = pending;
-        pendingDragRef.current = null;
-        setHasPending(false);
-        setDrag(pending);
-      }
-      const d = dragRef.current;
-      if (!d) return;
-      const dx = e.clientX - d.anchorX;
-      const dMins = snapMinutes((dx / d.timelineWidth) * totalMinutes);
-      let newStart = d.origStartMins, newEnd = d.origEndMins;
-      if (d.mode === "move") { newStart += dMins; newEnd += dMins; }
-      else if (d.mode === "resize-start") { newStart = Math.max(dayStartMins, Math.min(d.origStartMins + dMins, d.origEndMins - SNAP_MINUTES)); }
-      else { newEnd = Math.min(dayEndMins, Math.max(d.origEndMins + dMins, d.origStartMins + SNAP_MINUTES)); }
-      let targetTech = d.origTechId;
-      if (d.mode === "move") { const h = getTechAtY(e.clientY); if (h) targetTech = h; }
-      if (newStart < dayStartMins) { if (d.mode === "move") newEnd += dayStartMins - newStart; newStart = dayStartMins; }
-      if (newEnd > dayEndMins) { if (d.mode === "move") newStart -= newEnd - dayEndMins; newEnd = dayEndMins; }
-      const override: DragOverride = { startMins: newStart, endMins: newEnd, techId: targetTech };
-      dragOverrideRef.current = override;
-      setDragOverride(override);
-    },
-    [totalMinutes, dayStartMins, dayEndMins, getTechAtY],
-  );
-
-  const handleMouseUp = useCallback(async () => {
-    if (pendingDragRef.current) { pendingDragRef.current = null; setHasPending(false); return; }
-    const d = dragRef.current;
-    const override = dragOverrideRef.current;
-    setDrag(null); setDragOverride(null); dragRef.current = null; dragOverrideRef.current = null;
-    if (!d || !override) return;
-    if (override.startMins === d.origStartMins && override.endMins === d.origEndMins && override.techId === d.origTechId) return;
-
-    const newStartDate = new Date(date + "T00:00:00");
-    newStartDate.setMinutes(override.startMins);
-    const newEndDate = new Date(date + "T00:00:00");
-    newEndDate.setMinutes(override.endMins);
-
-    // Find the job to get its type
-    const job = store.jobs.find((x) => x.id === d.assignmentId);
-
-    if (override.techId !== d.origTechId) {
-      store.optimisticMove(d.assignmentId, override.techId);
-      store.updateServiceTimes(d.assignmentId, newStartDate.toISOString(), newEndDate.toISOString());
-      const { moveJob, updateServiceTimes: updateST } = await import("../../Actions/boardActions");
-      const res = await moveJob({ id: d.assignmentId, technicianId: override.techId, sortOrder: 0, type: job?.type ?? "serviceRecord" });
-      if (!res.success) { store.optimisticMove(d.assignmentId, d.origTechId); } else if (res.data) { store.updateJob(res.data as WorkBoardJob); }
-      await updateST({ id: d.assignmentId, startDateTime: newStartDate, endDateTime: newEndDate });
-    } else {
-      store.updateServiceTimes(d.assignmentId, newStartDate.toISOString(), newEndDate.toISOString());
-      const { updateServiceTimes: updateST } = await import("../../Actions/boardActions");
-      await updateST({ id: d.assignmentId, startDateTime: newStartDate, endDateTime: newEndDate });
-    }
-  }, [store, date]);
-
-  useEffect(() => {
-    if (!drag && !hasPending) return;
-    window.addEventListener("mousemove", handleMouseMove);
-    window.addEventListener("mouseup", handleMouseUp);
-    return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); };
-  }, [drag, hasPending, handleMouseMove, handleMouseUp]);
-
-  const startDrag = (e: React.MouseEvent, job: WorkBoardJob, mode: DragMode, timelineEl: HTMLElement) => {
-    e.preventDefault(); e.stopPropagation();
-    const startStr = job.startDateTime;
-    const endStr = job.endDateTime;
-    let startMins = dayStartMins, endMins = dayStartMins + 60;
-    if (startStr) { const d = new Date(startStr); startMins = d.getHours() * 60 + d.getMinutes(); }
-    if (endStr) { const d = new Date(endStr); endMins = d.getHours() * 60 + d.getMinutes(); }
-    pendingDragRef.current = { assignmentId: job.id, mode, origStartMins: startMins, origEndMins: endMins, origTechId: job.technicianId!, anchorX: e.clientX, anchorY: e.clientY, timelineWidth: timelineEl.getBoundingClientRect().width };
-    setHasPending(true);
-  };
-
-  return (
-    <div className="flex flex-1 flex-col overflow-auto">
-      <div className="min-w-[900px]">
-        <div className="flex border-b">
-          <div className="w-[160px] shrink-0 border-r p-2">
-            <span className="text-xs font-medium text-muted-foreground">Technician</span>
-          </div>
-          <div className="relative flex-1">
-            <div className="flex">
-              {hourSlots.map((slot) => (
-                <div key={slot} className="flex-1 border-r px-1 py-2 text-center text-[11px] font-medium text-muted-foreground">{slot}</div>
-              ))}
-            </div>
-          </div>
-        </div>
-        {technicians.map((tech) => (
-          <TechTimelineRow
-            key={tech.id}
-            tech={tech}
-            date={date}
-            dayAssignments={dayAssignments}
-            dayStartMins={dayStartMins}
-            dayEndMins={dayEndMins}
-            totalMinutes={totalMinutes}
-            hourSlots={hourSlots}
-            drag={drag}
-            dragOverride={dragOverride}
-            startDrag={startDrag}
-            onCardClick={onCardClick}
-            onTechClick={onTechClick}
-            onCreateWorkOrder={onCreateWorkOrder}
-            cancelPendingDrag={cancelPendingDrag}
-            registerRef={(el) => { if (el) techRowRefs.current.set(tech.id, el); else techRowRefs.current.delete(tech.id); }}
-          />
-        ))}
-      </div>
-    </div>
-  );
-}

+ 0 - 39
src/features/workboard/Components/DayTimeline/utils.ts

@@ -1,39 +0,0 @@
-export function timeToMinutes(time: string): number {
-  const [h, m] = time.split(":").map(Number);
-  return h * 60 + m;
-}
-
-export function minutesToTime(mins: number): string {
-  const clamped = Math.max(0, Math.min(1439, mins));
-  const h = Math.floor(clamped / 60);
-  const m = clamped % 60;
-  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
-}
-
-export function getResolvedRange(
-  startDateTime: string | null | undefined,
-  endDateTime: string | null | undefined,
-  dayStartMins: number,
-): { startMins: number; endMins: number; isUnscheduled: boolean } {
-  if (startDateTime && endDateTime) {
-    const s = new Date(startDateTime);
-    const e = new Date(endDateTime);
-    return {
-      startMins: s.getHours() * 60 + s.getMinutes(),
-      endMins: e.getHours() * 60 + e.getMinutes(),
-      isUnscheduled: false,
-    };
-  }
-  return { startMins: dayStartMins, endMins: dayStartMins + 60, isUnscheduled: true };
-}
-
-export const BAR_COLORS = [
-  "bg-sky-400/80 text-sky-950 dark:bg-sky-600/80 dark:text-sky-50",
-  "bg-emerald-400/80 text-emerald-950 dark:bg-emerald-600/80 dark:text-emerald-50",
-  "bg-amber-400/80 text-amber-950 dark:bg-amber-600/80 dark:text-amber-50",
-  "bg-violet-400/80 text-violet-950 dark:bg-violet-600/80 dark:text-violet-50",
-  "bg-rose-400/80 text-rose-950 dark:bg-rose-600/80 dark:text-rose-50",
-  "bg-cyan-400/80 text-cyan-950 dark:bg-cyan-600/80 dark:text-cyan-50",
-  "bg-lime-400/80 text-lime-950 dark:bg-lime-600/80 dark:text-lime-50",
-  "bg-orange-400/80 text-orange-950 dark:bg-orange-600/80 dark:text-orange-50",
-];

+ 131 - 0
src/features/workboard/Components/JobTooltip.tsx

@@ -0,0 +1,131 @@
+'use client'
+
+import { CalendarClock, ClipboardCheck, User, Wrench } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import type { WorkBoardJob } from '../Actions/boardActions'
+import { type ClockFormat, formatClockRange } from '../utils/clock'
+import { getDurationMinutes, getJobDateRange } from '../utils/datetime'
+import { normalizeStatus } from '../utils/job-colors'
+import { formatDuration } from './DurationSlider'
+
+/** Status strings as the translation file spells them. */
+const STATUS_KEY: Record<string, string> = {
+  pending: 'pending',
+  scheduled: 'pending',
+  'in-progress': 'inProgress',
+  'waiting-parts': 'waitingParts',
+  completed: 'completed',
+}
+
+/**
+ * Everything about a job that will not fit on its block.
+ *
+ * A block on a busy week is a coloured sliver with a truncated title, and the
+ * browser's own `title` attribute was showing little more than that. What
+ * someone hovering actually wants to know is whose car it is, who has it, and
+ * how long it runs, so that is what this says.
+ */
+export function JobTooltip({
+  job,
+  timeFormat,
+  ownerName,
+  ownerColor,
+  bayName,
+  children,
+}: {
+  job: WorkBoardJob
+  timeFormat: ClockFormat
+  /** Technician on the job, when one is assigned. */
+  ownerName?: string | null
+  ownerColor?: string | null
+  bayName?: string | null
+  children: React.ReactNode
+}) {
+  const t = useTranslations('workBoard.job')
+  const tStatus = useTranslations('workBoard.presenter.statusLabels')
+
+  const { start, end } = getJobDateRange(job)
+  const minutes = start && end ? getDurationMinutes(start, end) : null
+  const statusKey = STATUS_KEY[normalizeStatus(job.status)]
+
+  const vehicle = job.vehicle
+    ? [`${job.vehicle.year} ${job.vehicle.make} ${job.vehicle.model}`, job.vehicle.licensePlate]
+        .filter(Boolean)
+        .join(' · ')
+    : null
+
+  return (
+    <Tooltip>
+      <TooltipTrigger asChild>{children}</TooltipTrigger>
+      <TooltipContent side="right" align="start" className="max-w-[260px]">
+        <p className="flex items-start gap-1.5 font-medium">
+          {job.type === 'serviceRecord' ? (
+            <Wrench className="mt-px h-3.5 w-3.5 shrink-0" />
+          ) : (
+            <ClipboardCheck className="mt-px h-3.5 w-3.5 shrink-0" />
+          )}
+          <span>{job.title}</span>
+        </p>
+
+        {vehicle && <p className="mt-1">{vehicle}</p>}
+        {job.customerName && (
+          <p className="flex items-center gap-1.5 text-background/70">
+            <User className="h-3 w-3 shrink-0" />
+            {job.customerName}
+          </p>
+        )}
+
+        <dl className="mt-2 space-y-0.5">
+          {start && end ? (
+            <>
+              <Row
+                label={t('time')}
+                value={formatClockRange(
+                  start.getHours() * 60 + start.getMinutes(),
+                  end.getHours() * 60 + end.getMinutes(),
+                  timeFormat
+                )}
+              />
+              {minutes !== null && <Row label={t('duration')} value={formatDuration(minutes)} />}
+            </>
+          ) : (
+            <p className="flex items-center gap-1.5 text-background/70">
+              <CalendarClock className="h-3 w-3 shrink-0" />
+              {t('noTime')}
+            </p>
+          )}
+
+          <Row
+            label={t('technician')}
+            value={ownerName ?? t('none')}
+            colour={ownerName ? ownerColor : null}
+          />
+          {bayName && <Row label={t('workBay')} value={bayName} />}
+          {statusKey && <Row label={t('status')} value={tStatus(statusKey)} />}
+        </dl>
+      </TooltipContent>
+    </Tooltip>
+  )
+}
+
+/*
+ * Muted text inside a tooltip has to be muted against the tooltip, not against
+ * the page. The tooltip is `bg-foreground text-background`, so it inverts with
+ * the theme, and `text-muted-foreground` is a mid grey that lands at poor
+ * contrast on both the near-black light-theme tooltip and the near-white dark
+ * one. Fading the tooltip's own text colour tracks the inversion.
+ */
+function Row({ label, value, colour }: { label: string; value: string; colour?: string | null }) {
+  return (
+    <div className="flex justify-between gap-4">
+      <dt className="text-background/70">{label}</dt>
+      <dd className="flex items-center gap-1.5 text-right">
+        {colour && (
+          <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: colour }} />
+        )}
+        {value}
+      </dd>
+    </div>
+  )
+}

+ 91 - 0
src/features/workboard/Components/LaneHeaderTooltip.tsx

@@ -0,0 +1,91 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import type { WorkBoardJob } from '../Actions/boardActions'
+import { bookedMinutesOnDay } from '../utils/layout'
+import type { BoardLane } from '../utils/lanes'
+import { formatDuration } from './DurationSlider'
+
+/**
+ * What a lane header is worth saying when there is room to say it.
+ *
+ * The header itself is a colour dot and a name truncated to whatever the column
+ * width allows, which at fifteen lanes is a few characters. The hover is where
+ * the name is readable at all, so it carries the load: who, how full, how much
+ * is left, and how many jobs make up the number.
+ */
+export function LaneHeaderTooltip({
+  lane,
+  jobs,
+  days,
+  capacityMinutes,
+  periodLabel,
+  children,
+}: {
+  lane: BoardLane
+  /** Jobs in this lane, across the days the tooltip is summarising. */
+  jobs: WorkBoardJob[]
+  days: string[]
+  capacityMinutes: number
+  /** "Tuesday" or "This week", depending on what the header covers. */
+  periodLabel: string
+  children: React.ReactNode
+}) {
+  const t = useTranslations('workBoard.lane')
+
+  const booked = days.reduce((sum, day) => sum + bookedMinutesOnDay(jobs, day), 0)
+  const jobCount = jobs.filter((job) => days.some((day) => touchesDay(job, day))).length
+  const pct = capacityMinutes > 0 ? Math.round((booked / capacityMinutes) * 100) : null
+  const free = capacityMinutes - booked
+
+  return (
+    <Tooltip>
+      <TooltipTrigger asChild>{children}</TooltipTrigger>
+      <TooltipContent side="bottom" className="max-w-[240px]">
+        <p className="flex items-center gap-1.5 font-medium">
+          <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: lane.color }} />
+          {lane.name}
+        </p>
+        <p className="mt-1 text-background/70">{periodLabel}</p>
+        <dl className="mt-1 space-y-0.5">
+          <Row label={t('booked')} value={formatDuration(booked)} />
+          {capacityMinutes > 0 && (
+            <>
+              <Row label={t('capacity')} value={formatDuration(capacityMinutes)} />
+              <Row
+                label={free >= 0 ? t('free') : t('over')}
+                value={formatDuration(Math.abs(free))}
+              />
+            </>
+          )}
+          <Row label={t('jobs')} value={String(jobCount)} />
+        </dl>
+        {pct !== null && (
+          <p className="mt-1 tabular-nums text-background/70">{t('utilisation', { pct })}</p>
+        )}
+        {lane.isPlaceholder && <p className="mt-1 text-background/70">{t('placeholder')}</p>}
+      </TooltipContent>
+    </Tooltip>
+  )
+}
+
+/*
+ * Muted text inside a tooltip has to be muted against the tooltip, not against
+ * the page. The tooltip is `bg-foreground text-background`, so it inverts with
+ * the theme, and `text-muted-foreground` is a mid grey that lands at poor
+ * contrast on both the near-black light-theme tooltip and the near-white dark
+ * one. Fading the tooltip's own text colour tracks the inversion.
+ */
+function Row({ label, value }: { label: string; value: string }) {
+  return (
+    <div className="flex justify-between gap-4">
+      <dt className="text-background/70">{label}</dt>
+      <dd className="tabular-nums">{value}</dd>
+    </div>
+  )
+}
+
+function touchesDay(job: WorkBoardJob, day: string): boolean {
+  return bookedMinutesOnDay([job], day) > 0
+}

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

@@ -1,98 +0,0 @@
-'use client'
-
-import { Wrench, ClipboardCheck } from 'lucide-react'
-import type { Technician } from '../store/workboardStore'
-import type { WorkBoardJob } from '../Actions/boardActions'
-import { jobOverlapsDate } from '../utils/datetime'
-import { useTranslations } from 'next-intl'
-
-function DayJobCard({ job }: { job: WorkBoardJob }) {
-  const isServiceRecord = job.type === 'serviceRecord'
-
-  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">{job.title}</p>
-        {job.vehicle && (
-          <p className="truncate text-sm text-muted-foreground">
-            {job.vehicle.year} {job.vehicle.make} {job.vehicle.model}
-            {job.vehicle.licensePlate ? ` · ${job.vehicle.licensePlate}` : ''}
-          </p>
-        )}
-      </div>
-      {job.status && (
-        <span className="shrink-0 rounded-full bg-muted px-2.5 py-1 text-xs font-medium capitalize">
-          {job.status.replace(/_/g, ' ')}
-        </span>
-      )}
-    </div>
-  )
-}
-
-export function PresenterDayView({
-  date,
-  technicians,
-  assignments,
-}: {
-  date: string
-  technicians: Technician[]
-  assignments: WorkBoardJob[]
-}) {
-  const t = useTranslations('workBoard.presenter')
-  const dayAssignments = assignments.filter((a) => jobOverlapsDate(a, date))
-
-  if (technicians.length === 0) {
-    return (
-      <div className="flex flex-1 items-center justify-center">
-        <p className="text-lg text-muted-foreground">{t('noTechnicians')}</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">
-              <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 === 1 ? t('job', { count: techJobs.length }) : t('jobs', { count: techJobs.length })}
-                  </p>
-                </div>
-              </div>
-
-              <div className="flex flex-1 flex-wrap gap-2">
-                {techJobs.length === 0 && (
-                  <p className="py-2 text-sm text-muted-foreground/60">{t('noJobsToday')}</p>
-                )}
-                {techJobs.map((job) => (
-                  <div key={job.id} className="w-72">
-                    <DayJobCard job={job} />
-                  </div>
-                ))}
-              </div>
-            </div>
-          )
-        })}
-      </div>
-    </div>
-  )
-}

+ 0 - 174
src/features/workboard/Components/PresenterTimeline.tsx

@@ -1,174 +0,0 @@
-"use client";
-
-import { useMemo, useEffect, useState } from "react";
-import { cn } from "@/lib/utils";
-import type { WorkBoardJob } from "../Actions/boardActions";
-import type { Technician } from "../store/workboardStore";
-import { Wrench, ClipboardCheck } from "lucide-react";
-import { jobOverlapsDate, getJobDateRange, getDurationMinutes } from "../utils/datetime";
-import { formatDuration } from "./DurationSlider";
-import { useTranslations } from "next-intl";
-
-function timeToMinutes(time: string): number {
-  const [h, m] = time.split(":").map(Number);
-  return h * 60 + m;
-}
-
-function minutesToTime(mins: number): string {
-  const clamped = Math.max(0, Math.min(1439, mins));
-  const h = Math.floor(clamped / 60);
-  const m = clamped % 60;
-  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
-}
-
-function getHourSlots(startTime: string, endTime: string): string[] {
-  const startMins = timeToMinutes(startTime);
-  const endMins = timeToMinutes(endTime);
-  const slots: string[] = [];
-  for (let m = startMins; m < endMins; m += 60) slots.push(minutesToTime(m));
-  return slots;
-}
-
-const BAR_COLORS = [
-  "bg-sky-400/80 text-sky-950 dark:bg-sky-600/80 dark:text-sky-50",
-  "bg-emerald-400/80 text-emerald-950 dark:bg-emerald-600/80 dark:text-emerald-50",
-  "bg-amber-400/80 text-amber-950 dark:bg-amber-600/80 dark:text-amber-50",
-  "bg-violet-400/80 text-violet-950 dark:bg-violet-600/80 dark:text-violet-50",
-  "bg-rose-400/80 text-rose-950 dark:bg-rose-600/80 dark:text-rose-50",
-  "bg-cyan-400/80 text-cyan-950 dark:bg-cyan-600/80 dark:text-cyan-50",
-  "bg-lime-400/80 text-lime-950 dark:bg-lime-600/80 dark:text-lime-50",
-  "bg-orange-400/80 text-orange-950 dark:bg-orange-600/80 dark:text-orange-50",
-];
-
-export function PresenterTimeline({
-  date,
-  technicians,
-  assignments,
-  workDayStart,
-  workDayEnd,
-}: {
-  date: string;
-  technicians: Technician[];
-  assignments: WorkBoardJob[];
-  workDayStart: string;
-  workDayEnd: string;
-}) {
-  const t = useTranslations("workBoard.presenter");
-  const dayStartMins = timeToMinutes(workDayStart);
-  const baseEndMins = timeToMinutes(workDayEnd);
-
-  const dayAssignments = useMemo(
-    () => assignments.filter((a) => jobOverlapsDate(a, date)),
-    [assignments, date],
-  );
-
-  const dayEndMins = useMemo(() => {
-    let max = baseEndMins;
-    for (const a of dayAssignments) {
-      const endStr = a.endDateTime;
-      if (endStr) {
-        const d = new Date(endStr);
-        const endMins = d.getHours() * 60 + d.getMinutes();
-        if (endMins > max) max = endMins;
-      }
-    }
-    return max > baseEndMins ? Math.ceil(max / 60) * 60 : baseEndMins;
-  }, [dayAssignments, baseEndMins]);
-
-  const totalMinutes = dayEndMins - dayStartMins;
-  const hourSlots = useMemo(() => getHourSlots(workDayStart, minutesToTime(dayEndMins)), [workDayStart, dayEndMins]);
-
-  if (technicians.length === 0) {
-    return <div className="flex flex-1 items-center justify-center"><p className="text-lg text-muted-foreground">{t("noTechnicians")}</p></div>;
-  }
-
-  return (
-    <div className="flex flex-1 flex-col overflow-auto">
-      <div className="min-w-[900px]">
-        <div className="flex border-b">
-          <div className="w-[180px] shrink-0 border-r p-2" />
-          <div className="relative flex-1">
-            <div className="flex">
-              {hourSlots.map((slot) => (
-                <div key={slot} className="flex-1 border-r px-1 py-2 text-center text-xs font-semibold text-muted-foreground">{slot}</div>
-              ))}
-            </div>
-          </div>
-        </div>
-
-        {technicians.map((tech) => {
-          const techJobs = dayAssignments.filter((a) => a.technicianId === tech.id).sort((a, b) => a.sortOrder - b.sortOrder);
-          const bookedMinutes = techJobs.reduce((sum, a) => {
-            const { start, end } = getJobDateRange(a);
-            return start && end ? sum + getDurationMinutes(start, end) : sum;
-          }, 0);
-
-          return (
-            <div key={tech.id} className="flex border-b last:border-b-0">
-              <div className="flex w-[180px] shrink-0 flex-col justify-center gap-1 border-r p-3">
-                <div className="flex items-center gap-2">
-                  <div className="h-3.5 w-3.5 shrink-0 rounded-full" style={{ backgroundColor: tech.color }} />
-                  <span className="text-sm font-semibold">{tech.name}</span>
-                </div>
-                <span className="text-[11px] text-muted-foreground">
-                  {techJobs.length === 1 ? t("job", { count: techJobs.length }) : t("jobs", { count: techJobs.length })}
-                  {" · "}
-                  {formatDuration(bookedMinutes)}
-                </span>
-              </div>
-
-              <div className="relative flex-1" style={{ minHeight: "64px" }}>
-                <div className="absolute inset-0 flex pointer-events-none">
-                  {hourSlots.map((slot) => (<div key={slot} className="flex-1 border-r border-dashed border-border/50" />))}
-                </div>
-                <PresenterCurrentTime dayStartMins={dayStartMins} totalMinutes={totalMinutes} date={date} />
-
-                {techJobs.map((job, jobIdx) => {
-                  const startStr = job.startDateTime;
-                  const endStr = job.endDateTime;
-                  let startMins = dayStartMins, endMins = dayStartMins + 60;
-                  if (startStr) { const d = new Date(startStr); startMins = d.getHours() * 60 + d.getMinutes(); }
-                  if (endStr) { const d = new Date(endStr); endMins = d.getHours() * 60 + d.getMinutes(); }
-                  const isUnscheduled = !startStr || !endStr;
-                  const leftPct = ((startMins - dayStartMins) / totalMinutes) * 100;
-                  const widthPct = ((endMins - startMins) / totalMinutes) * 100;
-                  const isServiceRecord = job.type === "serviceRecord";
-
-                  return (
-                    <div
-                      key={job.id}
-                      className={cn("absolute top-1 bottom-1 z-[1] flex items-center overflow-hidden rounded shadow-sm select-none", BAR_COLORS[jobIdx % BAR_COLORS.length], isUnscheduled && "border border-dashed border-current/30 opacity-70")}
-                      style={{ left: `${Math.max(leftPct, 0)}%`, width: `${Math.max(widthPct, 1)}%` }}
-                      title={`${minutesToTime(startMins)}–${minutesToTime(endMins)} · ${job.title}${job.vehicle ? ` - ${job.vehicle.make} ${job.vehicle.model}` : ""}`}
-                    >
-                      <div className="flex items-center gap-1.5 px-3 min-w-0 text-xs font-semibold">
-                        {isServiceRecord ? <Wrench className="h-3.5 w-3.5 shrink-0" /> : <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />}
-                        <span className="truncate">{job.title}{job.vehicle ? ` - ${job.vehicle.licensePlate || `${job.vehicle.make} ${job.vehicle.model}`}` : ""}</span>
-                        <span className="ml-auto shrink-0 text-[10px] opacity-75">{minutesToTime(startMins)}–{minutesToTime(endMins)}</span>
-                      </div>
-                    </div>
-                  );
-                })}
-              </div>
-            </div>
-          );
-        })}
-      </div>
-    </div>
-  );
-}
-
-function PresenterCurrentTime({ dayStartMins, totalMinutes, date }: { dayStartMins: number; totalMinutes: number; date: string }) {
-  const [now, setNow] = useState(() => new Date());
-  useEffect(() => { const timer = setInterval(() => setNow(new Date()), 60000); return () => clearInterval(timer); }, []);
-  const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
-  if (date !== today) return null;
-  const nowMins = now.getHours() * 60 + now.getMinutes();
-  const leftPct = ((nowMins - dayStartMins) / totalMinutes) * 100;
-  if (leftPct < 0 || leftPct > 100) return null;
-  return (
-    <div className="absolute top-0 bottom-0 z-10 w-0.5 bg-red-500 pointer-events-none" style={{ left: `${leftPct}%` }}>
-      <div className="absolute -top-1 -left-1 h-2.5 w-2.5 rounded-full bg-red-500" />
-    </div>
-  );
-}

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

@@ -1,88 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import type { WorkBoardJob } from "../Actions/boardActions";
-import type { Technician } from "../store/workboardStore";
-import { BoardDayCell } from "./BoardDayCell";
-import { formatDuration } from "./DurationSlider";
-import { jobOverlapsDate, getJobDateRange, getDurationMinutes } from "../utils/datetime";
-
-export function TechnicianRow({
-  technician,
-  days,
-  assignments,
-  onCardClick,
-  onTechClick,
-}: {
-  technician: Technician;
-  days: string[];
-  assignments: WorkBoardJob[];
-  onCardClick: (job: WorkBoardJob) => void;
-  onTechClick?: (technician: Technician) => void;
-}) {
-  const techAssignments = assignments.filter(
-    (a) => a.technicianId === technician.id,
-  );
-
-  const weekBooked = techAssignments.reduce((sum, a) => {
-    const { start, end } = getJobDateRange(a);
-    if (start && end) return sum + getDurationMinutes(start, end);
-    return sum;
-  }, 0);
-  const weekCapacity = technician.dailyCapacity * days.length;
-  const weekPct = weekCapacity > 0 ? Math.round((weekBooked / weekCapacity) * 100) : 0;
-
-  return (
-    <div className="contents">
-      <button
-        type="button"
-        className="flex flex-col gap-1.5 rounded-md bg-muted/50 p-2 text-left transition-colors hover:bg-muted"
-        onClick={() => onTechClick?.(technician)}
-      >
-        <div className="flex items-center gap-2">
-          <div
-            className="h-3 w-3 shrink-0 rounded-full"
-            style={{ backgroundColor: technician.color }}
-          />
-          <span className="text-sm font-medium leading-tight">
-            {technician.name}
-          </span>
-        </div>
-        <div className="w-full space-y-0.5">
-          <div className="h-1 w-full overflow-hidden rounded-full bg-background">
-            <div
-              className={cn(
-                "h-full rounded-full transition-all",
-                weekPct > 100
-                  ? "bg-red-500"
-                  : weekPct >= 75
-                    ? "bg-amber-500"
-                    : "bg-emerald-500",
-              )}
-              style={{ width: `${Math.min(weekPct, 100)}%` }}
-            />
-          </div>
-          <p className="text-[10px] text-muted-foreground">
-            {formatDuration(weekBooked)} / {formatDuration(weekCapacity)}
-          </p>
-        </div>
-      </button>
-
-      {days.map((day) => {
-        const cellAssignments = techAssignments.filter(
-          (a) => jobOverlapsDate(a, day),
-        );
-        return (
-          <BoardDayCell
-            key={day}
-            technicianId={technician.id}
-            date={day}
-            assignments={cellAssignments}
-            dailyCapacity={technician.dailyCapacity}
-            onCardClick={onCardClick}
-          />
-        );
-      })}
-    </div>
-  );
-}

+ 92 - 34
src/features/workboard/Components/UnassignedJobsPanel.tsx

@@ -1,66 +1,66 @@
-"use client";
+'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 { useDroppable } from "@dnd-kit/core";
-import { useWorkBoardStore } from "../store/workboardStore";
-import { UnassignedJobCard } from "./BoardJobCard";
-import { useTranslations } from "next-intl";
-import { cn } from "@/lib/utils";
+import { useState } from 'react'
+import { Search } from 'lucide-react'
+import { Input } from '@/components/ui/input'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { useDroppable } from '@dnd-kit/core'
+import { useWorkBoardStore } from '../store/workboardStore'
+import { UnassignedJobCard } from './BoardJobCard'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
 
 
 export function UnassignedJobsPanel() {
 export function UnassignedJobsPanel() {
-  const serviceRecords = useWorkBoardStore((s) => s.unassignedServiceRecords);
-  const inspections = useWorkBoardStore((s) => s.unassignedInspections);
-  const [search, setSearch] = useState("");
-  const t = useTranslations("workBoard.unassigned");
+  const serviceRecords = useWorkBoardStore((s) => s.unassignedServiceRecords)
+  const inspections = useWorkBoardStore((s) => s.unassignedInspections)
+  const [search, setSearch] = useState('')
+  const t = useTranslations('workBoard.unassigned')
 
 
   const { isOver, setNodeRef } = useDroppable({
   const { isOver, setNodeRef } = useDroppable({
-    id: "unassigned-drop",
+    id: 'unassigned-drop',
     data: { unassigned: true },
     data: { unassigned: true },
-  });
+  })
 
 
-  const lowerSearch = search.toLowerCase();
+  const lowerSearch = search.toLowerCase()
 
 
   const filteredSR = serviceRecords.filter((sr) => {
   const filteredSR = serviceRecords.filter((sr) => {
-    if (!search) return true;
+    if (!search) return true
     return (
     return (
       sr.title.toLowerCase().includes(lowerSearch) ||
       sr.title.toLowerCase().includes(lowerSearch) ||
       sr.vehicle?.make.toLowerCase().includes(lowerSearch) ||
       sr.vehicle?.make.toLowerCase().includes(lowerSearch) ||
       sr.vehicle?.model.toLowerCase().includes(lowerSearch) ||
       sr.vehicle?.model.toLowerCase().includes(lowerSearch) ||
       sr.vehicle?.licensePlate?.toLowerCase().includes(lowerSearch)
       sr.vehicle?.licensePlate?.toLowerCase().includes(lowerSearch)
-    );
-  });
+    )
+  })
 
 
   const filteredInsp = inspections.filter((i) => {
   const filteredInsp = inspections.filter((i) => {
-    if (!search) return true;
+    if (!search) return true
     return (
     return (
       i.template.name.toLowerCase().includes(lowerSearch) ||
       i.template.name.toLowerCase().includes(lowerSearch) ||
       i.vehicle.make.toLowerCase().includes(lowerSearch) ||
       i.vehicle.make.toLowerCase().includes(lowerSearch) ||
       i.vehicle.model.toLowerCase().includes(lowerSearch) ||
       i.vehicle.model.toLowerCase().includes(lowerSearch) ||
       i.vehicle.licensePlate?.toLowerCase().includes(lowerSearch)
       i.vehicle.licensePlate?.toLowerCase().includes(lowerSearch)
-    );
-  });
+    )
+  })
 
 
-  const totalCount = filteredSR.length + filteredInsp.length;
+  const totalCount = filteredSR.length + filteredInsp.length
 
 
   return (
   return (
     <div
     <div
       ref={setNodeRef}
       ref={setNodeRef}
       className={cn(
       className={cn(
-        "flex w-64 shrink-0 flex-col rounded-lg border bg-muted/30 transition-colors",
-        isOver && "ring-2 ring-primary/50 bg-primary/5"
+        // The sidebar is a desktop affordance: below md the board needs every
+        // pixel of width it has, so the same list opens as a drawer instead.
+        'hidden w-64 shrink-0 flex-col rounded-lg border bg-muted/30 transition-colors md:flex',
+        isOver && 'ring-2 ring-primary/50 bg-primary/5'
       )}
       )}
     >
     >
       <div className="border-b p-3">
       <div className="border-b p-3">
-        <h3 className="mb-2 text-sm font-semibold">
-          {t("title", { count: totalCount })}
-        </h3>
+        <h3 className="mb-2 text-sm font-semibold">{t('title', { count: totalCount })}</h3>
         <div className="relative">
         <div className="relative">
           <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
           <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
           <Input
           <Input
-            placeholder={t("searchPlaceholder")}
+            placeholder={t('searchPlaceholder')}
             value={search}
             value={search}
             onChange={(e) => setSearch(e.target.value)}
             onChange={(e) => setSearch(e.target.value)}
             className="h-8 pl-7 text-xs"
             className="h-8 pl-7 text-xs"
@@ -76,12 +76,70 @@ export function UnassignedJobsPanel() {
             <UnassignedJobCard key={insp.id} job={insp} type="inspection" />
             <UnassignedJobCard key={insp.id} job={insp} type="inspection" />
           ))}
           ))}
           {totalCount === 0 && (
           {totalCount === 0 && (
-            <p className="py-8 text-center text-xs text-muted-foreground">
-              {t("empty")}
-            </p>
+            <p className="py-8 text-center text-xs text-muted-foreground">{t('empty')}</p>
           )}
           )}
         </div>
         </div>
       </ScrollArea>
       </ScrollArea>
     </div>
     </div>
-  );
+  )
+}
+
+/**
+ * The same list, for the drawer that replaces the sidebar on a phone.
+ *
+ * Not a drop target: while the drawer is open it covers the board, so there is
+ * nowhere to drop onto. It is for seeing and searching what is waiting; the
+ * dragging happens on a screen wide enough to show both at once.
+ */
+export function UnassignedJobsList() {
+  const serviceRecords = useWorkBoardStore((s) => s.unassignedServiceRecords)
+  const inspections = useWorkBoardStore((s) => s.unassignedInspections)
+  const [search, setSearch] = useState('')
+  const t = useTranslations('workBoard.unassigned')
+
+  const lowerSearch = search.toLowerCase()
+  const filteredSR = serviceRecords.filter(
+    (sr) =>
+      !search ||
+      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) =>
+      !search ||
+      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 min-h-0 flex-1 flex-col">
+      <div className="relative px-4 pb-3">
+        <Search className="absolute left-6 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
+        <Input
+          placeholder={t('searchPlaceholder')}
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          className="h-9 pl-7"
+        />
+      </div>
+      <ScrollArea className="min-h-0 flex-1">
+        <div className="flex flex-col gap-1 px-4 pb-4">
+          {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">{t('empty')}</p>
+          )}
+        </div>
+      </ScrollArea>
+    </div>
+  )
 }
 }

+ 232 - 0
src/features/workboard/Components/WeekCardGrid.tsx

@@ -0,0 +1,232 @@
+'use client'
+
+import { useDroppable } from '@dnd-kit/core'
+import { ClipboardCheck, Wrench } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import type { WorkBoardJob } from '../Actions/boardActions'
+import { getDurationMinutes, getJobDateRange, jobOverlapsDate } from '../utils/datetime'
+import type { ClockFormat } from '../utils/clock'
+import type { BoardLane } from '../utils/lanes'
+import { BoardJobCard } from './BoardJobCard'
+import { formatDuration } from './DurationSlider'
+import { JobTooltip } from './JobTooltip'
+import { LaneHeaderTooltip } from './LaneHeaderTooltip'
+
+/**
+ * The week as a list per lane per day.
+ *
+ * The timeline answers "when and for how long"; this answers "who has what",
+ * and it is the layout that survives a big team: fifteen technicians is fifteen
+ * rows and five columns, which fits a screen, where the timeline would be
+ * seventy-five columns and three screens of sideways scrolling.
+ */
+export function WeekCardGrid({
+  days,
+  lanes,
+  jobsByLane,
+  todayStr,
+  timeFormat,
+  lookup,
+  readOnly = false,
+  onOpenJob,
+  onLaneClick,
+}: {
+  days: string[]
+  lanes: BoardLane[]
+  jobsByLane: Map<string, WorkBoardJob[]>
+  todayStr: string
+  timeFormat: ClockFormat
+  /** Technicians and bays by id, for naming both in a job's tooltip. */
+  lookup?: Map<string, { name: string; color: string }>
+  /** Wall-display mode: the same grid, with nothing to pick up. */
+  readOnly?: boolean
+  onOpenJob: (job: WorkBoardJob) => void
+  onLaneClick?: (lane: BoardLane) => void
+}) {
+  const t = useTranslations('workBoard.board')
+
+  const gridTemplateColumns = `minmax(120px, 160px) repeat(${days.length}, minmax(140px, 1fr))`
+
+  return (
+    <div className="min-h-0 min-w-0 flex-1 overflow-auto rounded-md border">
+      <div className="grid min-w-full gap-px bg-border/60" style={{ gridTemplateColumns }}>
+        <div className="sticky left-0 top-0 z-30 bg-background" />
+        {days.map((day) => {
+          const date = new Date(`${day}T12:00:00`)
+          const isToday = day === todayStr
+          return (
+            <div
+              key={`head-${day}`}
+              className={cn(
+                'sticky top-0 z-20 bg-background px-2 py-1 text-center text-xs font-medium',
+                isToday ? 'text-primary' : 'text-muted-foreground'
+              )}
+            >
+              {t(`days.${DAY_KEYS[date.getDay()]}`)} {date.getDate()}
+            </div>
+          )
+        })}
+
+        {lanes.map((lane) => {
+          const laneJobs = jobsByLane.get(lane.id) ?? []
+          return (
+            <div key={lane.id} className="contents">
+              <LaneHeaderTooltip
+                lane={lane}
+                jobs={laneJobs}
+                days={days}
+                capacityMinutes={lane.dailyCapacity * days.length}
+                periodLabel={t('lanes.thisWeek')}
+              >
+                <button
+                  type="button"
+                  disabled={lane.isPlaceholder || readOnly || !onLaneClick}
+                  onClick={() => onLaneClick?.(lane)}
+                  className="sticky left-0 z-10 flex flex-col justify-center gap-1 bg-background p-2 text-left disabled:cursor-default"
+                >
+                  <span className="flex items-center gap-2">
+                    <span
+                      className="h-3 w-3 shrink-0 rounded-full"
+                      style={{ backgroundColor: lane.color }}
+                    />
+                    <span className="truncate text-sm font-medium">{lane.name}</span>
+                  </span>
+                </button>
+              </LaneHeaderTooltip>
+
+              {days.map((day) => (
+                <DayCell
+                  key={`${lane.id}-${day}`}
+                  lane={lane}
+                  date={day}
+                  jobs={laneJobs.filter((job) => jobOverlapsDate(job, day))}
+                  isToday={day === todayStr}
+                  readOnly={readOnly}
+                  timeFormat={timeFormat}
+                  lookup={lookup}
+                  onOpenJob={onOpenJob}
+                />
+              ))}
+            </div>
+          )
+        })}
+      </div>
+    </div>
+  )
+}
+
+const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const
+
+function DayCell({
+  lane,
+  date,
+  jobs,
+  isToday,
+  readOnly,
+  timeFormat,
+  lookup,
+  onOpenJob,
+}: {
+  lane: BoardLane
+  date: string
+  jobs: WorkBoardJob[]
+  isToday: boolean
+  readOnly?: boolean
+  timeFormat: ClockFormat
+  lookup?: Map<string, { name: string; color: string }>
+  onOpenJob: (job: WorkBoardJob) => void
+}) {
+  const { isOver, setNodeRef } = useDroppable({
+    id: `card::${lane.id}::${date}`,
+    data: { laneId: lane.id, date, week: true },
+    disabled: readOnly,
+  })
+
+  const booked = jobs.reduce((sum, job) => {
+    const { start, end } = getJobDateRange(job)
+    return start && end ? sum + getDurationMinutes(start, end) : sum
+  }, 0)
+  const pct = lane.dailyCapacity > 0 ? (booked / lane.dailyCapacity) * 100 : 0
+
+  return (
+    <div
+      ref={setNodeRef}
+      className={cn(
+        'flex min-h-[84px] flex-col gap-1 bg-background p-1.5 transition-colors',
+        isToday && 'bg-primary/[0.04]',
+        isOver && 'bg-primary/10'
+      )}
+    >
+      {jobs.map((job) => (
+        <JobTooltip
+          key={job.id}
+          job={job}
+          timeFormat={timeFormat}
+          ownerName={job.technicianId ? lookup?.get(job.technicianId)?.name : null}
+          ownerColor={job.technicianId ? lookup?.get(job.technicianId)?.color : null}
+          bayName={job.workBayId ? lookup?.get(job.workBayId)?.name : null}
+        >
+          <div>
+            {readOnly ? (
+              <StaticJobCard job={job} />
+            ) : (
+              <BoardJobCard job={job} onClick={() => onOpenJob(job)} />
+            )}
+          </div>
+        </JobTooltip>
+      ))}
+
+      {booked > 0 && (
+        <div className="mt-auto space-y-0.5 pt-1">
+          <div className="h-1 w-full overflow-hidden rounded-full bg-muted">
+            <div
+              className={cn(
+                'h-full rounded-full',
+                pct > 100 ? 'bg-red-500' : pct >= 75 ? 'bg-amber-500' : 'bg-emerald-500'
+              )}
+              style={{ width: `${Math.min(pct, 100)}%` }}
+            />
+          </div>
+          <p
+            className={cn(
+              'text-center text-[10px] font-medium tabular-nums',
+              pct > 100 ? 'text-red-500' : 'text-muted-foreground'
+            )}
+          >
+            {formatDuration(booked)}
+            {lane.dailyCapacity > 0 ? ` / ${formatDuration(lane.dailyCapacity)}` : ''}
+          </p>
+        </div>
+      )}
+    </div>
+  )
+}
+
+/**
+ * The same card without a grip or a drag listener.
+ *
+ * A wall display should not offer to pick anything up: the grip appearing on
+ * hover invites a drag that would do nothing, because the presenter wires no
+ * drop handlers at all.
+ */
+function StaticJobCard({ job }: { job: WorkBoardJob }) {
+  return (
+    <div className="rounded-md border bg-card p-1.5 text-xs shadow-sm">
+      <div className="flex items-center gap-1">
+        {job.type === 'serviceRecord' ? (
+          <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">{job.title}</span>
+      </div>
+      {job.vehicle && (
+        <p className="truncate text-muted-foreground">
+          {job.vehicle.year} {job.vehicle.make} {job.vehicle.model}
+          {job.vehicle.licensePlate ? ` · ${job.vehicle.licensePlate}` : ''}
+        </p>
+      )}
+    </div>
+  )
+}

+ 81 - 0
src/features/workboard/Components/WeekTimeline/UnscheduledStrip.tsx

@@ -0,0 +1,81 @@
+'use client'
+
+import { useDraggable } from '@dnd-kit/core'
+import { CalendarClock, ClipboardCheck, Wrench } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import type { WorkBoardJob } from '../../Actions/boardActions'
+
+/** Draggable id prefix, so the board's drop handler can tell these apart. */
+export const UNSCHEDULED_DRAG_PREFIX = 'unscheduled-'
+
+/**
+ * Work that has a lane but no time.
+ *
+ * A job can be given to a technician without anyone saying when it happens, and
+ * on a timeline there is nowhere to draw it. Rather than dropping it at the
+ * start of the shift and implying a booking nobody made, it waits here until
+ * someone drags it onto the week.
+ */
+export function UnscheduledStrip({
+  jobs,
+  onOpenJob,
+}: {
+  jobs: WorkBoardJob[]
+  onOpenJob: (job: WorkBoardJob) => void
+}) {
+  const t = useTranslations('workBoard.week')
+  if (jobs.length === 0) return null
+
+  return (
+    <div className="flex flex-wrap items-center gap-1.5 rounded-md border border-dashed bg-muted/30 px-2 py-1.5">
+      <span className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground">
+        <CalendarClock className="h-3.5 w-3.5" />
+        {t('unscheduled', { count: jobs.length })}
+      </span>
+      {jobs.map((job) => (
+        <UnscheduledChip key={job.id} job={job} onOpen={onOpenJob} />
+      ))}
+    </div>
+  )
+}
+
+function UnscheduledChip({
+  job,
+  onOpen,
+}: {
+  job: WorkBoardJob
+  onOpen: (job: WorkBoardJob) => void
+}) {
+  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
+    id: `${UNSCHEDULED_DRAG_PREFIX}${job.id}`,
+    data: { job, unscheduled: true },
+  })
+
+  const vehicleLabel = job.vehicle
+    ? job.vehicle.licensePlate || `${job.vehicle.make} ${job.vehicle.model}`
+    : null
+
+  return (
+    <button
+      ref={setNodeRef}
+      type="button"
+      {...listeners}
+      {...attributes}
+      data-pan-ignore
+      onClick={() => onOpen(job)}
+      className={cn(
+        'flex max-w-[220px] cursor-grab items-center gap-1 rounded border bg-card px-1.5 py-0.5 text-[11px] shadow-sm touch-none active:cursor-grabbing',
+        isDragging && 'opacity-50'
+      )}
+    >
+      {job.type === 'serviceRecord' ? (
+        <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">{job.title}</span>
+      {vehicleLabel && <span className="truncate text-muted-foreground">{vehicleLabel}</span>}
+    </button>
+  )
+}

+ 180 - 0
src/features/workboard/Components/WeekTimeline/WeekJobBlock.tsx

@@ -0,0 +1,180 @@
+'use client'
+
+import { useRef } from 'react'
+import { ChevronDown, ChevronUp, ClipboardCheck, Wrench } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import type { WorkBoardJob } from '../../Actions/boardActions'
+import { type ClockFormat, formatClockRange } from '../../utils/clock'
+import { JobTooltip } from '../JobTooltip'
+import { statusBlockColor } from '../../utils/job-colors'
+import type { PositionedJob, TimeWindow } from '../../utils/layout'
+import { percentForMinutes, percentForSpan } from './geometry'
+import type { WeekDragMode } from './useWeekDrag'
+
+/**
+ * How much a block says, decided by how long the job is rather than by how many
+ * pixels tall it came out. Duration is data and is known before anything is
+ * laid out; pixels are not, and asking for them was what made the board depend
+ * on a measurement that could arrive wrong or not at all.
+ */
+const VEHICLE_FROM_MINUTES = 25
+const TIMES_FROM_MINUTES = 75
+/** Short jobs still get a floor, so a 15-minute block is clickable. */
+const MIN_BLOCK_HEIGHT = 14
+
+export function WeekJobBlock({
+  positioned,
+  window: timeWindow,
+  isDragging,
+  readOnly,
+  timeFormat,
+  laneColor,
+  owner,
+  technician,
+  bayName,
+  onOpen,
+  onDragHandle,
+}: {
+  positioned: PositionedJob
+  window: TimeWindow
+  isDragging: boolean
+  readOnly?: boolean
+  timeFormat: ClockFormat
+  laneColor: string
+  /** Who the job belongs to, when the column itself does not say. */
+  owner?: { name: string; color: string } | null
+  /** Named in the tooltip whatever the columns are grouped by. */
+  technician?: { name: string; color: string } | null
+  bayName?: string | null
+  onOpen: (job: WorkBoardJob) => void
+  onDragHandle: (event: React.PointerEvent, job: WorkBoardJob, mode: WeekDragMode) => void
+}) {
+  const { job, column, columns, continuesBefore, continuesAfter } = positioned
+  const width = 100 / columns
+  const durationMinutes = positioned.endMins - positioned.startMins
+  const isServiceRecord = job.type === 'serviceRecord'
+  const timeLabel = formatClockRange(positioned.startMins, positioned.endMins, timeFormat)
+  const vehicleLabel = job.vehicle
+    ? job.vehicle.licensePlate || `${job.vehicle.make} ${job.vehicle.model}`
+    : null
+
+  const pressOrigin = useRef<{ x: number; y: number } | null>(null)
+
+  const pressStart = (event: React.PointerEvent) => {
+    if (readOnly) return
+    pressOrigin.current = { x: event.clientX, y: event.clientY }
+    // A job carried in from another day is only a view of work that starts
+    // elsewhere; moving it from here would silently reschedule that day too.
+    if (continuesBefore) return
+    onDragHandle(event, job, 'move')
+  }
+
+  return (
+    <JobTooltip
+      job={job}
+      timeFormat={timeFormat}
+      ownerName={technician?.name}
+      ownerColor={technician?.color}
+      bayName={bayName}
+    >
+      <div
+        role="button"
+        tabIndex={0}
+        // The board pans when the background is dragged; a job owns its own drag.
+        data-pan-ignore
+        aria-label={`${timeLabel} ${job.title}`}
+        className={cn(
+          'group absolute overflow-hidden rounded-md border border-black/10 px-1.5 py-0.5 text-[11px] leading-tight shadow-sm select-none',
+          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+          statusBlockColor(job.status),
+          continuesBefore && 'rounded-t-none border-t-dashed',
+          continuesAfter && 'rounded-b-none border-b-dashed',
+          isDragging ? 'z-20 opacity-90 ring-2 ring-primary' : 'z-10',
+          readOnly
+            ? 'cursor-default'
+            : continuesBefore
+              ? 'cursor-pointer'
+              : 'cursor-grab active:cursor-grabbing'
+        )}
+        style={{
+          top: `${percentForMinutes(positioned.startMins, timeWindow)}%`,
+          height: `${percentForSpan(durationMinutes, timeWindow)}%`,
+          minHeight: MIN_BLOCK_HEIGHT,
+          left: `calc(${column * width}% + 1px)`,
+          width: `calc(${width}% - 2px)`,
+          borderLeft: `3px solid ${owner?.color ?? laneColor}`,
+          touchAction: 'none',
+        }}
+        onPointerDown={pressStart}
+        onClick={(event) => {
+          // A release that travelled is the end of a drag, not a click on the job.
+          const origin = pressOrigin.current
+          pressOrigin.current = null
+          if (origin) {
+            const travelled =
+              Math.abs(event.clientX - origin.x) + Math.abs(event.clientY - origin.y)
+            if (travelled > 4) return
+          }
+          onOpen(job)
+        }}
+        onKeyDown={(event) => {
+          if (event.key === 'Enter' || event.key === ' ') {
+            event.preventDefault()
+            onOpen(job)
+          }
+        }}
+      >
+        {!continuesBefore && !readOnly && (
+          <div
+            aria-hidden
+            className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-ns-resize opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/15"
+            onPointerDown={(event) => {
+              event.stopPropagation()
+              onDragHandle(event, job, 'resize-start')
+            }}
+          />
+        )}
+
+        {isDragging ? (
+          // Mid-drag the block is the only readout of where it will land, so the
+          // snapped time replaces the description entirely.
+          <div className="pointer-events-none flex h-full items-start gap-1 font-semibold tabular-nums">
+            <span className="truncate">{timeLabel}</span>
+          </div>
+        ) : (
+          <div className="pointer-events-none flex items-start gap-1">
+            {continuesBefore ? (
+              <ChevronUp className="mt-px h-3 w-3 shrink-0 opacity-70" />
+            ) : isServiceRecord ? (
+              <Wrench className="mt-px h-3 w-3 shrink-0 opacity-80" />
+            ) : (
+              <ClipboardCheck className="mt-px h-3 w-3 shrink-0 opacity-80" />
+            )}
+            <div className="min-w-0 flex-1">
+              <div className="truncate font-medium">{job.title}</div>
+              {owner && <div className="truncate font-medium opacity-90">{owner.name}</div>}
+              {durationMinutes >= VEHICLE_FROM_MINUTES && vehicleLabel && (
+                <div className="truncate opacity-80">{vehicleLabel}</div>
+              )}
+              {durationMinutes >= TIMES_FROM_MINUTES && (
+                <div className="truncate tabular-nums opacity-70">{timeLabel}</div>
+              )}
+            </div>
+            {continuesAfter && <ChevronDown className="mt-px h-3 w-3 shrink-0 opacity-70" />}
+          </div>
+        )}
+
+        {!continuesAfter && !readOnly && (
+          <div
+            aria-hidden
+            className="absolute inset-x-0 bottom-0 z-20 h-1.5 cursor-ns-resize opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/15"
+            onPointerDown={(event) => {
+              event.stopPropagation()
+              onDragHandle(event, job, 'resize-end')
+            }}
+          />
+        )}
+      </div>
+    </JobTooltip>
+  )
+}

+ 225 - 0
src/features/workboard/Components/WeekTimeline/WeekLaneColumn.tsx

@@ -0,0 +1,225 @@
+'use client'
+
+import { memo, useCallback, useMemo, useRef } from 'react'
+import { useDroppable } from '@dnd-kit/core'
+import { Plus } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import {
+  ContextMenu,
+  ContextMenuContent,
+  ContextMenuItem,
+  ContextMenuTrigger,
+} from '@/components/ui/context-menu'
+import type { WorkBoardJob } from '../../Actions/boardActions'
+import { type ClockFormat, formatClock } from '../../utils/clock'
+import type { BoardLane } from '../../utils/lanes'
+import { type TimeWindow, layoutLaneDay } from '../../utils/layout'
+import { columnKey, minutesAtPoint, percentForMinutes, percentForSpan } from './geometry'
+import { WeekJobBlock } from './WeekJobBlock'
+import type { WeekDragMode } from './useWeekDrag'
+
+/** Blocks thinner than this are unreadable, so short jobs get a floor. */
+const MIN_BLOCK_HEIGHT = 14
+
+function WeekLaneColumnImpl({
+  date,
+  lane,
+  endsDay,
+  dropGhost,
+  jobs,
+  window: timeWindow,
+  slotMinutes,
+  timeFormat,
+  ownerOf,
+  lookup,
+  workDayStart,
+  workDayEnd,
+  nowMinutes,
+  readOnly,
+  draggingJobId,
+  registerColumn,
+  onOpenJob,
+  onDragHandle,
+  onCreateJob,
+}: {
+  date: string
+  lane: BoardLane
+  /** Last lane of its day, which gets the heavier rule between days. */
+  endsDay: boolean
+  /** Where a job dragged in from outside would land, while it is still in the air. */
+  dropGhost?: { startMins: number; endMins: number } | null
+  jobs: WorkBoardJob[]
+  window: TimeWindow
+  slotMinutes: number
+  timeFormat: ClockFormat
+  /** Set when the lane column does not name the owner, so the block must. */
+  ownerOf?: (job: WorkBoardJob) => { name: string; color: string } | null
+  /** Technicians and bays by id, for naming both in a job's tooltip. */
+  lookup?: Map<string, { name: string; color: string }>
+  workDayStart: number
+  workDayEnd: number
+  /** Minutes from midnight to draw the "now" line at, or null on other days. */
+  nowMinutes: number | null
+  readOnly?: boolean
+  draggingJobId: string | null
+  registerColumn: (key: string, el: HTMLElement | null) => void
+  onOpenJob: (job: WorkBoardJob) => void
+  onDragHandle: (event: React.PointerEvent, job: WorkBoardJob, mode: WeekDragMode) => void
+  onCreateJob?: (lane: BoardLane, date: string, startMins: number) => void
+}) {
+  const t = useTranslations('workBoard.week')
+  const key = columnKey(date, lane.id)
+  const bodyRef = useRef<HTMLDivElement | null>(null)
+  const contextMinutes = useRef(timeWindow.startMins)
+
+  const { isOver, setNodeRef } = useDroppable({
+    id: `week::${key}`,
+    data: { laneId: lane.id, date, week: true },
+  })
+
+  const attachRef = useCallback(
+    (el: HTMLDivElement | null) => {
+      bodyRef.current = el
+      setNodeRef(el)
+      registerColumn(key, el)
+    },
+    [key, registerColumn, setNodeRef]
+  )
+
+  const positioned = useMemo(() => layoutLaneDay(jobs, date, timeWindow), [jobs, date, timeWindow])
+
+  // Rules as two stacked gradients rather than one element per slot. A shop
+  // with fifteen technicians draws seventy-five of these columns; at thirty
+  // elements each that was two thousand nodes of pure decoration, re-rendered
+  // on every frame of a drag. The stops are percentages, so they follow the
+  // column however tall CSS decides it should be.
+  const rules = useMemo(() => {
+    const hour = percentForSpan(60, timeWindow)
+    const slot = percentForSpan(slotMinutes, timeWindow)
+    return {
+      backgroundImage: [
+        `repeating-linear-gradient(to bottom, var(--border) 0 1px, transparent 1px ${hour}%)`,
+        `repeating-linear-gradient(to bottom, color-mix(in oklch, var(--border) 45%, transparent) 0 1px, transparent 1px ${slot}%)`,
+      ].join(', '),
+    }
+  }, [timeWindow, slotMinutes])
+
+  /** Hours outside the shop's working day, shaded top and bottom. */
+  const closedBefore = percentForSpan(Math.max(workDayStart - timeWindow.startMins, 0), timeWindow)
+  const closedAfter = percentForSpan(Math.max(timeWindow.endMins - workDayEnd, 0), timeWindow)
+
+  return (
+    <ContextMenu>
+      <ContextMenuTrigger asChild disabled={!onCreateJob}>
+        <div
+          ref={attachRef}
+          className={cn(
+            'relative',
+            // A heavier rule between days and a light one between lanes, so a
+            // week of columns still reads as five days at a glance.
+            endsDay ? 'border-r-2 border-r-border' : 'border-r border-r-border/50',
+            nowMinutes !== null && 'bg-primary/[0.03]',
+            isOver && 'bg-primary/10'
+          )}
+          onContextMenu={(event) => {
+            const rect = bodyRef.current?.getBoundingClientRect()
+            if (!rect) return
+            const raw = minutesAtPoint(event.clientY, rect, timeWindow)
+            contextMinutes.current = Math.max(
+              timeWindow.startMins,
+              Math.min(
+                Math.round(raw / slotMinutes) * slotMinutes,
+                timeWindow.endMins - slotMinutes
+              )
+            )
+          }}
+        >
+          <div aria-hidden className="absolute inset-0" style={rules} />
+          {closedBefore > 0 && (
+            <div
+              aria-hidden
+              className="absolute inset-x-0 top-0 bg-muted/40"
+              style={{ height: `${closedBefore}%` }}
+            />
+          )}
+          {closedAfter > 0 && (
+            <div
+              aria-hidden
+              className="absolute inset-x-0 bottom-0 bg-muted/40"
+              style={{ height: `${closedAfter}%` }}
+            />
+          )}
+
+          {nowMinutes !== null && <NowLine minutes={nowMinutes} window={timeWindow} />}
+
+          {dropGhost && (
+            <div
+              aria-hidden
+              className="pointer-events-none absolute inset-x-0.5 z-20 flex items-start justify-center overflow-hidden rounded-md border-2 border-dashed border-primary bg-primary/15"
+              style={{
+                top: `${percentForMinutes(dropGhost.startMins, timeWindow)}%`,
+                height: `${percentForSpan(dropGhost.endMins - dropGhost.startMins, timeWindow)}%`,
+                minHeight: MIN_BLOCK_HEIGHT,
+              }}
+            >
+              <span className="truncate px-1 text-[10px] font-semibold tabular-nums text-primary">
+                {formatClock(dropGhost.startMins, timeFormat)}
+              </span>
+            </div>
+          )}
+
+          {positioned.map((item) => (
+            <WeekJobBlock
+              key={item.job.id}
+              positioned={item}
+              window={timeWindow}
+              isDragging={draggingJobId === item.job.id}
+              readOnly={readOnly}
+              timeFormat={timeFormat}
+              laneColor={lane.color}
+              owner={ownerOf?.(item.job) ?? null}
+              technician={
+                item.job.technicianId ? (lookup?.get(item.job.technicianId) ?? null) : null
+              }
+              bayName={item.job.workBayId ? (lookup?.get(item.job.workBayId)?.name ?? null) : null}
+              onOpen={onOpenJob}
+              onDragHandle={onDragHandle}
+            />
+          ))}
+        </div>
+      </ContextMenuTrigger>
+      {onCreateJob && (
+        <ContextMenuContent>
+          <ContextMenuItem onClick={() => onCreateJob(lane, date, contextMinutes.current)}>
+            <Plus className="mr-2 h-4 w-4" />
+            {t('createAt', { time: formatClock(contextMinutes.current, timeFormat) })}
+          </ContextMenuItem>
+        </ContextMenuContent>
+      )}
+    </ContextMenu>
+  )
+}
+
+/**
+ * The current time, drawn in every column of today so the line reads straight
+ * across the day rather than stopping at one lane.
+ */
+function NowLine({ minutes, window: timeWindow }: { minutes: number; window: TimeWindow }) {
+  if (minutes < timeWindow.startMins || minutes > timeWindow.endMins) return null
+
+  return (
+    <div
+      aria-hidden
+      className="pointer-events-none absolute inset-x-0 z-20 h-px bg-red-500"
+      style={{ top: `${percentForMinutes(minutes, timeWindow)}%` }}
+    />
+  )
+}
+
+/**
+ * A week is up to eighty-five of these, and the parent re-renders on every
+ * frame of a drag to move one ghost. Without this, all of them re-laid-out
+ * their jobs to redraw a dashed rectangle in one of them.
+ */
+export const WeekLaneColumn = memo(WeekLaneColumnImpl)

+ 76 - 0
src/features/workboard/Components/WeekTimeline/geometry.ts

@@ -0,0 +1,76 @@
+import type { TimeWindow } from '../../utils/layout'
+
+/**
+ * The timeline positions everything as a percentage of its own height.
+ *
+ * The first version measured the board in pixels and divided that height among
+ * the hours. That made every position depend on a ResizeObserver having fired
+ * with a sensible number, and when it had not, the board collapsed and the zoom
+ * control did nothing. Percentages let CSS own the height: the grid row is
+ * `1fr` and blocks sit at a fraction of it, so the board is correct on the
+ * first paint with nothing measured at all.
+ */
+
+/** Minutes a job gets when it lands on the week with no duration of its own. */
+export const DEFAULT_JOB_MINUTES = 60
+
+/** Columns are addressed by the day and lane they belong to. */
+export function columnKey(date: string, laneId: string): string {
+  return `${date}::${laneId}`
+}
+
+export function parseColumnKey(key: string): { date: string; laneId: string } {
+  const separator = key.indexOf('::')
+  return {
+    date: key.slice(0, separator),
+    laneId: key.slice(separator + 2),
+  }
+}
+
+export function windowMinutes(window: TimeWindow): number {
+  return Math.max(window.endMins - window.startMins, 1)
+}
+
+/** How far down the column a time of day sits, as a percentage. */
+export function percentForMinutes(mins: number, window: TimeWindow): number {
+  return ((mins - window.startMins) / windowMinutes(window)) * 100
+}
+
+/** How tall a span of minutes is, as a percentage of the column. */
+export function percentForSpan(minutes: number, window: TimeWindow): number {
+  return (minutes / windowMinutes(window)) * 100
+}
+
+/**
+ * The time of day a pointer is over.
+ *
+ * Derived from the column's own box, so it stays right whatever height the
+ * board ended up with, including mid-zoom and mid-resize.
+ */
+export function minutesAtPoint(clientY: number, rect: DOMRect, window: TimeWindow): number {
+  if (rect.height <= 0) return window.startMins
+  const ratio = (clientY - rect.top) / rect.height
+  return window.startMins + ratio * windowMinutes(window)
+}
+
+/** Pixels per minute implied by a rendered column, for turning drags into time. */
+export function pxPerMinuteOf(rect: DOMRect, window: TimeWindow): number {
+  return rect.height > 0 ? rect.height / windowMinutes(window) : 0
+}
+
+/**
+ * Where a dnd-kit drag currently is, in client coordinates.
+ *
+ * `activatorEvent` is the pointer-down that started the drag and `delta` is how
+ * far it has moved since, so the two together are the live pointer. Shared by
+ * the drop-preview and the drop itself: if these ever disagreed, jobs would
+ * land somewhere other than the ghost the person was aiming with.
+ */
+export function pointerFromDndEvent(event: {
+  activatorEvent: Event
+  delta: { x: number; y: number }
+}): { x: number; y: number } | null {
+  const activator = event.activatorEvent as { clientX?: number; clientY?: number }
+  if (activator?.clientX === undefined || activator.clientY === undefined) return null
+  return { x: activator.clientX + event.delta.x, y: activator.clientY + event.delta.y }
+}

+ 578 - 0
src/features/workboard/Components/WeekTimeline/index.tsx

@@ -0,0 +1,578 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useDndMonitor } from '@dnd-kit/core'
+import { ChevronLeft, ChevronRight } from 'lucide-react'
+import { useLocale, useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import { useDateSettings } from '@/components/date-settings-context'
+import type { WorkBoardJob } from '../../Actions/boardActions'
+import { ZOOM_STEP, clampZoom } from '../../hooks/useBoardPreferences'
+import { timeToMinutes } from '../../utils/datetime'
+import { formatClock } from '../../utils/clock'
+import {
+  type BoardLane,
+  type LaneGrouping,
+  UNLANED,
+  groupJobsByLane,
+  laneIdForJob,
+} from '../../utils/lanes'
+import {
+  MINUTES_IN_DAY,
+  bookedMinutesOnDay,
+  computeTimeWindow,
+  hourMarks,
+  isUnscheduled,
+  resolveJobOnDay,
+} from '../../utils/layout'
+import { LaneHeaderTooltip } from '../LaneHeaderTooltip'
+import { UnscheduledStrip } from './UnscheduledStrip'
+import { WeekLaneColumn } from './WeekLaneColumn'
+import {
+  DEFAULT_JOB_MINUTES,
+  columnKey,
+  percentForMinutes,
+  pointerFromDndEvent,
+  windowMinutes,
+} from './geometry'
+import { useDragToPan } from './useDragToPan'
+import { type WeekDropTarget, useWeekDrag } from './useWeekDrag'
+
+/** Quarter-hour rules once there is room for them. */
+const FINE_SLOT_FROM_ZOOM = 1.5
+
+const GUTTER_WIDTH = 60
+/** Low enough that a shop with eight bays still sees Monday to Friday at once;
+ *  tracks are `1fr` above it, so a shop with two lanes gets wide columns. */
+const MIN_COLUMN_WIDTH = 44
+/** Both header rows are fixed height: the lane row needs to know where to
+ *  stick underneath the day row, and the body needs to know how much of the
+ *  board is left for it. */
+const DAY_HEADER_HEIGHT = 26
+const LANE_HEADER_HEIGHT = 34
+/** Below this an hour is too thin to drop anything into. */
+const MIN_PX_PER_MINUTE = 0.3
+
+export type WeekScheduleChange = {
+  job: WorkBoardJob
+  laneId: string
+  start: Date
+  end: Date
+}
+
+/**
+ * The whole week on one clock.
+ *
+ * Time runs down the left and every day is split into a column per lane, which
+ * is how a workshop's own wall planner reads: what is on, in which bay, and for
+ * how long, without opening anything.
+ */
+export function WeekTimeline({
+  days,
+  hiddenDays,
+  lanes,
+  jobs,
+  grouping,
+  zoom,
+  onZoomChange,
+  snapMinutes,
+  workDayStart,
+  workDayEnd,
+  dropResolverRef,
+  onOpenJob,
+  onSchedule,
+  onCreateJob,
+  onLaneClick,
+  onShowHiddenDays,
+  onShowWholeWeek,
+  owners,
+  readOnly = false,
+}: {
+  days: string[]
+  /** Days left out of the grid, so work on them can still be accounted for. */
+  hiddenDays?: string[]
+  lanes: BoardLane[]
+  jobs: WorkBoardJob[]
+  grouping: LaneGrouping
+  /** 1 fits the day to the board; above that the hours grow and it scrolls. */
+  zoom: number
+  snapMinutes: number
+  onZoomChange?: (zoom: number) => void
+  /** "HH:MM" from workshop settings. */
+  workDayStart: string
+  workDayEnd: string
+  /**
+   * Filled with a resolver so the board's drag-and-drop handler can turn a drop
+   * point into a lane, a day and a time.
+   */
+  dropResolverRef?: React.MutableRefObject<
+    ((clientX: number, clientY: number) => WeekDropTarget | null) | null
+  >
+  onOpenJob: (job: WorkBoardJob) => void
+  onSchedule: (change: WeekScheduleChange) => void
+  onCreateJob?: (lane: BoardLane, date: string, startMins: number) => void
+  onLaneClick?: (lane: BoardLane) => void
+  onShowHiddenDays?: () => void
+  /** Collapses the board to a column per day, which always fits. */
+  onShowWholeWeek?: () => void
+  /** Technicians and bays by id, so an ungrouped board can still say whose job it is. */
+  owners?: Map<string, { name: string; color: string }>
+  /** Wall-display mode: the same board, with nothing to drag or open. */
+  readOnly?: boolean
+}) {
+  const t = useTranslations('workBoard.week')
+  const locale = useLocale()
+  const { timeFormat } = useDateSettings()
+  const scrollRef = useRef<HTMLDivElement>(null)
+  const hasScrolled = useRef(false)
+
+  const dayStartMins = timeToMinutes(workDayStart)
+  const dayEndMins = timeToMinutes(workDayEnd)
+
+  const scheduled = useMemo(() => jobs.filter((job) => !isUnscheduled(job)), [jobs])
+  const unscheduled = useMemo(() => jobs.filter(isUnscheduled), [jobs])
+
+  const timeWindow = useMemo(
+    () => computeTimeWindow(scheduled, days, dayStartMins, dayEndMins),
+    [scheduled, days, dayStartMins, dayEndMins]
+  )
+
+  const showLaneHeaders = grouping !== 'none'
+
+  // CSS owns the height. The body row is `minmax(<floor>, 1fr)`, so it fills
+  // the board when there is room and grows past it when zoomed in; everything
+  // inside is placed as a percentage of that row. Nothing has to be measured,
+  // which is what the earlier pixel arithmetic got wrong: when the measurement
+  // was missing the board collapsed and zooming changed nothing.
+  const bodyTrack = useMemo(() => {
+    const scale = zoom
+    // An absolute floor keeps an hour usable on a short window; the percentage
+    // is what makes zooming mean anything, because it is measured against the
+    // board itself. `1fr` on top of both fills any space left over, so the
+    // board is never short of the bottom of its own frame.
+    const floorPx = Math.round(windowMinutes(timeWindow) * MIN_PX_PER_MINUTE * scale)
+    if (scale === 1) return `minmax(${floorPx}px, 1fr)`
+    return `minmax(max(${floorPx}px, ${Math.round(scale * 100)}%), 1fr)`
+  }, [timeWindow, zoom])
+
+  /** Lane ids the board is actually showing, minus the catch-all itself. */
+  const knownLaneIds = useMemo(
+    () => new Set(lanes.filter((lane) => !lane.isPlaceholder).map((lane) => lane.id)),
+    [lanes]
+  )
+
+  const laneIdOf = useCallback(
+    (job: WorkBoardJob) => laneIdForJob(job, grouping, knownLaneIds),
+    [grouping, knownLaneIds]
+  )
+
+  const drag = useWeekDrag({
+    window: timeWindow,
+    snapMinutes,
+    laneIdOf,
+    onCommit: onSchedule,
+  })
+
+  useEffect(() => {
+    if (!dropResolverRef) return
+    dropResolverRef.current = drag.resolvePoint
+    return () => {
+      dropResolverRef.current = null
+    }
+  }, [dropResolverRef, drag.resolvePoint])
+
+  // A job dragged in from the unassigned panel is under the cursor as an opaque
+  // card, which says nothing about where it will actually land. This is the
+  // block it would become, drawn snapped in the target column while the drag is
+  // still in the air, from the same resolver the drop itself uses.
+  const [dropGhost, setDropGhost] = useState<{
+    key: string
+    startMins: number
+    endMins: number
+  } | null>(null)
+
+  const trackDropGhost = useCallback(
+    (event: { activatorEvent: Event; delta: { x: number; y: number } }) => {
+      const point = pointerFromDndEvent(event)
+      const target = point ? drag.resolvePoint(point.x, point.y) : null
+      if (!target || target.laneId === UNLANED) {
+        setDropGhost(null)
+        return
+      }
+      const endMins = Math.min(target.startMins + DEFAULT_JOB_MINUTES, timeWindow.endMins)
+      setDropGhost({
+        key: columnKey(target.date, target.laneId),
+        startMins: target.startMins,
+        endMins,
+      })
+    },
+    [drag.resolvePoint, timeWindow.endMins]
+  )
+
+  useDndMonitor({
+    onDragMove: trackDropGhost,
+    onDragOver: trackDropGhost,
+    onDragEnd: () => setDropGhost(null),
+    onDragCancel: () => setDropGhost(null),
+  })
+
+  // While a job is being dragged it is laid out from the preview, so it moves
+  // between lanes and days through exactly the same code that placed it.
+  const displayed = useMemo(() => {
+    const preview = drag.preview
+    if (!preview) return scheduled
+    return scheduled.map((job) => {
+      if (job.id !== preview.jobId) return job
+      const patch =
+        grouping === 'technician'
+          ? { technicianId: preview.laneId }
+          : grouping === 'bay'
+            ? { workBayId: preview.laneId }
+            : {}
+      return {
+        ...job,
+        ...patch,
+        startDateTime: new Date(preview.startMs).toISOString(),
+        endDateTime: new Date(preview.endMs).toISOString(),
+      }
+    })
+  }, [scheduled, drag.preview, grouping])
+
+  const byLane = useMemo(
+    () => groupJobsByLane(displayed, grouping, knownLaneIds),
+    [displayed, grouping, knownLaneIds]
+  )
+
+  const todayStr = useMemo(() => {
+    const now = new Date()
+    return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
+  }, [])
+  const nowMinutes = useNowMinutes()
+
+  // Work booked on a day the board is not showing would otherwise vanish
+  // without a word, which is worse than showing the day.
+  const hiddenCount = useMemo(() => {
+    if (!hiddenDays?.length) return 0
+    return scheduled.filter((job) =>
+      hiddenDays.some((day) => resolveJobOnDay(job, day, { startMins: 0, endMins: MINUTES_IN_DAY }))
+    ).length
+  }, [scheduled, hiddenDays])
+
+  const slotMinutes = zoom >= FINE_SLOT_FROM_ZOOM ? 15 : 30
+  const pan = useDragToPan(scrollRef)
+
+  // Ctrl (or command) with the wheel zooms, which is the gesture every map and
+  // canvas uses, and is what a trackpad pinch already sends. Without the
+  // modifier the wheel scrolls the board as usual.
+  useEffect(() => {
+    const el = scrollRef.current
+    if (!el || !onZoomChange) return
+    const onWheel = (event: WheelEvent) => {
+      if (!event.ctrlKey && !event.metaKey) return
+      event.preventDefault()
+      const direction = event.deltaY < 0 ? 1 : -1
+      const next = clampZoom(zoom * (1 + direction * ZOOM_STEP))
+      if (next !== zoom) onZoomChange(next)
+    }
+    el.addEventListener('wheel', onWheel, { passive: false })
+    return () => el.removeEventListener('wheel', onWheel)
+  }, [zoom, onZoomChange])
+  /** The week runs off the edge and there is a grouping that would fix it. */
+  const overflowsWeek = (pan.overflow.left || pan.overflow.right) && grouping !== 'none'
+
+  /** One day's worth of columns, so paging lands on a day boundary. */
+  const dayWidth = useCallback(() => {
+    const el = scrollRef.current
+    if (!el || days.length === 0) return MIN_COLUMN_WIDTH * lanes.length
+    return Math.max((el.scrollWidth - GUTTER_WIDTH) / days.length, MIN_COLUMN_WIDTH)
+  }, [days.length, lanes.length])
+
+  // With one column a day, nothing along the top says who a job belongs to, so
+  // the block carries its owner's colour and name instead. This is what makes
+  // the ungrouped week readable for a shop too big to give everyone a column.
+  const ownerOf = useMemo(() => {
+    if (grouping !== 'none' || !owners) return undefined
+    return (job: WorkBoardJob) =>
+      owners.get(job.technicianId ?? '') ?? owners.get(job.workBayId ?? '') ?? null
+  }, [grouping, owners])
+
+  const marks = useMemo(() => hourMarks(timeWindow), [timeWindow])
+
+  const gridTemplateColumns = `${GUTTER_WIDTH}px repeat(${days.length * lanes.length}, minmax(${MIN_COLUMN_WIDTH}px, 1fr))`
+  const gridTemplateRows = `${DAY_HEADER_HEIGHT}px ${showLaneHeaders ? `${LANE_HEADER_HEIGHT}px ` : ''}${bodyTrack}`
+
+  // Open on the current time rather than at the top of the axis: a shop looking
+  // at today wants the next hour, not the start of the shift.
+  useEffect(() => {
+    if (hasScrolled.current || !scrollRef.current) return
+    if (!days.includes(todayStr) || nowMinutes === null) return
+    hasScrolled.current = true
+    const body = scrollRef.current.scrollHeight - scrollRef.current.clientHeight
+    if (body <= 0) return
+    const ratio = percentForMinutes(nowMinutes, timeWindow) / 100
+    scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollHeight * ratio - 120)
+  }, [days, todayStr, nowMinutes, timeWindow])
+
+  if (lanes.length === 0) return null
+
+  const dayFormatter = new Intl.DateTimeFormat(locale || undefined, {
+    weekday: 'short',
+    day: 'numeric',
+    month: 'short',
+  })
+
+  return (
+    // `min-w-0` is load-bearing: a flex item's automatic minimum size is its
+    // content's, and this content is a grid eighty-five columns wide. Without
+    // it the board refuses to shrink, and the unassigned panel beside it gets
+    // pushed off the edge of the row instead.
+    <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
+      <UnscheduledStrip jobs={unscheduled} onOpenJob={onOpenJob} />
+
+      {hiddenCount > 0 && (
+        <button
+          type="button"
+          onClick={onShowHiddenDays}
+          className="self-start rounded-md border border-dashed px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted"
+        >
+          {t('hiddenDays', { count: hiddenCount })}
+        </button>
+      )}
+
+      <div className="relative min-h-0 flex-1">
+        {pan.overflow.left && (
+          <>
+            <div
+              aria-hidden
+              className="pointer-events-none absolute inset-y-0 left-0 z-40 w-10 rounded-l-md bg-linear-to-r from-background to-transparent"
+            />
+            <button
+              type="button"
+              data-pan-ignore
+              aria-label={t('panLeft')}
+              onClick={() => pan.panBy(-1, dayWidth())}
+              className="absolute left-1 top-1/2 z-40 -translate-y-1/2 rounded-full border bg-background/95 p-1.5 shadow-md transition-colors hover:bg-muted"
+            >
+              <ChevronLeft className="h-4 w-4" />
+            </button>
+          </>
+        )}
+        {pan.overflow.right && (
+          <>
+            <div
+              aria-hidden
+              className="pointer-events-none absolute inset-y-0 right-0 z-40 w-10 rounded-r-md bg-linear-to-l from-background to-transparent"
+            />
+            <button
+              type="button"
+              data-pan-ignore
+              aria-label={t('panRight')}
+              onClick={() => pan.panBy(1, dayWidth())}
+              className="absolute right-1 top-1/2 z-40 -translate-y-1/2 rounded-full border bg-background/95 p-1.5 shadow-md transition-colors hover:bg-muted"
+            >
+              <ChevronRight className="h-4 w-4" />
+            </button>
+          </>
+        )}
+        <div
+          ref={scrollRef}
+          className={cn(
+            'relative h-full overflow-auto rounded-md border',
+            pan.isPanning ? 'cursor-grabbing select-none' : 'cursor-grab'
+          )}
+        >
+          <div
+            className="grid h-full min-h-full min-w-full"
+            style={{ gridTemplateColumns, gridTemplateRows }}
+          >
+            {/* Day headers */}
+            <div
+              className="sticky left-0 top-0 z-50 border-b border-r bg-background"
+              style={{ height: DAY_HEADER_HEIGHT }}
+            />
+            {days.map((day) => {
+              const isToday = day === todayStr
+              return (
+                <div
+                  key={`day-${day}`}
+                  className={cn(
+                    'sticky top-0 z-40 border-b border-r bg-background px-2 py-1 text-center text-xs font-medium',
+                    isToday && 'text-primary'
+                  )}
+                  style={{
+                    gridColumn: `span ${lanes.length}`,
+                    height: DAY_HEADER_HEIGHT,
+                  }}
+                >
+                  {dayFormatter.format(new Date(`${day}T12:00:00`))}
+                </div>
+              )
+            })}
+
+            {/* Lane headers, repeated under each day so a column is readable
+              wherever the board has been scrolled to. */}
+            {showLaneHeaders && (
+              <>
+                <div
+                  className="sticky left-0 z-50 border-b border-r bg-background"
+                  style={{ top: DAY_HEADER_HEIGHT }}
+                />
+                {days.flatMap((day) =>
+                  lanes.map((lane, laneIndex) => {
+                    const isLastLane = laneIndex === lanes.length - 1
+                    const booked = bookedMinutesOnDay(byLane.get(lane.id) ?? [], day)
+                    const pct =
+                      lane.dailyCapacity > 0
+                        ? Math.round((booked / lane.dailyCapacity) * 100)
+                        : null
+                    return (
+                      <LaneHeaderTooltip
+                        key={`lane-${day}-${lane.id}`}
+                        lane={lane}
+                        jobs={byLane.get(lane.id) ?? []}
+                        days={[day]}
+                        capacityMinutes={lane.dailyCapacity}
+                        periodLabel={dayFormatter.format(new Date(`${day}T12:00:00`))}
+                      >
+                        <button
+                          type="button"
+                          disabled={lane.isPlaceholder || readOnly || !onLaneClick}
+                          onClick={() => onLaneClick?.(lane)}
+                          className={cn(
+                            'sticky z-40 flex flex-col justify-center gap-1 border-b bg-background px-1.5 text-left disabled:cursor-default',
+                            isLastLane ? 'border-r-2 border-r-border' : 'border-r'
+                          )}
+                          style={{ top: DAY_HEADER_HEIGHT, height: LANE_HEADER_HEIGHT }}
+                        >
+                          <span className="flex items-center gap-1">
+                            <span
+                              className="h-2 w-2 shrink-0 rounded-full"
+                              style={{ backgroundColor: lane.color }}
+                            />
+                            <span className="truncate text-[11px] font-medium">{lane.name}</span>
+                          </span>
+                          {pct !== null && (
+                            <span className="h-0.5 w-full overflow-hidden rounded-full bg-muted">
+                              <span
+                                className={cn(
+                                  'block h-full rounded-full',
+                                  pct > 100
+                                    ? 'bg-red-500'
+                                    : pct >= 75
+                                      ? 'bg-amber-500'
+                                      : 'bg-emerald-500'
+                                )}
+                                style={{ width: `${Math.min(pct, 100)}%` }}
+                              />
+                            </span>
+                          )}
+                        </button>
+                      </LaneHeaderTooltip>
+                    )
+                  })
+                )}
+              </>
+            )}
+
+            {/* Time gutter. Labels straddle their line, except the first and
+                last: centring those put half the text outside the column,
+                where the sticky header painted over it. Those two tuck inside
+                instead. */}
+            <div className="relative sticky left-0 z-30 border-r bg-background">
+              {marks.map((mins, index) => {
+                const isFirst = index === 0
+                const isLast = index === marks.length - 1
+                return (
+                  <span
+                    key={mins}
+                    className={cn(
+                      'absolute right-1 text-[10px] tabular-nums text-muted-foreground',
+                      !isFirst && !isLast && '-translate-y-1/2',
+                      isLast && '-translate-y-full'
+                    )}
+                    style={{ top: `${percentForMinutes(mins, timeWindow)}%` }}
+                  >
+                    {formatClock(mins, timeFormat)}
+                  </span>
+                )
+              })}
+            </div>
+
+            {days.flatMap((day) =>
+              lanes.map((lane, laneIndex) => (
+                <WeekLaneColumn
+                  key={`col-${day}-${lane.id}`}
+                  date={day}
+                  lane={lane}
+                  endsDay={laneIndex === lanes.length - 1}
+                  dropGhost={dropGhost?.key === columnKey(day, lane.id) ? dropGhost : null}
+                  jobs={byLane.get(lane.id) ?? []}
+                  window={timeWindow}
+                  slotMinutes={slotMinutes}
+                  timeFormat={timeFormat}
+                  ownerOf={ownerOf}
+                  lookup={owners}
+                  workDayStart={dayStartMins}
+                  workDayEnd={dayEndMins}
+                  nowMinutes={day === todayStr ? nowMinutes : null}
+                  draggingJobId={drag.isDragging ? (drag.preview?.jobId ?? null) : null}
+                  registerColumn={drag.registerColumn}
+                  readOnly={readOnly}
+                  onOpenJob={onOpenJob}
+                  onDragHandle={drag.startDrag}
+                  onCreateJob={lane.isPlaceholder || readOnly ? undefined : onCreateJob}
+                />
+              ))
+            )}
+          </div>
+        </div>
+      </div>
+
+      {!readOnly && (
+        // Every instruction in it is about a mouse: dragging edges, panning the
+        // background, Ctrl and the wheel. On a phone it is a wasted line.
+        <div className="hidden flex-wrap items-center gap-x-3 gap-y-1 px-1 text-[11px] text-muted-foreground md:flex">
+          <span>
+            {t('hint')}
+            {(pan.overflow.left || pan.overflow.right) && ` ${t('hintPan')}`}
+            {onZoomChange && ` ${t('hintZoom')}`}
+          </span>
+          {/* The question this view kept prompting was "how do I see the whole
+              week", and the answer was buried in a dropdown. It is a button
+              now, and it only shows up when the week does not already fit. */}
+          {overflowsWeek && onShowWholeWeek && (
+            <button
+              type="button"
+              onClick={onShowWholeWeek}
+              className="rounded border border-dashed px-1.5 py-0.5 font-medium text-foreground transition-colors hover:bg-muted"
+            >
+              {t('wholeWeek')}
+            </button>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+
+/**
+ * Minutes since midnight, refreshed often enough for the "now" line to creep.
+ *
+ * Starts null so the server-rendered board and the first client render agree;
+ * the clock only exists once the page is live.
+ */
+function useNowMinutes(): number | null {
+  const [minutes, setMinutes] = useState<number | null>(null)
+
+  useEffect(() => {
+    const tick = () => {
+      const now = new Date()
+      setMinutes(now.getHours() * 60 + now.getMinutes())
+    }
+    tick()
+    const timer = setInterval(tick, 30_000)
+    return () => clearInterval(timer)
+  }, [])
+
+  return minutes
+}

+ 121 - 0
src/features/workboard/Components/WeekTimeline/useDragToPan.ts

@@ -0,0 +1,121 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+
+/** Movement before a press becomes a pan rather than a click. */
+const PAN_THRESHOLD = 4
+
+/** Elements that own their own drag: jobs, buttons, the lane menu. */
+const IGNORE_SELECTOR = '[data-pan-ignore], button, a, [role="button"]'
+
+export type BoardOverflow = { left: boolean; right: boolean }
+
+/**
+ * Grab the board and pull it sideways.
+ *
+ * A week that is wider than the window is only usable if moving through it is
+ * as direct as looking at it; a scrollbar at the bottom of a tall board is a
+ * long way from where the eye is. Mouse and pen drag the background here.
+ * Touch is deliberately left alone, because a finger already pans natively and
+ * hijacking it would break the flick everyone expects.
+ */
+export function useDragToPan(ref: React.RefObject<HTMLElement | null>) {
+  const [isPanning, setIsPanning] = useState(false)
+  const [overflow, setOverflow] = useState<BoardOverflow>({ left: false, right: false })
+
+  const measure = useCallback(() => {
+    const el = ref.current
+    if (!el) return
+    const max = el.scrollWidth - el.clientWidth
+    setOverflow((current) => {
+      const next = { left: el.scrollLeft > 1, right: el.scrollLeft < max - 1 }
+      return current.left === next.left && current.right === next.right ? current : next
+    })
+  }, [ref])
+
+  /** Scroll by one day, so paging lands on a day boundary rather than mid-column. */
+  const panBy = useCallback(
+    (direction: -1 | 1, dayWidth: number) => {
+      const el = ref.current
+      if (!el) return
+      el.scrollBy({ left: direction * dayWidth, behavior: 'smooth' })
+    },
+    [ref]
+  )
+
+  useEffect(() => {
+    const el = ref.current
+    if (!el) return
+
+    let active = false
+    let moved = false
+    let startX = 0
+    let startY = 0
+    let fromLeft = 0
+    let fromTop = 0
+
+    const onPointerDown = (event: PointerEvent) => {
+      if (event.pointerType === 'touch') return
+      // Left button drags the board; middle button is the browser-wide
+      // convention for panning and is worth honouring too.
+      if (event.button !== 0 && event.button !== 1) return
+      const target = event.target as HTMLElement | null
+      if (target?.closest(IGNORE_SELECTOR)) return
+
+      // A press on the scrollbar itself is the browser's to handle. Without
+      // this the pan drags the board one way while the scrollbar drags it the
+      // other, and horizontal scrolling stops working entirely.
+      if (event.offsetX > el.clientWidth || event.offsetY > el.clientHeight) return
+
+      active = true
+      moved = false
+      startX = event.clientX
+      startY = event.clientY
+      fromLeft = el.scrollLeft
+      fromTop = el.scrollTop
+    }
+
+    const onPointerMove = (event: PointerEvent) => {
+      if (!active) return
+      const dx = event.clientX - startX
+      const dy = event.clientY - startY
+      if (!moved && Math.abs(dx) + Math.abs(dy) < PAN_THRESHOLD) return
+      if (!moved) {
+        moved = true
+        setIsPanning(true)
+      }
+      event.preventDefault()
+      el.scrollLeft = fromLeft - dx
+      el.scrollTop = fromTop - dy
+    }
+
+    const stop = () => {
+      if (!active) return
+      active = false
+      moved = false
+      setIsPanning(false)
+    }
+
+    el.addEventListener('pointerdown', onPointerDown)
+    window.addEventListener('pointermove', onPointerMove, { passive: false })
+    window.addEventListener('pointerup', stop)
+    window.addEventListener('pointercancel', stop)
+    el.addEventListener('scroll', measure, { passive: true })
+
+    measure()
+    const observer =
+      typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => measure())
+    observer?.observe(el)
+
+    return () => {
+      el.removeEventListener('pointerdown', onPointerDown)
+      window.removeEventListener('pointermove', onPointerMove)
+      window.removeEventListener('pointerup', stop)
+      window.removeEventListener('pointercancel', stop)
+      el.removeEventListener('scroll', measure)
+      observer?.disconnect()
+    }
+  }, [ref, measure])
+
+  return { isPanning, overflow, panBy, remeasure: measure }
+}

+ 391 - 0
src/features/workboard/Components/WeekTimeline/useWeekDrag.ts

@@ -0,0 +1,391 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import type { WorkBoardJob } from '../../Actions/boardActions'
+import type { TimeWindow } from '../../utils/layout'
+import { dayStartDate } from '../../utils/layout'
+import { minutesAtPoint, parseColumnKey, pxPerMinuteOf } from './geometry'
+
+export type WeekDragMode = 'move' | 'resize-start' | 'resize-end'
+
+/** Where a dragged job would land if the pointer were released now. */
+export type WeekDragPreview = {
+  jobId: string
+  startMs: number
+  endMs: number
+  laneId: string
+}
+
+export type WeekDropTarget = {
+  date: string
+  laneId: string
+  /** Minutes from midnight, already snapped. */
+  startMins: number
+}
+
+type ActiveDrag = {
+  jobId: string
+  mode: WeekDragMode
+  origStartMs: number
+  origEndMs: number
+  origLaneId: string
+  anchorX: number
+  anchorY: number
+  holdTimer?: number
+}
+
+/** Movement, in pixels, before a press becomes a drag rather than a click. */
+const DRAG_THRESHOLD = 4
+/** How long a finger must rest on a job before it starts moving it. */
+const TOUCH_HOLD_MS = 220
+const MINUTE_MS = 60_000
+
+/** Minutes from midnight of a timestamp. */
+function timeOfDay(ms: number): number {
+  const d = new Date(ms)
+  return d.getHours() * 60 + d.getMinutes()
+}
+
+function sameDay(a: number, b: number): boolean {
+  return new Date(a).toDateString() === new Date(b).toDateString()
+}
+
+/**
+ * Pull a timestamp onto the nearest snap line of its own day.
+ *
+ * Snapping the movement rather than the result was the earlier mistake: a job
+ * already sitting at 12:07 stayed seven minutes off every line no matter how it
+ * was dragged. Snapping the result means a block dropped near noon is at noon.
+ */
+function snapToGrid(ms: number, snapMinutes: number): number {
+  const date = new Date(ms)
+  const minutes = date.getHours() * 60 + date.getMinutes()
+  const snapped = Math.round(minutes / snapMinutes) * snapMinutes
+  date.setSeconds(0, 0)
+  return date.getTime() + (snapped - minutes) * MINUTE_MS
+}
+
+/**
+ * Moving and resizing jobs on the week timeline.
+ *
+ * Positions are carried as absolute timestamps rather than minutes-into-the-day
+ * so that a drag across day columns, across midnight, or over a daylight-saving
+ * change is one piece of arithmetic instead of three special cases. The hook
+ * only ever produces a preview; committing it is the caller's business.
+ */
+export function useWeekDrag({
+  window: timeWindow,
+  snapMinutes,
+  laneIdOf,
+  onCommit,
+}: {
+  window: TimeWindow
+  snapMinutes: number
+  /** Lane a job currently belongs to, under the active grouping. */
+  laneIdOf: (job: WorkBoardJob) => string
+  onCommit: (change: { job: WorkBoardJob; laneId: string; start: Date; end: Date }) => void
+}) {
+  const columns = useRef<Map<string, HTMLElement>>(new Map())
+  /**
+   * Column boxes, measured once per gesture.
+   *
+   * Every pointer move used to ask all of them for a fresh rect, and a
+   * seventeen-technician week has eighty-five columns: eighty-five forced
+   * layouts per frame, which is what made dragging a job onto the board feel
+   * heavy. Nothing moves them mid-drag except a scroll or a resize, so the
+   * measurements are taken once and thrown away when either happens.
+   */
+  const rects = useRef<Map<string, DOMRect> | null>(null)
+  const [preview, setPreview] = useState<WeekDragPreview | null>(null)
+  const [armed, setArmed] = useState(false)
+  const [isDragging, setIsDragging] = useState(false)
+
+  const activeRef = useRef<ActiveDrag | null>(null)
+  const pendingRef = useRef<ActiveDrag | null>(null)
+  const jobRef = useRef<WorkBoardJob | null>(null)
+  const previewRef = useRef<WeekDragPreview | null>(null)
+  const frameRef = useRef<number | null>(null)
+
+  const invalidateRects = useCallback(() => {
+    rects.current = null
+  }, [])
+
+  const registerColumn = useCallback(
+    (key: string, el: HTMLElement | null) => {
+      if (el) columns.current.set(key, el)
+      else columns.current.delete(key)
+      invalidateRects()
+    },
+    [invalidateRects]
+  )
+
+  // A scroll anywhere above the board moves every column, and so does a
+  // resize. Both are rare next to pointer moves, so throwing the cache away is
+  // cheaper than keeping it correct any other way.
+  useEffect(() => {
+    window.addEventListener('scroll', invalidateRects, { capture: true, passive: true })
+    window.addEventListener('resize', invalidateRects)
+    return () => {
+      window.removeEventListener('scroll', invalidateRects, { capture: true })
+      window.removeEventListener('resize', invalidateRects)
+    }
+  }, [invalidateRects])
+
+  const measuredColumns = useCallback(() => {
+    if (!rects.current) {
+      const measured = new Map<string, DOMRect>()
+      for (const [key, el] of columns.current) measured.set(key, el.getBoundingClientRect())
+      rects.current = measured
+    }
+    return rects.current
+  }, [])
+
+  /**
+   * Pixels per minute, read off a rendered column.
+   *
+   * Taking this as a prop meant the drag maths and the layout could disagree
+   * whenever the board had been resized or zoomed; asking the DOM cannot.
+   */
+  const pxPerMinute = useCallback(() => {
+    for (const rect of measuredColumns().values()) {
+      const value = pxPerMinuteOf(rect, timeWindow)
+      if (value > 0) return value
+    }
+    return 0
+  }, [measuredColumns, timeWindow])
+
+  /** The column under a point, if any. */
+  const columnAt = useCallback(
+    (clientX: number, clientY: number) => {
+      for (const [key, rect] of measuredColumns()) {
+        if (
+          clientX >= rect.left &&
+          clientX <= rect.right &&
+          clientY >= rect.top &&
+          clientY <= rect.bottom
+        ) {
+          return { key, rect, ...parseColumnKey(key) }
+        }
+      }
+      return null
+    },
+    [measuredColumns]
+  )
+
+  /**
+   * Where a pointer sits on the board, for drops that come from outside the
+   * timeline such as the unassigned panel.
+   */
+  const resolvePoint = useCallback(
+    (clientX: number, clientY: number): WeekDropTarget | null => {
+      const hit = columnAt(clientX, clientY)
+      if (!hit) return null
+      const raw = minutesAtPoint(clientY, hit.rect, timeWindow)
+      const snapped = Math.round(raw / snapMinutes) * snapMinutes
+      return {
+        date: hit.date,
+        laneId: hit.laneId,
+        startMins: Math.max(
+          timeWindow.startMins,
+          Math.min(snapped, timeWindow.endMins - snapMinutes)
+        ),
+      }
+    },
+    [columnAt, timeWindow, snapMinutes]
+  )
+
+  const clearDrag = useCallback(() => {
+    if (pendingRef.current?.holdTimer) clearTimeout(pendingRef.current.holdTimer)
+    pendingRef.current = null
+    activeRef.current = null
+    jobRef.current = null
+    previewRef.current = null
+    rects.current = null
+    if (frameRef.current !== null) {
+      cancelAnimationFrame(frameRef.current)
+      frameRef.current = null
+    }
+    setPreview(null)
+    setArmed(false)
+    setIsDragging(false)
+  }, [])
+
+  const applyMove = useCallback(
+    (clientX: number, clientY: number) => {
+      const drag = activeRef.current
+      const job = jobRef.current
+      if (!drag || !job) return
+
+      const scale = pxPerMinute()
+      if (scale <= 0) return
+      const deltaMins = (clientY - drag.anchorY) / scale
+      const duration = drag.origEndMs - drag.origStartMs
+      const windowMs = (timeWindow.endMins - timeWindow.startMins) * MINUTE_MS
+
+      let laneId = drag.origLaneId
+      let startMs = drag.origStartMs
+      let endMs = drag.origEndMs
+
+      if (drag.mode === 'move') {
+        let dayShiftMs = 0
+        const hit = columnAt(clientX, clientY)
+        if (hit) {
+          laneId = hit.laneId
+          const origDay = new Date(drag.origStartMs)
+          origDay.setHours(0, 0, 0, 0)
+          dayShiftMs = dayStartDate(hit.date).getTime() - origDay.getTime()
+        }
+
+        startMs = snapToGrid(drag.origStartMs + dayShiftMs + deltaMins * MINUTE_MS, snapMinutes)
+        endMs = startMs + duration
+
+        // A job that fits inside the visible hours stays inside them, so a
+        // block cannot be parked half off the top of the board. Jobs longer
+        // than the visible day are left alone: there is no position that
+        // satisfies the constraint.
+        if (duration <= windowMs) {
+          const minutes = timeOfDay(startMs)
+          const latestStart = timeWindow.endMins - duration / MINUTE_MS
+          const clamped = Math.max(timeWindow.startMins, Math.min(minutes, latestStart))
+          if (clamped !== minutes) {
+            startMs += (clamped - minutes) * MINUTE_MS
+            endMs = startMs + duration
+          }
+        }
+      } else if (drag.mode === 'resize-start') {
+        startMs = Math.min(
+          snapToGrid(drag.origStartMs + deltaMins * MINUTE_MS, snapMinutes),
+          drag.origEndMs - snapMinutes * MINUTE_MS
+        )
+        // Only pull the edge back into view while it is still on its own day;
+        // a multi-day job legitimately starts before the visible hours.
+        if (sameDay(startMs, drag.origStartMs)) {
+          const minutes = timeOfDay(startMs)
+          if (minutes < timeWindow.startMins) {
+            startMs += (timeWindow.startMins - minutes) * MINUTE_MS
+          }
+        }
+      } else {
+        endMs = Math.max(
+          snapToGrid(drag.origEndMs + deltaMins * MINUTE_MS, snapMinutes),
+          drag.origStartMs + snapMinutes * MINUTE_MS
+        )
+        if (sameDay(endMs, drag.origEndMs)) {
+          const minutes = timeOfDay(endMs)
+          if (minutes > timeWindow.endMins) {
+            endMs -= (minutes - timeWindow.endMins) * MINUTE_MS
+          }
+        }
+      }
+
+      const next: WeekDragPreview = { jobId: job.id, startMs, endMs, laneId }
+      previewRef.current = next
+      // One paint per frame however many moves the pointer reports.
+      if (frameRef.current === null) {
+        frameRef.current = requestAnimationFrame(() => {
+          frameRef.current = null
+          if (previewRef.current) setPreview(previewRef.current)
+        })
+      }
+    },
+    [columnAt, pxPerMinute, snapMinutes, timeWindow]
+  )
+
+  const startDrag = useCallback(
+    (event: React.PointerEvent, job: WorkBoardJob, mode: WeekDragMode) => {
+      if (event.pointerType === 'mouse' && event.button !== 0) return
+      if (!job.startDateTime || !job.endDateTime) return
+
+      const base: ActiveDrag = {
+        jobId: job.id,
+        mode,
+        origStartMs: new Date(job.startDateTime).getTime(),
+        origEndMs: new Date(job.endDateTime).getTime(),
+        origLaneId: laneIdOf(job),
+        anchorX: event.clientX,
+        anchorY: event.clientY,
+      }
+      jobRef.current = job
+
+      if (event.pointerType === 'touch') {
+        // A finger has to rest before it moves anything, otherwise every
+        // attempt to scroll the board would drag a job instead.
+        base.holdTimer = window.setTimeout(() => {
+          const pending = pendingRef.current
+          if (!pending) return
+          pending.holdTimer = undefined
+          activeRef.current = pending
+          pendingRef.current = null
+          setIsDragging(true)
+        }, TOUCH_HOLD_MS)
+      }
+
+      pendingRef.current = base
+      setArmed(true)
+    },
+    [laneIdOf]
+  )
+
+  useEffect(() => {
+    if (!armed) return
+
+    const onMove = (event: PointerEvent) => {
+      const pending = pendingRef.current
+      if (pending) {
+        const distance =
+          Math.abs(event.clientX - pending.anchorX) + Math.abs(event.clientY - pending.anchorY)
+        if (pending.holdTimer !== undefined) {
+          // Still waiting out the touch hold: real movement means a scroll.
+          if (distance > DRAG_THRESHOLD * 2) clearDrag()
+          return
+        }
+        if (distance < DRAG_THRESHOLD) return
+        activeRef.current = pending
+        pendingRef.current = null
+        setIsDragging(true)
+      }
+      if (!activeRef.current) return
+      if (event.cancelable) event.preventDefault()
+      applyMove(event.clientX, event.clientY)
+    }
+
+    const onUp = () => {
+      const drag = activeRef.current
+      const job = jobRef.current
+      const result = previewRef.current
+      clearDrag()
+      if (!drag || !job || !result) return
+      if (
+        result.startMs === drag.origStartMs &&
+        result.endMs === drag.origEndMs &&
+        result.laneId === drag.origLaneId
+      ) {
+        return
+      }
+      onCommit({
+        job,
+        laneId: result.laneId,
+        start: new Date(result.startMs),
+        end: new Date(result.endMs),
+      })
+    }
+
+    window.addEventListener('pointermove', onMove, { passive: false })
+    window.addEventListener('pointerup', onUp)
+    window.addEventListener('pointercancel', clearDrag)
+    return () => {
+      window.removeEventListener('pointermove', onMove)
+      window.removeEventListener('pointerup', onUp)
+      window.removeEventListener('pointercancel', clearDrag)
+    }
+  }, [applyMove, armed, clearDrag, onCommit])
+
+  return {
+    preview,
+    isDragging,
+    startDrag,
+    cancelDrag: clearDrag,
+    registerColumn,
+    resolvePoint,
+  }
+}

+ 194 - 0
src/features/workboard/Components/WorkBayDialog.tsx

@@ -0,0 +1,194 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { Loader2 } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  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 { useConfirm } from '@/components/confirm-dialog'
+import { toast } from 'sonner'
+import type { WorkBay } from '../Actions/boardActions'
+import { createWorkBay, deleteWorkBay, updateWorkBay } from '../Actions/workBayActions'
+import { useWorkBoardStore } from '../store/workboardStore'
+import { formatDuration } from './DurationSlider'
+
+const PRESET_COLORS = [
+  '#64748b',
+  '#0ea5e9',
+  '#22c55e',
+  '#f59e0b',
+  '#a855f7',
+  '#ec4899',
+  '#14b8a6',
+  '#f97316',
+]
+
+const DEFAULT_CAPACITY = 480
+
+export function WorkBayDialog({
+  open,
+  onOpenChange,
+  workBay,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  workBay?: WorkBay | null
+}) {
+  const t = useTranslations('workBoard.bay')
+  const tc = useTranslations('common.buttons')
+  const confirm = useConfirm()
+  const store = useWorkBoardStore()
+
+  const [name, setName] = useState('')
+  const [color, setColor] = useState(PRESET_COLORS[0])
+  const [capacity, setCapacity] = useState(DEFAULT_CAPACITY)
+  const [saving, setSaving] = useState(false)
+  const [deleting, setDeleting] = useState(false)
+
+  useEffect(() => {
+    if (!open) return
+    setName(workBay?.name ?? '')
+    setColor(workBay?.color ?? PRESET_COLORS[0])
+    setCapacity(workBay?.dailyCapacity ?? DEFAULT_CAPACITY)
+  }, [open, workBay])
+
+  const handleSubmit = async (event: React.FormEvent) => {
+    event.preventDefault()
+    if (!name.trim()) return
+    setSaving(true)
+
+    const res = workBay
+      ? await updateWorkBay({
+          id: workBay.id,
+          name: name.trim(),
+          color,
+          dailyCapacity: capacity,
+        })
+      : await createWorkBay({ name: name.trim(), color, dailyCapacity: capacity })
+
+    if (res.success && res.data) {
+      store.upsertWorkBay(res.data as WorkBay)
+      onOpenChange(false)
+    } else {
+      toast.error(res.error ?? t('saveFailed'))
+    }
+    setSaving(false)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{workBay ? t('editTitle') : t('addTitle')}</DialogTitle>
+          <DialogDescription>{t('description')}</DialogDescription>
+        </DialogHeader>
+
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="bay-name">{t('name')}</Label>
+            <Input
+              id="bay-name"
+              placeholder={t('namePlaceholder')}
+              value={name}
+              onChange={(event) => setName(event.target.value)}
+              autoFocus
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label>{t('color')}</Label>
+            <div className="flex flex-wrap gap-2">
+              {PRESET_COLORS.map((preset) => (
+                <button
+                  key={preset}
+                  type="button"
+                  aria-label={preset}
+                  onClick={() => setColor(preset)}
+                  className="h-7 w-7 rounded-full border-2 transition-transform hover:scale-110"
+                  style={{
+                    backgroundColor: preset,
+                    borderColor: color === preset ? 'currentColor' : 'transparent',
+                  }}
+                />
+              ))}
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="bay-capacity">{t('dailyCapacity')}</Label>
+            <div className="flex items-center gap-2">
+              <Input
+                id="bay-capacity"
+                type="number"
+                min={1}
+                max={24}
+                step={0.5}
+                value={capacity / 60}
+                onChange={(event) => {
+                  const hours = Number(event.target.value)
+                  if (!Number.isFinite(hours)) return
+                  setCapacity(Math.round(Math.min(Math.max(hours, 1), 24) * 60))
+                }}
+                className="w-24"
+              />
+              <span className="text-sm text-muted-foreground">
+                {t('hoursPerDay')} · {formatDuration(capacity)}
+              </span>
+            </div>
+            <p className="text-xs text-muted-foreground">{t('capacityHint')}</p>
+          </div>
+
+          <div className="flex justify-between gap-2">
+            {workBay ? (
+              <Button
+                type="button"
+                variant="destructive"
+                disabled={deleting || saving}
+                onClick={async () => {
+                  const ok = await confirm({
+                    title: t('deleteTitle'),
+                    description: t('deleteDescription', { name: workBay.name }),
+                    confirmLabel: tc('delete'),
+                    destructive: true,
+                  })
+                  if (!ok) return
+                  setDeleting(true)
+                  const res = await deleteWorkBay({ id: workBay.id })
+                  if (res.success) {
+                    store.removeWorkBay(workBay.id)
+                    onOpenChange(false)
+                  } else {
+                    toast.error(res.error ?? t('saveFailed'))
+                  }
+                  setDeleting(false)
+                }}
+              >
+                {deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                {tc('delete')}
+              </Button>
+            ) : (
+              <div />
+            )}
+            <div className="flex gap-2">
+              <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+                {tc('cancel')}
+              </Button>
+              <Button type="submit" disabled={saving || !name.trim()}>
+                {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                {workBay ? tc('save') : t('addBay')}
+              </Button>
+            </div>
+          </div>
+        </form>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 788 - 208
src/features/workboard/Components/WorkBoardClient.tsx

@@ -1,124 +1,301 @@
-"use client";
-
-import { useState, useEffect, useCallback } from "react";
-import { useSearchParams, useRouter, usePathname } from "next/navigation";
-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 { WorkBoardJob } from "../Actions/boardActions";
+'use client'
+
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
+import { useSearchParams, useRouter, usePathname } from 'next/navigation'
+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 { WorkBay, WorkBoardJob } from '../Actions/boardActions'
 import {
 import {
   getBoardJobs,
   getBoardJobs,
   getUnassignedJobs,
   getUnassignedJobs,
   assignTechnician,
   assignTechnician,
   moveJob,
   moveJob,
+  scheduleJob,
   unassignJob,
   unassignJob,
   updateServiceTimes,
   updateServiceTimes,
-} from "../Actions/boardActions";
-import { WorkBoardToolbar, type BoardView } from "./WorkBoardToolbar";
-import { DayTimeline } from "./DayTimeline";
-import type { WorkBoardSettings } from "../Actions/boardActions";
-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";
-import { useTranslations } from "next-intl";
-import { VehiclePickerDialog } from "@/components/vehicle-picker-dialog";
-import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
-import { getCustomersList } from "@/features/customers/Actions/customerActions";
+} from '../Actions/boardActions'
+import { WorkBoardToolbar, type BoardView } from './WorkBoardToolbar'
+import type { WorkBoardSettings } from '../Actions/boardActions'
+import { UnassignedJobsList, UnassignedJobsPanel } from './UnassignedJobsPanel'
+import { TechnicianDialog } from './TechnicianDialog'
+import { WorkBayDialog } from './WorkBayDialog'
+import { JobDetailPopover } from './JobDetailPopover'
+import { WeekTimeline, type WeekScheduleChange } from './WeekTimeline'
+import { WeekCardGrid } from './WeekCardGrid'
+import type { WeekDropTarget } from './WeekTimeline/useWeekDrag'
+import { useBoardPreferences } from '../hooks/useBoardPreferences'
+import {
+  type BoardLane,
+  type LaneGrouping,
+  UNLANED,
+  buildLanes,
+  groupJobsByLane,
+  isLaneGrouping,
+  laneAssignment,
+  laneIdForJob,
+} from '../utils/lanes'
+import { Button } from '@/components/ui/button'
+import {
+  Drawer,
+  DrawerContent,
+  DrawerHeader,
+  DrawerTitle,
+  DrawerTrigger,
+} from '@/components/ui/drawer'
+import { Users, Wrench, ClipboardCheck, Columns3, Inbox } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { VehiclePickerDialog } from '@/components/vehicle-picker-dialog'
+import { getVehicles } from '@/features/vehicles/Actions/vehicleActions'
+import { getCustomersList } from '@/features/customers/Actions/customerActions'
+import { timeToMinutes } from '../utils/datetime'
+import { useDateSettings } from '@/components/date-settings-context'
+
+/** Minutes a job gets when it is dropped onto the week with no duration of its own. */
+const DEFAULT_JOB_MINUTES = 60
 
 
 function toLocalDateString(d: Date): string {
 function toLocalDateString(d: Date): string {
-  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
 }
 }
 
 
 function getWeekStartDate(date: Date, weekStartDay: number): string {
 function getWeekStartDate(date: Date, weekStartDay: number): string {
-  const d = new Date(date);
-  const diff = (d.getDay() - weekStartDay + 7) % 7;
-  d.setDate(d.getDate() - diff);
-  return toLocalDateString(d);
+  const d = new Date(date)
+  const diff = (d.getDay() - weekStartDay + 7) % 7
+  d.setDate(d.getDate() - diff)
+  return toLocalDateString(d)
 }
 }
 
 
 function getWeekDays(weekStart: string): string[] {
 function getWeekDays(weekStart: string): string[] {
-  const days: string[] = [];
+  const days: string[] = []
   for (let i = 0; i < 7; i++) {
   for (let i = 0; i < 7; i++) {
-    const d = new Date(weekStart + "T12:00:00");
-    d.setDate(d.getDate() + i);
-    days.push(toLocalDateString(d));
+    const d = new Date(weekStart + 'T12:00:00')
+    d.setDate(d.getDate() + i)
+    days.push(toLocalDateString(d))
   }
   }
-  return days;
+  return days
 }
 }
 
 
-const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
+function isWeekend(date: string): boolean {
+  const day = new Date(date + 'T12:00:00').getDay()
+  return day === 0 || day === 6
+}
 
 
-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 ?? "");
+/** Where a dnd-kit drag was released, derived from its own event data. */
+function pointerAt(event: DragEndEvent): { x: number; y: number } | null {
+  const activator = event.activatorEvent as { clientX?: number; clientY?: number } | undefined
+  if (activator?.clientX === undefined || activator.clientY === undefined) return null
+  return {
+    x: activator.clientX + event.delta.x,
+    y: activator.clientY + event.delta.y,
+  }
+}
+
+/**
+ * What follows the cursor while a job is in the air.
+ *
+ * Deliberately a single small line rather than the full card: the board draws a
+ * ghost at the snapped slot underneath, and a card the size of the real one sat
+ * squarely on top of it, hiding the very thing you were aiming with. Offset
+ * down and right of the cursor for the same reason.
+ */
+function DragChip({
+  title,
+  subtitle,
+  isInspection,
+}: {
+  title: string
+  subtitle?: string | null
+  isInspection?: boolean
+}) {
   return (
   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 className="pointer-events-none max-w-[200px] translate-x-3 translate-y-3">
+      <div className="flex items-center gap-1 rounded-md border bg-card/95 px-1.5 py-0.5 text-[11px] shadow-md backdrop-blur-sm">
+        {isInspection ? (
+          <ClipboardCheck className="h-3 w-3 shrink-0 text-green-500" />
+        ) : (
+          <Wrench className="h-3 w-3 shrink-0 text-blue-500" />
+        )}
+        <span className="truncate font-medium">{title}</span>
+        {subtitle && <span className="truncate text-muted-foreground">{subtitle}</span>}
       </div>
       </div>
     </div>
     </div>
-  );
+  )
 }
 }
 
 
 export function WorkBoardClient({
 export function WorkBoardClient({
   initialTechnicians,
   initialTechnicians,
+  initialWorkBays,
   initialAssignments,
   initialAssignments,
   initialUnassigned,
   initialUnassigned,
   initialWeekStart,
   initialWeekStart,
   boardSettings,
   boardSettings,
 }: {
 }: {
-  initialTechnicians: Technician[];
-  initialAssignments: WorkBoardJob[];
-  initialUnassigned: { serviceRecords: unknown[]; inspections: unknown[] };
-  initialWeekStart: string;
-  boardSettings: WorkBoardSettings;
+  initialTechnicians: Technician[]
+  initialWorkBays: WorkBay[]
+  initialAssignments: WorkBoardJob[]
+  initialUnassigned: { serviceRecords: unknown[]; inspections: unknown[] }
+  initialWeekStart: string
+  boardSettings: WorkBoardSettings
 }) {
 }) {
-  const store = useWorkBoardStore();
-  const t = useTranslations("workBoard.board");
-  const searchParams = useSearchParams();
-  const router = useRouter();
-  const pathname = usePathname();
-  const urlView = searchParams.get("view") as BoardView | null;
-  const urlDate = searchParams.get("date");
-  const urlWeek = searchParams.get("week");
+  const store = useWorkBoardStore()
+  const t = useTranslations('workBoard.board')
+  const { timeFormat } = useDateSettings()
+  const tUnassigned = useTranslations('workBoard.unassigned')
+  const searchParams = useSearchParams()
+  const router = useRouter()
+  const pathname = usePathname()
+  const urlView = searchParams.get('view') as BoardView | null
+  const urlDate = searchParams.get('date')
+  const urlWeek = searchParams.get('week')
+  const urlGrouping = searchParams.get('group')
 
 
   useEffect(() => {
   useEffect(() => {
-    store.setTechnicians(initialTechnicians);
-    store.setJobs(initialAssignments);
-    store.setUnassigned(initialUnassigned.serviceRecords as Parameters<typeof store.setUnassigned>[0], initialUnassigned.inspections as Parameters<typeof store.setUnassigned>[1]);
-    store.setWeekStart(urlWeek || initialWeekStart);
+    store.setTechnicians(initialTechnicians)
+    store.setWorkBays(initialWorkBays)
+    store.setJobs(initialAssignments)
+    store.setUnassigned(
+      initialUnassigned.serviceRecords as Parameters<typeof store.setUnassigned>[0],
+      initialUnassigned.inspections as Parameters<typeof store.setUnassigned>[1]
+    )
+    store.setWeekStart(urlWeek || initialWeekStart)
     if (urlWeek && urlWeek !== initialWeekStart) {
     if (urlWeek && urlWeek !== initialWeekStart) {
-      getBoardJobs(urlWeek).then((res) => { if (res.success && res.data) store.setJobs(res.data as WorkBoardJob[]); });
+      getBoardJobs(urlWeek).then((res) => {
+        if (res.success && res.data) store.setJobs(res.data as WorkBoardJob[])
+      })
     }
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []);
-
-  useWorkBoardWebSocket();
-
-  const [view, setViewState] = useState<BoardView>(urlView === "day" || urlView === "week" ? urlView : "week");
-  const [selectedDate, setSelectedDateState] = useState(urlDate || toLocalDateString(new Date()));
-  const [techDialogOpen, setTechDialogOpen] = useState(false);
-  const [editingTech, setEditingTech] = useState<Technician | null>(null);
-  const [selectedJob, setSelectedJob] = useState<WorkBoardJob | null>(null);
-  const [popoverOpen, setPopoverOpen] = useState(false);
-  const [activeDrag, setActiveDrag] = useState<{ id: string; job?: WorkBoardJob; unassignedJob?: { job: Record<string, unknown>; type: "serviceRecord" | "inspection" } } | null>(null);
-  const [vehiclePickerOpen, setVehiclePickerOpen] = useState(false);
-  const [vehiclePickerVehicles, setVehiclePickerVehicles] = useState<{ id: string; make: string; model: string; year: number; licensePlate: string | null; customer: { id: string; name: string; company: string | null } | null }[]>([]);
-  const [vehiclePickerCustomers, setVehiclePickerCustomers] = useState<{ id: string; name: string; company: string | null }[]>([]);
-  const [boardContext, setBoardContext] = useState<Record<string, string>>({});
-
-  const weekStart = store.weekStart || initialWeekStart;
-  const days = getWeekDays(weekStart);
+  }, [])
+
+  useWorkBoardWebSocket()
+
+  const { preferences, update: updatePreferences } = useBoardPreferences(
+    isLaneGrouping(urlGrouping) ? { grouping: urlGrouping } : undefined
+  )
+
+  const [view, setViewState] = useState<BoardView>(
+    urlView === 'day' || urlView === 'week' ? urlView : 'week'
+  )
+  const [selectedDate, setSelectedDateState] = useState(urlDate || toLocalDateString(new Date()))
+  const [techDialogOpen, setTechDialogOpen] = useState(false)
+  const [editingTech, setEditingTech] = useState<Technician | null>(null)
+  const [bayDialogOpen, setBayDialogOpen] = useState(false)
+  const [editingBay, setEditingBay] = useState<WorkBay | null>(null)
+  const [selectedJob, setSelectedJob] = useState<WorkBoardJob | null>(null)
+  const [popoverOpen, setPopoverOpen] = useState(false)
+  const [activeDrag, setActiveDrag] = useState<{
+    id: string
+    job?: WorkBoardJob
+    unassignedJob?: { job: Record<string, unknown>; type: 'serviceRecord' | 'inspection' }
+  } | null>(null)
+  const [vehiclePickerOpen, setVehiclePickerOpen] = useState(false)
+  const [vehiclePickerVehicles, setVehiclePickerVehicles] = useState<
+    {
+      id: string
+      make: string
+      model: string
+      year: number
+      licensePlate: string | null
+      customer: { id: string; name: string; company: string | null } | null
+    }[]
+  >([])
+  const [vehiclePickerCustomers, setVehiclePickerCustomers] = useState<
+    { id: string; name: string; company: string | null }[]
+  >([])
+  const [boardContext, setBoardContext] = useState<Record<string, string>>({})
+
+  const weekDropResolver = useRef<
+    ((clientX: number, clientY: number) => WeekDropTarget | null) | null
+  >(null)
+
+  const unassignedCount = store.unassignedServiceRecords.length + store.unassignedInspections.length
+
+  const weekStart = store.weekStart || initialWeekStart
+  const allDays = useMemo(() => getWeekDays(weekStart), [weekStart])
+  // Day is the same board with one day's worth of columns, not a different
+  // component. Switching period used to swap in an entirely separate view,
+  // which threw away the grouping, the lane filter, the zoom and the tooltips.
+  const weekDays = useMemo(
+    () => (preferences.showWeekends ? allDays : allDays.filter((d) => !isWeekend(d))),
+    [allDays, preferences.showWeekends]
+  )
+  const days = useMemo(
+    () => (view === 'day' ? [selectedDate] : weekDays),
+    [view, selectedDate, weekDays]
+  )
+  const hiddenDays = useMemo(
+    () => (view === 'day' || preferences.showWeekends ? [] : allDays.filter(isWeekend)),
+    [view, allDays, preferences.showWeekends]
+  )
+
+  const lanes = useMemo(
+    () =>
+      buildLanes({
+        grouping: preferences.grouping,
+        technicians: store.technicians,
+        workBays: store.workBays,
+        jobs: store.jobs,
+        labels: {
+          unlaned: preferences.grouping === 'bay' ? t('lanes.noBay') : t('lanes.noTechnician'),
+          all: t('lanes.everyone'),
+        },
+      }),
+    [preferences.grouping, store.technicians, store.workBays, store.jobs, t]
+  )
+
+  const visibleLanes = useMemo(
+    () => lanes.filter((lane) => !preferences.hiddenLaneIds.includes(lane.id)),
+    [lanes, preferences.hiddenLaneIds]
+  )
+
+  /** Lanes carrying work in the week on screen, for the "only these" shortcut. */
+  const busyLaneIds = useMemo(() => {
+    const ids = new Set<string>()
+    for (const job of store.jobs) ids.add(laneIdForJob(job, preferences.grouping))
+    return lanes.filter((lane) => ids.has(lane.id)).map((lane) => lane.id)
+  }, [store.jobs, lanes, preferences.grouping])
+
+  /** Every lane the shop has, by id, whichever grouping is on. */
+  const owners = useMemo(() => {
+    const map = new Map<string, { name: string; color: string }>()
+    for (const tech of store.technicians) map.set(tech.id, { name: tech.name, color: tech.color })
+    for (const bay of store.workBays) map.set(bay.id, { name: bay.name, color: bay.color })
+    return map
+  }, [store.technicians, store.workBays])
+
+  /** Jobs bucketed for the card layout, which has no drag preview to fold in. */
+  const jobsByLane = useMemo(
+    () =>
+      groupJobsByLane(
+        store.jobs,
+        preferences.grouping,
+        new Set(visibleLanes.filter((lane) => !lane.isPlaceholder).map((lane) => lane.id))
+      ),
+    [store.jobs, preferences.grouping, visibleLanes]
+  )
+
+  const toggleLane = useCallback(
+    (laneId: string) => {
+      const hidden = preferences.hiddenLaneIds
+      updatePreferences({
+        hiddenLaneIds: hidden.includes(laneId)
+          ? hidden.filter((id) => id !== laneId)
+          : [...hidden, laneId],
+      })
+    },
+    [preferences.hiddenLaneIds, updatePreferences]
+  )
+
   const sensors = useSensors(
   const sensors = useSensors(
     useSensor(PointerSensor, {
     useSensor(PointerSensor, {
       activationConstraint: { distance: 5 },
       activationConstraint: { distance: 5 },
@@ -127,182 +304,585 @@ export function WorkBoardClient({
       activationConstraint: { delay: 200, tolerance: 5 },
       activationConstraint: { delay: 200, tolerance: 5 },
     }),
     }),
     useSensor(KeyboardSensor)
     useSensor(KeyboardSensor)
-  );
-
-  const updateUrl = useCallback((newView: BoardView, newDate: string, newWeekStart: string) => {
-    const params = new URLSearchParams(); params.set("view", newView); params.set("date", newDate); params.set("week", newWeekStart);
-    router.replace(`${pathname}?${params.toString()}`, { scroll: false });
-  }, [router, pathname]);
-
-  const setView = useCallback((v: BoardView) => { setViewState(v); updateUrl(v, selectedDate, weekStart); }, [selectedDate, weekStart, updateUrl]);
-  const setSelectedDate = useCallback((d: string) => { setSelectedDateState(d); updateUrl(view, d, weekStart); }, [view, weekStart, updateUrl]);
-
-  const loadWeekData = useCallback(async (ws: string) => {
-    store.setWeekStart(ws); updateUrl(view, selectedDate, ws);
-    const [assignRes, unassignedRes] = await Promise.all([getBoardJobs(ws), getUnassignedJobs()]);
-    if (assignRes.success && assignRes.data) store.setJobs(assignRes.data as WorkBoardJob[]);
-    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, view, selectedDate, updateUrl]);
-
-  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 = () => { setSelectedDate(toLocalDateString(new Date())); loadWeekData(getWeekStartDate(new Date(), boardSettings.weekStartDay)); };
-  const handlePrevDay = () => { const d = new Date(selectedDate + "T12:00:00"); d.setDate(d.getDate() - 1); const nd = toLocalDateString(d); setSelectedDate(nd); const nw = getWeekStartDate(d, boardSettings.weekStartDay); if (nw !== weekStart) loadWeekData(nw); };
-  const handleNextDay = () => { const d = new Date(selectedDate + "T12:00:00"); d.setDate(d.getDate() + 1); const nd = toLocalDateString(d); setSelectedDate(nd); const nw = getWeekStartDate(d, boardSettings.weekStartDay); if (nw !== weekStart) loadWeekData(nw); };
-
-  const handleCreateWorkOrder = async (techId: string, startTime: string, endTime: string) => {
-    setBoardContext({ boardTech: techId, boardDate: selectedDate, boardStart: startTime, boardEnd: endTime });
-    const [vehiclesResult, customersResult] = await Promise.all([getVehicles(), getCustomersList()]);
-    setVehiclePickerVehicles(vehiclesResult.success && vehiclesResult.data ? vehiclesResult.data.map((v) => ({ id: v.id, make: v.make, model: v.model, year: v.year, licensePlate: v.licensePlate, customer: v.customer })) : []);
-    setVehiclePickerCustomers(customersResult.success && customersResult.data ? customersResult.data : []);
-    setVehiclePickerOpen(true);
-  };
+  )
+
+  const updateUrl = useCallback(
+    (newView: BoardView, newDate: string, newWeekStart: string, grouping: LaneGrouping) => {
+      const params = new URLSearchParams()
+      params.set('view', newView)
+      params.set('date', newDate)
+      params.set('week', newWeekStart)
+      params.set('group', grouping)
+      router.replace(`${pathname}?${params.toString()}`, { scroll: false })
+    },
+    [router, pathname]
+  )
+
+  const setView = useCallback(
+    (v: BoardView) => {
+      setViewState(v)
+      updateUrl(v, selectedDate, weekStart, preferences.grouping)
+    },
+    [selectedDate, weekStart, preferences.grouping, updateUrl]
+  )
+  const setSelectedDate = useCallback(
+    (d: string) => {
+      setSelectedDateState(d)
+      updateUrl(view, d, weekStart, preferences.grouping)
+    },
+    [view, weekStart, preferences.grouping, updateUrl]
+  )
+  const setGrouping = useCallback(
+    (grouping: LaneGrouping) => {
+      updatePreferences({ grouping })
+      updateUrl(view, selectedDate, weekStart, grouping)
+    },
+    [updatePreferences, updateUrl, view, selectedDate, weekStart]
+  )
+
+  const loadWeekData = useCallback(
+    async (ws: string) => {
+      store.setWeekStart(ws)
+      updateUrl(view, selectedDate, ws, preferences.grouping)
+      const [assignRes, unassignedRes] = await Promise.all([getBoardJobs(ws), getUnassignedJobs()])
+      if (assignRes.success && assignRes.data) store.setJobs(assignRes.data as WorkBoardJob[])
+      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, view, selectedDate, preferences.grouping, updateUrl]
+  )
+
+  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 = () => {
+    setSelectedDate(toLocalDateString(new Date()))
+    loadWeekData(getWeekStartDate(new Date(), boardSettings.weekStartDay))
+  }
+  const handlePrevDay = () => {
+    const d = new Date(selectedDate + 'T12:00:00')
+    d.setDate(d.getDate() - 1)
+    const nd = toLocalDateString(d)
+    setSelectedDate(nd)
+    const nw = getWeekStartDate(d, boardSettings.weekStartDay)
+    if (nw !== weekStart) loadWeekData(nw)
+  }
+  const handleNextDay = () => {
+    const d = new Date(selectedDate + 'T12:00:00')
+    d.setDate(d.getDate() + 1)
+    const nd = toLocalDateString(d)
+    setSelectedDate(nd)
+    const nw = getWeekStartDate(d, boardSettings.weekStartDay)
+    if (nw !== weekStart) loadWeekData(nw)
+  }
+
+  const openVehiclePicker = useCallback(async (context: Record<string, string>) => {
+    setBoardContext(context)
+    const [vehiclesResult, customersResult] = await Promise.all([getVehicles(), getCustomersList()])
+    setVehiclePickerVehicles(
+      vehiclesResult.success && vehiclesResult.data
+        ? vehiclesResult.data.map((v) => ({
+            id: v.id,
+            make: v.make,
+            model: v.model,
+            year: v.year,
+            licensePlate: v.licensePlate,
+            customer: v.customer,
+          }))
+        : []
+    )
+    setVehiclePickerCustomers(
+      customersResult.success && customersResult.data ? customersResult.data : []
+    )
+    setVehiclePickerOpen(true)
+  }, [])
+
+  const handleCreateInWeek = useCallback(
+    (lane: BoardLane, date: string, startMins: number) => {
+      const toTime = (mins: number) =>
+        `${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`
+      const context: Record<string, string> = {
+        boardDate: date,
+        boardStart: toTime(startMins),
+        boardEnd: toTime(startMins + DEFAULT_JOB_MINUTES),
+      }
+      if (lane.grouping === 'technician') context.boardTech = lane.id
+      if (lane.grouping === 'bay') context.boardBay = lane.id
+      return openVehiclePicker(context)
+    },
+    [openVehiclePicker]
+  )
+
+  /** Persist a lane and time change made by dragging on the week timeline. */
+  const handleWeekSchedule = useCallback(
+    async ({ job, laneId, start, end }: WeekScheduleChange) => {
+      const assignment = laneAssignment(laneId, preferences.grouping)
+      const previous = {
+        technicianId: job.technicianId,
+        workBayId: job.workBayId,
+        startDateTime: job.startDateTime,
+        endDateTime: job.endDateTime,
+      }
+
+      store.optimisticSchedule(job.id, {
+        ...assignment,
+        startDateTime: start.toISOString(),
+        endDateTime: end.toISOString(),
+      })
+
+      const res = await scheduleJob({
+        id: job.id,
+        type: job.type,
+        ...assignment,
+        startDateTime: start,
+        endDateTime: end,
+      })
+
+      if (!res.success) {
+        store.optimisticSchedule(job.id, previous)
+        toast.error(t('failedMove'), { description: res.error })
+      } else if (res.data) {
+        store.updateJob(res.data as WorkBoardJob)
+      }
+    },
+    [preferences.grouping, store, t]
+  )
 
 
   const handleDragStart = (event: DragStartEvent) => {
   const handleDragStart = (event: DragStartEvent) => {
-    const data = event.active.data.current;
-    if (data?.job && data.job.type) setActiveDrag({ id: event.active.id as string, job: data.job });
-    else if (data?.job && data?.type) setActiveDrag({ id: event.active.id as string, unassignedJob: { job: data.job, type: data.type as "serviceRecord" | "inspection" } });
-    else setActiveDrag({ id: event.active.id as string });
-  };
+    const data = event.active.data.current
+    if (data?.job && data.job.type) setActiveDrag({ id: event.active.id as string, job: data.job })
+    else if (data?.job && data?.type)
+      setActiveDrag({
+        id: event.active.id as string,
+        unassignedJob: { job: data.job, type: data.type as 'serviceRecord' | 'inspection' },
+      })
+    else setActiveDrag({ id: event.active.id as string })
+  }
 
 
   const handleDragEnd = async (event: DragEndEvent) => {
   const handleDragEnd = async (event: DragEndEvent) => {
-    setActiveDrag(null);
-    const { active, over } = event;
-    if (!over) return;
-    const overData = over.data.current as { technicianId?: string; date?: string; unassigned?: boolean } | undefined;
-    if (!overData) return;
-    const activeData = active.data.current;
+    setActiveDrag(null)
+    const { active, over } = event
+    if (!over) return
+    const overData = over.data.current as
+      | {
+          technicianId?: string
+          laneId?: string
+          date?: string
+          unassigned?: boolean
+          week?: boolean
+        }
+      | undefined
+    if (!overData) return
+    const activeData = active.data.current
 
 
     // Drop assigned job onto unassigned panel
     // Drop assigned job onto unassigned panel
     if (overData.unassigned && activeData?.job && activeData.job.type) {
     if (overData.unassigned && activeData?.job && activeData.job.type) {
-      const job = activeData.job as WorkBoardJob;
-      if (!job.technicianId) return;
-      store.removeJob(job.id);
-      const res = await unassignJob({ id: job.id, type: job.type });
-      if (!res.success) { toast.error(t("failedRemove"), { description: res.error }); store.addJob(job); }
-      else { 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]); }
-      return;
+      const job = activeData.job as WorkBoardJob
+      if (!job.technicianId && !job.workBayId) return
+      store.removeJob(job.id)
+      const res = await unassignJob({ id: job.id, type: job.type })
+      if (!res.success) {
+        toast.error(t('failedRemove'), { description: res.error })
+        store.addJob(job)
+      } else {
+        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]
+          )
+      }
+      return
     }
     }
 
 
-    if (!overData.technicianId) return;
+    // Drop onto the week timeline: the lane, the day and the time all come
+    // from where the pointer actually was, not from the column's mid-point.
+    if (overData.week) {
+      const point = pointerAt(event)
+      const target = point ? weekDropResolver.current?.(point.x, point.y) : null
+      const laneId = target?.laneId ?? overData.laneId
+      const date = target?.date ?? overData.date
+      if (!laneId || !date || laneId === UNLANED) return
+
+      const startMins = target?.startMins ?? timeToMinutes(boardSettings.workDayStart)
+      const start = new Date(`${date}T00:00:00`)
+      start.setMinutes(startMins)
+
+      if (!activeData?.job) return
+      const assignment = laneAssignment(laneId, preferences.grouping)
+      const end = new Date(start.getTime() + DEFAULT_JOB_MINUTES * 60_000)
+
+      // An assigned job dragged between cells of the card layout: it keeps the
+      // time of day it already had and only changes lane and date.
+      if (activeData.job.type && !activeData.unscheduled) {
+        const job = activeData.job as WorkBoardJob
+        const previousStart = job.startDateTime ? new Date(job.startDateTime) : null
+        const previousEnd = job.endDateTime ? new Date(job.endDateTime) : null
+        const duration =
+          previousStart && previousEnd
+            ? previousEnd.getTime() - previousStart.getTime()
+            : DEFAULT_JOB_MINUTES * 60_000
+        const movedStart = new Date(`${date}T00:00:00`)
+        movedStart.setMinutes(
+          previousStart
+            ? previousStart.getHours() * 60 + previousStart.getMinutes()
+            : timeToMinutes(boardSettings.workDayStart)
+        )
+        return handleWeekSchedule({
+          job,
+          laneId,
+          start: movedStart,
+          end: new Date(movedStart.getTime() + duration),
+        })
+      }
+
+      // Already on the board, just never given a time.
+      if (activeData.unscheduled) {
+        return handleWeekSchedule({
+          job: activeData.job as WorkBoardJob,
+          laneId,
+          start,
+          end,
+        })
+      }
+
+      if (activeData.type) {
+        // A job coming from the unassigned panel: it has no lane and no times.
+        const pooled = activeData.job as { id: string }
+        const type = activeData.type as 'serviceRecord' | 'inspection'
+
+        // Ungrouped, the board has one lane and dropping into it would name no
+        // technician and no bay: the job would leave the pool and land nowhere.
+        if (Object.keys(assignment).length === 0) {
+          toast.error(t('dropNeedsLane'))
+          return
+        }
+
+        store.removeFromUnassigned(pooled.id, type)
+        const res = await scheduleJob({
+          id: pooled.id,
+          type,
+          ...assignment,
+          startDateTime: start,
+          endDateTime: end,
+        })
+        if (!res.success) {
+          toast.error(t('failedAssign'), { description: res.error })
+          store.addToUnassigned(activeData.job, type)
+        } else if (res.data) {
+          store.addJob(res.data as WorkBoardJob)
+        }
+      }
+      return
+    }
+
+    if (!overData.technicianId) return
 
 
     if (activeData?.job && activeData.job.type) {
     if (activeData?.job && activeData.job.type) {
-      const job = activeData.job as WorkBoardJob;
-      const dropDate = overData.date;
-      const jobDate = job.startDateTime ? job.startDateTime.split("T")[0] : null;
-      const sameTech = job.technicianId === overData.technicianId;
-      const sameDay = dropDate && jobDate && dropDate === jobDate;
+      const job = activeData.job as WorkBoardJob
+      const dropDate = overData.date
+      const jobDate = job.startDateTime ? job.startDateTime.split('T')[0] : null
+      const sameTech = job.technicianId === overData.technicianId
+      const sameDay = dropDate && jobDate && dropDate === jobDate
 
 
-      if (sameTech && sameDay) return;
+      if (sameTech && sameDay) return
 
 
       // Move to different tech if needed
       // Move to different tech if needed
       if (!sameTech) {
       if (!sameTech) {
-        store.optimisticMove(job.id, overData.technicianId);
-        const res = await moveJob({ id: job.id, technicianId: overData.technicianId, sortOrder: 0, type: job.type });
-        if (!res.success) { toast.error(t("failedMove"), { description: res.error }); store.optimisticMove(job.id, job.technicianId!); return; }
-        else if (res.data) store.updateJob(res.data as WorkBoardJob);
+        store.optimisticMove(job.id, overData.technicianId)
+        const res = await moveJob({
+          id: job.id,
+          technicianId: overData.technicianId,
+          sortOrder: 0,
+          type: job.type,
+        })
+        if (!res.success) {
+          toast.error(t('failedMove'), { description: res.error })
+          store.optimisticMove(job.id, job.technicianId!)
+          return
+        } else if (res.data) store.updateJob(res.data as WorkBoardJob)
       }
       }
 
 
       // Update dates if dropped on a different day
       // Update dates if dropped on a different day
       if (!sameDay && dropDate) {
       if (!sameDay && dropDate) {
-        const oldStart = job.startDateTime ? new Date(job.startDateTime) : null;
-        const oldEnd = job.endDateTime ? new Date(job.endDateTime) : null;
-        const duration = oldStart && oldEnd ? oldEnd.getTime() - oldStart.getTime() : 60 * 60 * 1000;
+        const oldStart = job.startDateTime ? new Date(job.startDateTime) : null
+        const oldEnd = job.endDateTime ? new Date(job.endDateTime) : null
+        const duration = oldStart && oldEnd ? oldEnd.getTime() - oldStart.getTime() : 60 * 60 * 1000
         const timeOfDay = oldStart
         const timeOfDay = oldStart
-          ? `${String(oldStart.getHours()).padStart(2, "0")}:${String(oldStart.getMinutes()).padStart(2, "0")}`
-          : boardSettings.workDayStart;
-        const newStart = new Date(dropDate + "T" + timeOfDay + ":00");
-        const newEnd = new Date(newStart.getTime() + duration);
-        store.updateServiceTimes(job.id, newStart.toISOString(), newEnd.toISOString());
-        const timeRes = await updateServiceTimes({ id: job.id, startDateTime: newStart, endDateTime: newEnd });
-        if (!timeRes.success) { toast.error(t("failedMove"), { description: timeRes.error }); }
+          ? `${String(oldStart.getHours()).padStart(2, '0')}:${String(oldStart.getMinutes()).padStart(2, '0')}`
+          : boardSettings.workDayStart
+        const newStart = new Date(dropDate + 'T' + timeOfDay + ':00')
+        const newEnd = new Date(newStart.getTime() + duration)
+        store.updateServiceTimes(job.id, newStart.toISOString(), newEnd.toISOString())
+        const timeRes = await updateServiceTimes({
+          id: job.id,
+          startDateTime: newStart,
+          endDateTime: newEnd,
+        })
+        if (!timeRes.success) {
+          toast.error(t('failedMove'), { description: timeRes.error })
+        }
       }
       }
-      return;
+      return
     }
     }
 
 
     if (activeData?.job && activeData?.type) {
     if (activeData?.job && activeData?.type) {
-      const job = activeData.job;
-      const jobType = activeData.type as "serviceRecord" | "inspection";
+      const job = activeData.job
+      const jobType = activeData.type as 'serviceRecord' | 'inspection'
       // Set default time on the drop target's date (1 hour block at work day start)
       // Set default time on the drop target's date (1 hour block at work day start)
-      const dropDate = overData.date || selectedDate;
-      const startDateTime = new Date(dropDate + "T" + boardSettings.workDayStart + ":00");
-      const endDateTime = new Date(startDateTime.getTime() + 60 * 60 * 1000);
-      store.removeFromUnassigned(job.id, jobType);
-      const res = await assignTechnician({ id: job.id, technicianId: overData.technicianId, type: jobType, startDateTime, endDateTime });
-      if (!res.success) { toast.error(t("failedAssign"), { description: res.error }); store.addToUnassigned(job, jobType); }
-      else if (res.data) store.addJob(res.data as WorkBoardJob);
+      const dropDate = overData.date || selectedDate
+      const startDateTime = new Date(dropDate + 'T' + boardSettings.workDayStart + ':00')
+      const endDateTime = new Date(startDateTime.getTime() + 60 * 60 * 1000)
+      store.removeFromUnassigned(job.id, jobType)
+      const res = await assignTechnician({
+        id: job.id,
+        technicianId: overData.technicianId,
+        type: jobType,
+        startDateTime,
+        endDateTime,
+      })
+      if (!res.success) {
+        toast.error(t('failedAssign'), { description: res.error })
+        store.addToUnassigned(job, jobType)
+      } else if (res.data) store.addJob(res.data as WorkBoardJob)
     }
     }
-  };
+  }
 
 
   const handleRemoveJob = async (job: WorkBoardJob) => {
   const handleRemoveJob = async (job: WorkBoardJob) => {
-    setPopoverOpen(false); setSelectedJob(null);
-    store.removeJob(job.id);
-    const res = await unassignJob({ id: job.id, type: job.type });
-    if (!res.success) { toast.error(t("failedRemove"), { description: res.error }); store.addJob(job); }
-    else { 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]); }
-  };
+    setPopoverOpen(false)
+    setSelectedJob(null)
+    store.removeJob(job.id)
+    const res = await unassignJob({ id: job.id, type: job.type })
+    if (!res.success) {
+      toast.error(t('failedRemove'), { description: res.error })
+      store.addJob(job)
+    } else {
+      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 = (a: WorkBoardJob) => { setSelectedJob(a); setPopoverOpen(true); };
+  const handleCardClick = useCallback((job: WorkBoardJob) => {
+    setSelectedJob(job)
+    setPopoverOpen(true)
+  }, [])
+
+  const handleLaneClick = useCallback(
+    (lane: BoardLane) => {
+      if (lane.grouping === 'technician') {
+        const tech = store.technicians.find((candidate) => candidate.id === lane.id)
+        if (tech) {
+          setEditingTech(tech)
+          setTechDialogOpen(true)
+        }
+        return
+      }
+      if (lane.grouping === 'bay') {
+        const bay = store.workBays.find((candidate) => candidate.id === lane.id)
+        if (bay) {
+          setEditingBay(bay)
+          setBayDialogOpen(true)
+        }
+      }
+    },
+    [store.technicians, store.workBays]
+  )
+
+  const openAddBay = () => {
+    setEditingBay(null)
+    setBayDialogOpen(true)
+  }
+  const openAddTech = () => {
+    setEditingTech(null)
+    setTechDialogOpen(true)
+  }
 
 
-  if (store.technicians.length === 0) {
+  const toolbar = (
+    <WorkBoardToolbar
+      weekStart={weekStart}
+      selectedDate={selectedDate}
+      view={view}
+      grouping={preferences.grouping}
+      layout={preferences.layout}
+      showWeekends={preferences.showWeekends}
+      lanes={lanes}
+      hiddenLaneIds={preferences.hiddenLaneIds}
+      busyLaneIds={busyLaneIds}
+      onToggleLane={toggleLane}
+      onShowAllLanes={() => updatePreferences({ hiddenLaneIds: [] })}
+      onShowBusyLanes={() =>
+        updatePreferences({
+          hiddenLaneIds: lanes
+            .filter((lane) => !busyLaneIds.includes(lane.id))
+            .map((lane) => lane.id),
+        })
+      }
+      onPrevWeek={handlePrevWeek}
+      onNextWeek={handleNextWeek}
+      onPrevDay={handlePrevDay}
+      onNextDay={handleNextDay}
+      onToday={handleToday}
+      onAddTech={openAddTech}
+      onAddBay={openAddBay}
+      onViewChange={setView}
+      onGroupingChange={setGrouping}
+      onLayoutChange={(layout) => updatePreferences({ layout })}
+      onToggleWeekends={() => updatePreferences({ showWeekends: !preferences.showWeekends })}
+    />
+  )
+
+  if (store.technicians.length === 0 && store.workBays.length === 0) {
     return (
     return (
       <div className="flex flex-1 flex-col">
       <div className="flex flex-1 flex-col">
-        <WorkBoardToolbar weekStart={weekStart} selectedDate={selectedDate} view={view} onPrevWeek={handlePrevWeek} onNextWeek={handleNextWeek} onPrevDay={handlePrevDay} onNextDay={handleNextDay} onToday={handleToday} onAddTech={() => setTechDialogOpen(true)} onViewChange={setView} />
+        {toolbar}
         <div className="flex flex-1 flex-col items-center justify-center gap-4 py-20">
         <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" />
           <Users className="h-12 w-12 text-muted-foreground/40" />
-          <div className="text-center"><h3 className="text-lg font-medium">{t("noTechnicians")}</h3><p className="text-sm text-muted-foreground">{t("noTechniciansDescription")}</p></div>
+          <div className="text-center">
+            <h3 className="text-lg font-medium">{t('noTechnicians')}</h3>
+            <p className="text-sm text-muted-foreground">{t('noTechniciansDescription')}</p>
+          </div>
         </div>
         </div>
         <TechnicianDialog open={techDialogOpen} onOpenChange={setTechDialogOpen} />
         <TechnicianDialog open={techDialogOpen} onOpenChange={setTechDialogOpen} />
+        <WorkBayDialog open={bayDialogOpen} onOpenChange={setBayDialogOpen} />
       </div>
       </div>
-    );
+    )
   }
   }
 
 
   const dragOverlay = activeDrag?.job ? (
   const dragOverlay = activeDrag?.job ? (
-    <div className="w-48 opacity-90"><BoardJobCard job={activeDrag.job} /></div>
+    <DragChip
+      title={activeDrag.job.title}
+      subtitle={
+        activeDrag.job.vehicle?.licensePlate ??
+        (activeDrag.job.vehicle
+          ? `${activeDrag.job.vehicle.make} ${activeDrag.job.vehicle.model}`
+          : null)
+      }
+      isInspection={activeDrag.job.type === 'inspection'}
+    />
   ) : activeDrag?.unassignedJob ? (
   ) : activeDrag?.unassignedJob ? (
-    <div className="w-48 opacity-90"><UnassignedJobOverlayCard job={activeDrag.unassignedJob.job} type={activeDrag.unassignedJob.type} /></div>
-  ) : null;
-
-  const gridTemplateColumns = `140px ${days
-    .map((d) =>
-      d === toLocalDateString(new Date()) ? "minmax(0,2fr)" : "minmax(0,1fr)",
-    )
-    .join(" ")}`;
+    <DragChip
+      title={
+        (activeDrag.unassignedJob.job.title as string) ??
+        (activeDrag.unassignedJob.job.template as { name: string })?.name ??
+        ''
+      }
+      subtitle={
+        (activeDrag.unassignedJob.job.vehicle as { licensePlate: string | null } | undefined)
+          ?.licensePlate ?? null
+      }
+      isInspection={activeDrag.unassignedJob.type === 'inspection'}
+    />
+  ) : null
 
 
   return (
   return (
-    <div className="flex flex-1 flex-col gap-4">
-      <WorkBoardToolbar weekStart={weekStart} selectedDate={selectedDate} view={view} onPrevWeek={handlePrevWeek} onNextWeek={handleNextWeek} onPrevDay={handlePrevDay} onNextDay={handleNextDay} onToday={handleToday} onAddTech={() => { setEditingTech(null); setTechDialogOpen(true); }} onViewChange={setView} />
+    <div className="flex min-h-0 flex-1 flex-col gap-4">
+      {toolbar}
 
 
       <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
       <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
-        <div className="flex flex-1 gap-4 overflow-hidden">
-          <ScrollArea className="flex-1">
-            {view === "day" ? (
-              <DayTimeline date={selectedDate} technicians={store.technicians} assignments={store.jobs} workDayStart={boardSettings.workDayStart} workDayEnd={boardSettings.workDayEnd} onCardClick={handleCardClick} onTechClick={(t) => { setEditingTech(t); setTechDialogOpen(true); }} onCreateWorkOrder={handleCreateWorkOrder} />
-            ) : (
-              <div className="min-w-[900px] grid gap-1" style={{ gridTemplateColumns }}>
-                <div />
-                {days.map((day) => {
-                  const isToday = day === toLocalDateString(new Date());
-                  const d = new Date(day + "T12:00:00");
-                  const dayKey = ALL_DAY_KEYS[d.getDay()];
-                  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"}`}>{t(`days.${dayKey}`)} {d.getDate()}</div>;
-                })}
-                {store.technicians.map((tech) => (
-                  <TechnicianRow key={tech.id} technician={tech} days={days} assignments={store.jobs} onCardClick={handleCardClick} onTechClick={(t) => { setEditingTech(t); setTechDialogOpen(true); }} />
-                ))}
-              </div>
-            )}
-          </ScrollArea>
+        <div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
+          {visibleLanes.length === 0 ? (
+            <div className="flex flex-1 flex-col items-center justify-center gap-3 py-20">
+              <Columns3 className="h-10 w-10 text-muted-foreground/40" />
+              <p className="text-sm text-muted-foreground">{t('noBays')}</p>
+              <Button size="sm" onClick={openAddBay}>
+                {t('addFirstBay')}
+              </Button>
+            </div>
+          ) : preferences.layout === 'cards' ? (
+            <WeekCardGrid
+              days={days}
+              lanes={visibleLanes}
+              jobsByLane={jobsByLane}
+              todayStr={toLocalDateString(new Date())}
+              timeFormat={timeFormat}
+              lookup={owners}
+              onOpenJob={handleCardClick}
+              onLaneClick={handleLaneClick}
+            />
+          ) : (
+            <WeekTimeline
+              days={days}
+              hiddenDays={hiddenDays}
+              lanes={visibleLanes}
+              jobs={store.jobs}
+              grouping={preferences.grouping}
+              zoom={preferences.zoom}
+              onZoomChange={(zoom) => updatePreferences({ zoom })}
+              snapMinutes={preferences.snapMinutes}
+              workDayStart={boardSettings.workDayStart}
+              workDayEnd={boardSettings.workDayEnd}
+              owners={owners}
+              dropResolverRef={weekDropResolver}
+              onOpenJob={handleCardClick}
+              onSchedule={handleWeekSchedule}
+              onCreateJob={handleCreateInWeek}
+              onLaneClick={handleLaneClick}
+              onShowHiddenDays={() => updatePreferences({ showWeekends: true })}
+              onShowWholeWeek={view === 'week' ? () => setGrouping('none') : undefined}
+            />
+          )}
           <UnassignedJobsPanel />
           <UnassignedJobsPanel />
         </div>
         </div>
         <DragOverlay dropAnimation={null}>{dragOverlay}</DragOverlay>
         <DragOverlay dropAnimation={null}>{dragOverlay}</DragOverlay>
       </DndContext>
       </DndContext>
 
 
+      {/* The sidebar is hidden below md, so the same list gets a drawer. */}
+      <Drawer>
+        <DrawerTrigger asChild>
+          <Button variant="outline" size="sm" className="w-full md:hidden">
+            <Inbox className="mr-1.5 h-3.5 w-3.5" />
+            {tUnassigned('title', { count: unassignedCount })}
+          </Button>
+        </DrawerTrigger>
+        <DrawerContent className="max-h-[80svh]">
+          <DrawerHeader>
+            <DrawerTitle>{tUnassigned('title', { count: unassignedCount })}</DrawerTitle>
+          </DrawerHeader>
+          <UnassignedJobsList />
+        </DrawerContent>
+      </Drawer>
+
       {selectedJob && (
       {selectedJob && (
-        <JobDetailPopover job={selectedJob} open={popoverOpen} onOpenChange={(o) => { setPopoverOpen(o); if (!o) setSelectedJob(null); }} onRemove={() => handleRemoveJob(selectedJob)} />
+        <JobDetailPopover
+          job={selectedJob}
+          open={popoverOpen}
+          onOpenChange={(o) => {
+            setPopoverOpen(o)
+            if (!o) setSelectedJob(null)
+          }}
+          onRemove={() => handleRemoveJob(selectedJob)}
+        />
       )}
       )}
-      <TechnicianDialog open={techDialogOpen} onOpenChange={setTechDialogOpen} technician={editingTech} />
-      <VehiclePickerDialog open={vehiclePickerOpen} onOpenChange={(open) => { setVehiclePickerOpen(open); if (!open) setBoardContext({}); }} vehicles={vehiclePickerVehicles} customers={vehiclePickerCustomers} redirectQuery={boardContext} />
+      <TechnicianDialog
+        open={techDialogOpen}
+        onOpenChange={setTechDialogOpen}
+        technician={editingTech}
+      />
+      <WorkBayDialog open={bayDialogOpen} onOpenChange={setBayDialogOpen} workBay={editingBay} />
+      <VehiclePickerDialog
+        open={vehiclePickerOpen}
+        onOpenChange={(open) => {
+          setVehiclePickerOpen(open)
+          if (!open) setBoardContext({})
+        }}
+        vehicles={vehiclePickerVehicles}
+        customers={vehiclePickerCustomers}
+        redirectQuery={boardContext}
+      />
     </div>
     </div>
-  );
+  )
 }
 }

+ 425 - 98
src/features/workboard/Components/WorkBoardPresenter.tsx

@@ -1,177 +1,504 @@
 'use client'
 'use client'
 
 
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useMemo } from 'react'
 import { useSearchParams, useRouter, usePathname } from 'next/navigation'
 import { useSearchParams, useRouter, usePathname } from 'next/navigation'
+import { DndContext } from '@dnd-kit/core'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
-import { ChevronLeft, ChevronRight, Wrench, ClipboardCheck, Wifi, WifiOff } from 'lucide-react'
+import { ChevronLeft, ChevronRight, Wifi, WifiOff } from 'lucide-react'
 import { useWorkBoardStore, type Technician } from '../store/workboardStore'
 import { useWorkBoardStore, type Technician } from '../store/workboardStore'
 import { useWorkBoardWebSocket } from '../hooks/useWorkBoardWebSocket'
 import { useWorkBoardWebSocket } from '../hooks/useWorkBoardWebSocket'
-import type { WorkBoardJob } from '../Actions/boardActions'
+import type { WorkBay, WorkBoardJob } from '../Actions/boardActions'
 import { getBoardJobs } from '../Actions/boardActions'
 import { getBoardJobs } from '../Actions/boardActions'
 import { getTechnicians } from '../Actions/technicianActions'
 import { getTechnicians } from '../Actions/technicianActions'
-import { PresenterDayView } from './PresenterDayView'
+import { getWorkBays } from '../Actions/workBayActions'
 import { PresenterKanbanView } from './PresenterKanbanView'
 import { PresenterKanbanView } from './PresenterKanbanView'
-import { PresenterTimeline } from './PresenterTimeline'
-import { jobOverlapsDate } from '../utils/datetime'
+import { WeekTimeline } from './WeekTimeline'
+import { WeekCardGrid } from './WeekCardGrid'
+import { type BoardLayout, isBoardLayout } from '../hooks/useBoardPreferences'
+import { type ClockFormat } from '../utils/clock'
+import { type LaneGrouping, buildLanes, groupJobsByLane, isLaneGrouping } from '../utils/lanes'
 import { useTranslations, useLocale } from 'next-intl'
 import { useTranslations, useLocale } from 'next-intl'
+import { useDateSettings } from '@/components/date-settings-context'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
 
 
-type ViewMode = 'week' | 'day' | 'status' | 'timeline'
+type Period = 'day' | 'week'
+/** Timeline and Overview are the board's own layouts; Status is the kanban,
+ *  which only makes sense for a single day. */
+type PresenterLayout = BoardLayout | 'status'
+
+/** Older pinned displays carry ?view=; keep those URLs working. */
+const LEGACY_VIEWS: Record<string, { period: Period; layout: PresenterLayout }> = {
+  timeline: { period: 'day', layout: 'timeline' },
+  day: { period: 'day', layout: 'cards' },
+  status: { period: 'day', layout: 'status' },
+  week: { period: 'week', layout: 'timeline' },
+}
 
 
 function toLocalDateString(d: Date): string {
 function toLocalDateString(d: Date): string {
   return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
   return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
 }
 }
 
 
 function getWeekStart(date: Date, weekStartDay: number): string {
 function getWeekStart(date: Date, weekStartDay: number): string {
-  const d = new Date(date); const diff = (d.getDay() - weekStartDay + 7) % 7; d.setDate(d.getDate() - diff);
+  const d = new Date(date)
+  const diff = (d.getDay() - weekStartDay + 7) % 7
+  d.setDate(d.getDate() - diff)
   return toLocalDateString(d)
   return toLocalDateString(d)
 }
 }
 
 
 function getWeekDays(weekStart: string): string[] {
 function getWeekDays(weekStart: string): string[] {
   const days: 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)); }
+  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
   return days
 }
 }
 
 
-const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const
+/** The presenter shows the board; it never changes it. */
+const noop = () => undefined
 
 
 function formatWeekRange(weekStart: string, locale?: string): string {
 function formatWeekRange(weekStart: string, locale?: string): string {
-  const start = new Date(weekStart + 'T12:00:00'); const end = new Date(start); end.setDate(end.getDate() + 6);
+  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 opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }
   return `${start.toLocaleDateString(locale, opts)} – ${end.toLocaleDateString(locale, { ...opts, year: 'numeric' })}`
   return `${start.toLocaleDateString(locale, opts)} – ${end.toLocaleDateString(locale, { ...opts, year: 'numeric' })}`
 }
 }
 
 
 function formatDayDate(dateStr: string, locale?: string): string {
 function formatDayDate(dateStr: string, locale?: string): string {
-  return new Date(dateStr + 'T12:00:00').toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric' })
+  return new Date(dateStr + 'T12:00:00').toLocaleDateString(locale, {
+    weekday: 'long',
+    month: 'long',
+    day: 'numeric',
+  })
 }
 }
 
 
-function LiveClock() {
+/**
+ * The wall clock in the header.
+ *
+ * Rendered null until the first tick, because the server has no idea what time
+ * it is where the screen is and a mismatched first paint is a hydration error.
+ * That left a one-space-wide element that jumped to full width a moment later,
+ * shoving the header about, so the space is reserved up front: tabular figures
+ * and a width sized for the longest the format can be.
+ */
+function LiveClock({ format }: { format: ClockFormat }) {
   const [time, setTime] = useState<string | null>(null)
   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({ job }: { job: WorkBoardJob }) {
-  const isServiceRecord = job.type === 'serviceRecord'
+  useEffect(() => {
+    function update() {
+      setTime(
+        new Date().toLocaleTimeString(undefined, {
+          hour: '2-digit',
+          minute: '2-digit',
+          second: '2-digit',
+          hour12: format === '12h',
+        })
+      )
+    }
+    update()
+    const timer = setInterval(update, 1000)
+    return () => clearInterval(timer)
+  }, [format])
+
   return (
   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">{job.title}</span>
-      </div>
-      {job.vehicle && <p className="mt-0.5 truncate text-muted-foreground">{job.vehicle.year} {job.vehicle.make} {job.vehicle.model}{job.vehicle.licensePlate ? ` · ${job.vehicle.licensePlate}` : ''}</p>}
-      {job.status && <span className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5 text-xs capitalize">{job.status.replace(/_/g, ' ')}</span>}
-    </div>
+    <span
+      className="inline-block text-right tabular-nums"
+      // "11:59:59 PM" against "23:59:59": reserve for whichever is in use.
+      style={{ minWidth: format === '12h' ? '10ch' : '8ch' }}
+    >
+      {time ?? '\u00A0'}
+    </span>
   )
   )
 }
 }
 
 
-export function WorkBoardPresenter({ initialTechnicians, initialAssignments, initialWeekStart, workDayStart = '07:00', workDayEnd = '15:00', weekStartDay = 1 }: {
-  initialTechnicians: Technician[]; initialAssignments: WorkBoardJob[]; initialWeekStart: string; workDayStart?: string; workDayEnd?: string; weekStartDay?: number
+export function WorkBoardPresenter({
+  initialTechnicians,
+  initialWorkBays = [],
+  initialAssignments,
+  initialWeekStart,
+  workDayStart = '07:00',
+  workDayEnd = '15:00',
+  weekStartDay = 1,
+}: {
+  initialTechnicians: Technician[]
+  initialWorkBays?: WorkBay[]
+  initialAssignments: WorkBoardJob[]
+  initialWeekStart: string
+  workDayStart?: string
+  workDayEnd?: string
+  weekStartDay?: number
 }) {
 }) {
   const store = useWorkBoardStore()
   const store = useWorkBoardStore()
   const t = useTranslations('workBoard.presenter')
   const t = useTranslations('workBoard.presenter')
   const tb = useTranslations('workBoard.board')
   const tb = useTranslations('workBoard.board')
+  const tt = useTranslations('workBoard.toolbar')
+  const { timeFormat } = useDateSettings()
   const locale = useLocale()
   const locale = useLocale()
-  const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname()
-  const VALID_VIEWS: ViewMode[] = ['week', 'day', 'status', 'timeline']
-  const urlView = searchParams.get('view') as ViewMode | null
+  const searchParams = useSearchParams()
+  const router = useRouter()
+  const pathname = usePathname()
+  const legacy = LEGACY_VIEWS[searchParams.get('view') ?? '']
+  const urlPeriod = searchParams.get('period')
+  const urlLayout = searchParams.get('layout')
   const urlDate = searchParams.get('date')
   const urlDate = searchParams.get('date')
-  const [viewMode, setViewMode] = useState<ViewMode>(urlView && VALID_VIEWS.includes(urlView) ? urlView : 'week')
+  const urlGrouping = searchParams.get('group')
+
+  const [period, setPeriodState] = useState<Period>(
+    urlPeriod === 'day' || urlPeriod === 'week' ? urlPeriod : (legacy?.period ?? 'week')
+  )
+  const [layout, setLayoutState] = useState<PresenterLayout>(
+    urlLayout === 'status' || isBoardLayout(urlLayout)
+      ? (urlLayout as PresenterLayout)
+      : (legacy?.layout ?? 'timeline')
+  )
   const [selectedDate, setSelectedDateState] = useState(urlDate || toLocalDateString(new Date()))
   const [selectedDate, setSelectedDateState] = useState(urlDate || toLocalDateString(new Date()))
-  const updateUrl = useCallback((v: ViewMode, d: string) => { const p = new URLSearchParams(); p.set('view', v); p.set('date', d); router.replace(`${pathname}?${p.toString()}`, { scroll: false }) }, [router, pathname])
-  const setSelectedDate = useCallback((d: string) => { setSelectedDateState(d); updateUrl(viewMode, d) }, [viewMode, updateUrl])
-  const handleSetViewMode = (m: ViewMode) => { setViewMode(m); updateUrl(m, selectedDate) }
+  // The presenter keeps its settings in the address bar rather than reading the
+  // planner's saved preference: a wall display is usually a different machine,
+  // and one that should stay on whatever it was pinned to.
+  const [grouping, setGroupingState] = useState<LaneGrouping>(
+    isLaneGrouping(urlGrouping) ? urlGrouping : 'technician'
+  )
+
+  const updateUrl = useCallback(
+    (p: Period, l: PresenterLayout, d: string, g: LaneGrouping) => {
+      const params = new URLSearchParams()
+      params.set('period', p)
+      params.set('layout', l)
+      params.set('date', d)
+      params.set('group', g)
+      router.replace(`${pathname}?${params.toString()}`, { scroll: false })
+    },
+    [router, pathname]
+  )
 
 
-  useEffect(() => { store.setTechnicians(initialTechnicians); store.setJobs(initialAssignments); store.setWeekStart(initialWeekStart); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [])
+  const setSelectedDate = useCallback(
+    (d: string) => {
+      setSelectedDateState(d)
+      updateUrl(period, layout, d, grouping)
+    },
+    [period, layout, grouping, updateUrl]
+  )
+  const setPeriod = (p: Period) => {
+    // Status is a single day's kanban; a week has no such thing to show.
+    const next: PresenterLayout = p === 'week' && layout === 'status' ? 'timeline' : layout
+    setPeriodState(p)
+    setLayoutState(next)
+    updateUrl(p, next, selectedDate, grouping)
+  }
+  /** The status board is a day's kanban, so asking for it asks for a day. */
+  const showStatusBoard = () => {
+    setPeriodState('day')
+    setLayoutState('status')
+    updateUrl('day', 'status', selectedDate, grouping)
+  }
+  const setLayout = (l: PresenterLayout) => {
+    setLayoutState(l)
+    updateUrl(period, l, selectedDate, grouping)
+  }
+  const setGrouping = (g: LaneGrouping) => {
+    setGroupingState(g)
+    updateUrl(period, layout, selectedDate, g)
+  }
+
+  useEffect(() => {
+    store.setTechnicians(initialTechnicians)
+    store.setWorkBays(initialWorkBays)
+    store.setJobs(initialAssignments)
+    store.setWeekStart(initialWeekStart) /* eslint-disable-next-line react-hooks/exhaustive-deps */
+  }, [])
   useWorkBoardWebSocket()
   useWorkBoardWebSocket()
 
 
   const weekStart = store.weekStart || initialWeekStart
   const weekStart = store.weekStart || initialWeekStart
-  const days = getWeekDays(weekStart)
-  const today = toLocalDateString(new Date())
+  // Day is the same board given one day's worth of columns, exactly as on the
+  // planner. Switching period used to swap in a different component entirely.
+  const days = period === 'day' ? [selectedDate] : getWeekDays(weekStart)
 
 
-  const loadWeekData = useCallback(async (ws: string) => {
-    store.setWeekStart(ws)
-    const [assignRes, techRes] = await Promise.all([getBoardJobs(ws), getTechnicians()])
-    if (assignRes.success && assignRes.data) store.setJobs(assignRes.data as WorkBoardJob[])
-    if (techRes.success && techRes.data) store.setTechnicians(techRes.data as Technician[])
-  }, [store])
+  const loadWeekData = useCallback(
+    async (ws: string) => {
+      store.setWeekStart(ws)
+      const [assignRes, techRes, bayRes] = await Promise.all([
+        getBoardJobs(ws),
+        getTechnicians(),
+        getWorkBays(),
+      ])
+      if (assignRes.success && assignRes.data) store.setJobs(assignRes.data as WorkBoardJob[])
+      if (techRes.success && techRes.data) store.setTechnicians(techRes.data as Technician[])
+      if (bayRes.success && bayRes.data) store.setWorkBays(bayRes.data as WorkBay[])
+    },
+    [store]
+  )
 
 
-  const ensureWeekLoaded = useCallback((dateStr: string) => { const m = getWeekStart(new Date(dateStr + 'T12:00:00'), weekStartDay); if (m !== weekStart) loadWeekData(m) }, [weekStart, loadWeekData, weekStartDay])
-  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 nd = toLocalDateString(d); setSelectedDate(nd); ensureWeekLoaded(nd) } }
-  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 nd = toLocalDateString(d); setSelectedDate(nd); ensureWeekLoaded(nd) } }
-  const handleToday = () => { setSelectedDate(toLocalDateString(new Date())); loadWeekData(getWeekStart(new Date(), weekStartDay)) }
+  const ensureWeekLoaded = useCallback(
+    (dateStr: string) => {
+      const m = getWeekStart(new Date(dateStr + 'T12:00:00'), weekStartDay)
+      if (m !== weekStart) loadWeekData(m)
+    },
+    [weekStart, loadWeekData, weekStartDay]
+  )
+  const handlePrev = () => {
+    if (period === '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 nd = toLocalDateString(d)
+      setSelectedDate(nd)
+      ensureWeekLoaded(nd)
+    }
+  }
+  const handleNext = () => {
+    if (period === '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 nd = toLocalDateString(d)
+      setSelectedDate(nd)
+      ensureWeekLoaded(nd)
+    }
+  }
+  const handleToday = () => {
+    setSelectedDate(toLocalDateString(new Date()))
+    loadWeekData(getWeekStart(new Date(), weekStartDay))
+  }
 
 
   useEffect(() => {
   useEffect(() => {
-    function msUntilMidnight() { const now = new Date(); const m = new Date(now); m.setHours(24, 0, 0, 0); return m.getTime() - now.getTime() }
+    function msUntilMidnight() {
+      const now = new Date()
+      const m = new Date(now)
+      m.setHours(24, 0, 0, 0)
+      return m.getTime() - now.getTime()
+    }
     let timeout: ReturnType<typeof setTimeout>
     let timeout: ReturnType<typeof setTimeout>
-    function schedule() { timeout = setTimeout(() => { const now = new Date(); setSelectedDate(toLocalDateString(now)); loadWeekData(getWeekStart(now, weekStartDay)); schedule() }, msUntilMidnight() + 500) }
-    schedule(); return () => clearTimeout(timeout)
+    function schedule() {
+      timeout = setTimeout(() => {
+        const now = new Date()
+        setSelectedDate(toLocalDateString(now))
+        loadWeekData(getWeekStart(now, weekStartDay))
+        schedule()
+      }, msUntilMidnight() + 500)
+    }
+    schedule()
+    return () => clearTimeout(timeout)
   }, [loadWeekData, setSelectedDate, weekStartDay])
   }, [loadWeekData, setSelectedDate, weekStartDay])
 
 
-  const dateLabel = viewMode === 'week' ? formatWeekRange(weekStart, locale) : formatDayDate(selectedDate, locale)
+  const dateLabel =
+    period === 'week' ? formatWeekRange(weekStart, locale) : formatDayDate(selectedDate, locale)
+
+  const lanes = useMemo(
+    () =>
+      buildLanes({
+        grouping,
+        technicians: store.technicians,
+        workBays: store.workBays,
+        jobs: store.jobs,
+        labels: {
+          unlaned: grouping === 'bay' ? tb('lanes.noBay') : tb('lanes.noTechnician'),
+          all: tb('lanes.everyone'),
+        },
+      }),
+    [grouping, store.technicians, store.workBays, store.jobs, tb]
+  )
+
+  const owners = useMemo(() => {
+    const map = new Map<string, { name: string; color: string }>()
+    for (const tech of store.technicians) map.set(tech.id, { name: tech.name, color: tech.color })
+    for (const bay of store.workBays) map.set(bay.id, { name: bay.name, color: bay.color })
+    return map
+  }, [store.technicians, store.workBays])
+
+  const jobsByLane = useMemo(
+    () =>
+      groupJobsByLane(
+        store.jobs,
+        grouping,
+        new Set(lanes.filter((lane) => !lane.isPlaceholder).map((lane) => lane.id))
+      ),
+    [store.jobs, grouping, lanes]
+  )
 
 
   return (
   return (
     <div className="flex h-screen flex-col bg-background">
     <div className="flex h-screen flex-col bg-background">
       <header className="flex shrink-0 items-center justify-between border-b px-4 py-2">
       <header className="flex shrink-0 items-center justify-between border-b px-4 py-2">
         <div className="flex items-center gap-3">
         <div className="flex items-center gap-3">
           <h1 className="text-lg font-bold">{t('title')}</h1>
           <h1 className="text-lg font-bold">{t('title')}</h1>
-          <span className="text-sm text-muted-foreground">{dateLabel}</span>
+          <span className="min-w-[240px] text-sm text-muted-foreground">{dateLabel}</span>
         </div>
         </div>
         <div className="flex items-center gap-3">
         <div className="flex items-center gap-3">
+          {/* Period, then how to draw it, then what the lanes are: the same
+              three questions the board asks, in the same order. */}
           <div className="flex rounded-md border">
           <div className="flex rounded-md border">
-            <Button variant={viewMode === 'timeline' ? 'default' : 'ghost'} size="sm" className="rounded-none rounded-l-md" onClick={() => handleSetViewMode('timeline')}>{t('timeline')}</Button>
-            <Button variant={viewMode === 'day' ? 'default' : 'ghost'} size="sm" className="rounded-none border-l" onClick={() => handleSetViewMode('day')}>{t('day')}</Button>
-            <Button variant={viewMode === 'status' ? 'default' : 'ghost'} size="sm" className="rounded-none border-x" onClick={() => handleSetViewMode('status')}>{t('status')}</Button>
-            <Button variant={viewMode === 'week' ? 'default' : 'ghost'} size="sm" className="rounded-none rounded-r-md" onClick={() => handleSetViewMode('week')}>{t('week')}</Button>
+            <Button
+              variant={period === 'day' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none rounded-l-md"
+              onClick={() => setPeriod('day')}
+            >
+              {t('day')}
+            </Button>
+            <Button
+              variant={period === 'week' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none rounded-r-md border-l"
+              onClick={() => setPeriod('week')}
+            >
+              {t('week')}
+            </Button>
+          </div>
+
+          <div className="flex rounded-md border">
+            <Button
+              variant={layout === 'timeline' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none rounded-l-md"
+              onClick={() => setLayout('timeline')}
+            >
+              {tt('layoutTimeline')}
+            </Button>
+            <Button
+              variant={layout === 'cards' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none border-x"
+              onClick={() => setLayout('cards')}
+            >
+              {tt('layoutCards')}
+            </Button>
+            {/* A kanban of one day's work by status. There is no weekly
+                equivalent, so choosing it switches to a day rather than
+                disappearing when a week is selected: a control that moves
+                around under the pointer is worse than one that acts. */}
+            <Button
+              variant={layout === 'status' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none rounded-r-md"
+              onClick={() => showStatusBoard()}
+            >
+              {t('status')}
+            </Button>
+          </div>
+
+          {/* Kept in place rather than removed while the status board is up:
+              the kanban groups by status, so these have nothing to do, but
+              hiding them shifts every control beside them. */}
+          <div
+            className="flex rounded-md border"
+            title={layout === 'status' ? t('groupingUnused') : undefined}
+          >
+            <Button
+              variant={grouping === 'technician' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none rounded-l-md"
+              disabled={layout === 'status'}
+              onClick={() => setGrouping('technician')}
+            >
+              {tt('technicians')}
+            </Button>
+            <Button
+              variant={grouping === 'bay' ? 'default' : 'ghost'}
+              size="sm"
+              className="rounded-none rounded-r-md border-l"
+              disabled={layout === 'status'}
+              onClick={() => setGrouping('bay')}
+            >
+              {tt('workBays')}
+            </Button>
           </div>
           </div>
           <div className="flex items-center gap-1">
           <div className="flex items-center gap-1">
-            <Button variant="ghost" size="icon" onClick={handlePrev} aria-label={t('previous')}><ChevronLeft className="h-5 w-5" /></Button>
-            <Button variant="outline" size="sm" onClick={handleToday}>{t('today')}</Button>
-            <Button variant="ghost" size="icon" onClick={handleNext} aria-label={t('next')}><ChevronRight className="h-5 w-5" /></Button>
+            <Button variant="ghost" size="icon" onClick={handlePrev} aria-label={t('previous')}>
+              <ChevronLeft className="h-5 w-5" />
+            </Button>
+            <Button variant="outline" size="sm" onClick={handleToday}>
+              {t('today')}
+            </Button>
+            <Button variant="ghost" size="icon" onClick={handleNext} aria-label={t('next')}>
+              <ChevronRight className="h-5 w-5" />
+            </Button>
           </div>
           </div>
           <div className="flex items-center gap-2 text-sm text-muted-foreground">
           <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={t('live')}><Wifi className="h-4 w-4" /><span className="text-xs">{t('live')}</span></span> : <span className="flex items-center gap-1.5 animate-pulse text-red-500" title={t('disconnected')}><WifiOff className="h-4 w-4" /><span className="text-xs">{t('disconnected')}</span></span>}
-            <LiveClock />
+            {/* Icon only, and always the same size. The words "Live updates
+                disconnected" are four times the width of "Live", so swapping
+                between them shoved every control in the header sideways. */}
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <span
+                  className={cn(
+                    'flex h-4 w-4 items-center justify-center',
+                    store.connection === 'open' && 'text-green-600',
+                    store.connection === 'closed' && 'animate-pulse text-red-500',
+                    store.connection === 'connecting' && 'text-muted-foreground'
+                  )}
+                >
+                  {store.connection === 'closed' ? (
+                    <WifiOff className="h-4 w-4" />
+                  ) : (
+                    <Wifi className="h-4 w-4" />
+                  )}
+                </span>
+              </TooltipTrigger>
+              <TooltipContent side="bottom">
+                {store.connection === 'open'
+                  ? t('live')
+                  : store.connection === 'closed'
+                    ? t('disconnected')
+                    : t('connecting')}
+              </TooltipContent>
+            </Tooltip>
+            <LiveClock format={timeFormat} />
           </div>
           </div>
         </div>
         </div>
       </header>
       </header>
 
 
-      {viewMode === 'timeline' ? (
-        <PresenterTimeline date={selectedDate} technicians={store.technicians} assignments={store.jobs} workDayStart={workDayStart} workDayEnd={workDayEnd} />
-      ) : viewMode === 'day' ? (
-        <PresenterDayView date={selectedDate} technicians={store.technicians} assignments={store.jobs} />
-      ) : viewMode === 'status' ? (
-        <PresenterKanbanView date={selectedDate} technicians={store.technicians} assignments={store.jobs} />
+      {layout === 'status' ? (
+        <PresenterKanbanView
+          date={selectedDate}
+          technicians={store.technicians}
+          assignments={store.jobs}
+        />
+      ) : lanes.length === 0 ? (
+        <div className="flex flex-1 items-center justify-center p-10">
+          <p className="text-lg text-muted-foreground">
+            {grouping === 'bay' ? tb('noBays') : t('noTechnicians')}
+          </p>
+        </div>
       ) : (
       ) : (
-        <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'}` }}>
-            <div className="border-b p-2" />
-            {days.map((day) => {
-              const isToday = day === today; const d = new Date(day + 'T12:00:00');
-              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'}`}>{tb(`days.${DAY_KEYS[d.getDay()]}`)} {d.getDate()}/{d.getMonth() + 1}</div>
-            })}
-            {store.technicians.map((tech) => {
-              const techJobs = store.jobs.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 cellJobs = techJobs.filter((a) => jobOverlapsDate(a, 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' : ''}`}>
-                        {cellJobs.map((a) => <PresenterJobCard key={a.id} job={a} />)}
-                      </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">{t('noTechnicians')}</p></div>}
-          </div>
+        <div className="flex min-h-0 flex-1 flex-col p-2">
+          {/* The board the shop plans on, minus everything you could change:
+              a wall display is read while walking past it. */}
+          <DndContext>
+            {layout === 'cards' ? (
+              <WeekCardGrid
+                days={days}
+                lanes={lanes}
+                jobsByLane={jobsByLane}
+                todayStr={toLocalDateString(new Date())}
+                timeFormat={timeFormat}
+                lookup={owners}
+                readOnly
+                onOpenJob={noop}
+              />
+            ) : (
+              <WeekTimeline
+                days={days}
+                lanes={lanes}
+                jobs={store.jobs}
+                grouping={grouping}
+                zoom={1}
+                snapMinutes={15}
+                workDayStart={workDayStart}
+                workDayEnd={workDayEnd}
+                onOpenJob={noop}
+                onSchedule={noop}
+                readOnly
+              />
+            )}
+          </DndContext>
         </div>
         </div>
       )}
       )}
     </div>
     </div>

+ 266 - 74
src/features/workboard/Components/WorkBoardToolbar.tsx

@@ -1,82 +1,162 @@
-"use client";
+'use client'
 
 
-import Link from "next/link";
-import { Button } from "@/components/ui/button";
-import { ChevronLeft, ChevronRight, Plus, Calendar, Monitor, CalendarDays, Clock } from "lucide-react";
-import { useTranslations, useLocale } from "next-intl";
-import { cn } from "@/lib/utils";
+import Link from 'next/link'
+import { Button } from '@/components/ui/button'
+import { ButtonGroup } from '@/components/ui/button-group'
+import {
+  Calendar,
+  CalendarDays,
+  CalendarRange,
+  ChevronLeft,
+  ChevronRight,
+  Clock,
+  Columns3,
+  LayoutGrid,
+  Monitor,
+  ListFilter,
+  Plus,
+} from 'lucide-react'
+import { useTranslations, useLocale } from 'next-intl'
+import {
+  DropdownMenu,
+  DropdownMenuCheckboxItem,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuLabel,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import type { BoardLayout } from '../hooks/useBoardPreferences'
+import type { BoardLane, LaneGrouping } from '../utils/lanes'
 
 
-export type BoardView = "week" | "day";
+export type BoardView = 'week' | 'day'
+
+/**
+ * ISO 8601 week number, taken from the middle of the shown range.
+ *
+ * European workshops schedule by week number before they schedule by date
+ * ("that's a KW 36 job"), and the planner this view was modelled on leads with
+ * it. Sampling the midpoint keeps the number right whichever weekday the shop
+ * starts its week on.
+ */
+export function isoWeekNumber(weekStart: string): number {
+  const mid = new Date(weekStart + 'T12:00:00')
+  mid.setDate(mid.getDate() + 3)
+  const utc = new Date(Date.UTC(mid.getFullYear(), mid.getMonth(), mid.getDate()))
+  utc.setUTCDate(utc.getUTCDate() + 4 - (utc.getUTCDay() || 7))
+  const yearStart = Date.UTC(utc.getUTCFullYear(), 0, 1)
+  return Math.ceil(((utc.getTime() - yearStart) / 86_400_000 + 1) / 7)
+}
 
 
 function formatWeekRange(weekStart: string, locale?: string) {
 function formatWeekRange(weekStart: string, locale?: string) {
-  const start = new Date(weekStart + "T12:00:00");
-  const end = new Date(start);
-  end.setDate(end.getDate() + 6);
+  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(locale, opts);
+  const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }
+  const startStr = start.toLocaleDateString(locale, opts)
   const endStr = end.toLocaleDateString(locale, {
   const endStr = end.toLocaleDateString(locale, {
     ...opts,
     ...opts,
-    year: "numeric",
-  });
-  return `${startStr} – ${endStr}`;
+    year: 'numeric',
+  })
+  return `${startStr} – ${endStr}`
 }
 }
 
 
 function formatDayDate(date: string, locale?: string) {
 function formatDayDate(date: string, locale?: string) {
-  const d = new Date(date + "T12:00:00");
+  const d = new Date(date + 'T12:00:00')
   return d.toLocaleDateString(locale, {
   return d.toLocaleDateString(locale, {
-    weekday: "long",
-    month: "long",
-    day: "numeric",
-    year: "numeric",
-  });
+    weekday: 'long',
+    month: 'long',
+    day: 'numeric',
+    year: 'numeric',
+  })
 }
 }
 
 
 export function WorkBoardToolbar({
 export function WorkBoardToolbar({
   weekStart,
   weekStart,
   selectedDate,
   selectedDate,
   view,
   view,
+  grouping,
+  layout,
+  showWeekends,
+  lanes,
+  hiddenLaneIds,
+  busyLaneIds,
+  onToggleLane,
+  onShowAllLanes,
+  onShowBusyLanes,
   onPrevWeek,
   onPrevWeek,
   onNextWeek,
   onNextWeek,
   onPrevDay,
   onPrevDay,
   onNextDay,
   onNextDay,
   onToday,
   onToday,
   onAddTech,
   onAddTech,
+  onAddBay,
   onViewChange,
   onViewChange,
+  onGroupingChange,
+  onLayoutChange,
+  onToggleWeekends,
 }: {
 }: {
-  weekStart: string;
-  selectedDate: string;
-  view: BoardView;
-  onPrevWeek: () => void;
-  onNextWeek: () => void;
-  onPrevDay: () => void;
-  onNextDay: () => void;
-  onToday: () => void;
-  onAddTech: () => void;
-  onViewChange: (view: BoardView) => void;
+  weekStart: string
+  selectedDate: string
+  view: BoardView
+  grouping: LaneGrouping
+  layout: BoardLayout
+  showWeekends: boolean
+  /** Every lane the grouping offers, filtered or not. */
+  lanes: BoardLane[]
+  hiddenLaneIds: string[]
+  /** Lanes carrying work this week, for the "only these" shortcut. */
+  busyLaneIds: string[]
+  onToggleLane: (laneId: string) => void
+  onShowAllLanes: () => void
+  onShowBusyLanes: () => void
+  onPrevWeek: () => void
+  onNextWeek: () => void
+  onPrevDay: () => void
+  onNextDay: () => void
+  onToday: () => void
+  onAddTech: () => void
+  onAddBay: () => void
+  onViewChange: (view: BoardView) => void
+  onGroupingChange: (grouping: LaneGrouping) => void
+  onLayoutChange: (layout: BoardLayout) => void
+  onToggleWeekends: () => void
 }) {
 }) {
-  const t = useTranslations("workBoard.toolbar");
-  const locale = useLocale();
+  const t = useTranslations('workBoard.toolbar')
+  const locale = useLocale()
 
 
   return (
   return (
-    <div className="flex items-center justify-between gap-4">
+    <div className="flex flex-wrap items-center justify-between gap-2">
       <div className="flex items-center gap-2">
       <div className="flex items-center gap-2">
         <Button variant="outline" size="sm" onClick={onToday}>
         <Button variant="outline" size="sm" onClick={onToday}>
           <Calendar className="mr-1.5 h-3.5 w-3.5" />
           <Calendar className="mr-1.5 h-3.5 w-3.5" />
-          {t("today")}
+          {t('today')}
         </Button>
         </Button>
         <div className="flex items-center gap-1">
         <div className="flex items-center gap-1">
           <Button
           <Button
             variant="ghost"
             variant="ghost"
             size="icon"
             size="icon"
             className="h-8 w-8"
             className="h-8 w-8"
-            onClick={view === "week" ? onPrevWeek : onPrevDay}
-            aria-label={view === "week" ? t("previousWeek") : t("previousDay")}
+            onClick={view === 'week' ? onPrevWeek : onPrevDay}
+            aria-label={view === 'week' ? t('previousWeek') : t('previousDay')}
           >
           >
             <ChevronLeft className="h-4 w-4" />
             <ChevronLeft className="h-4 w-4" />
           </Button>
           </Button>
-          <span className="min-w-[180px] text-center text-sm font-medium">
-            {view === "week"
+          <span className="flex min-w-[260px] items-center justify-center gap-2 text-sm font-medium">
+            {view === 'week' && (
+              <span className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold tabular-nums text-muted-foreground">
+                {t('weekNumber', { number: isoWeekNumber(weekStart) })}
+              </span>
+            )}
+            {view === 'week'
               ? formatWeekRange(weekStart, locale)
               ? formatWeekRange(weekStart, locale)
               : formatDayDate(selectedDate, locale)}
               : formatDayDate(selectedDate, locale)}
           </span>
           </span>
@@ -84,54 +164,166 @@ export function WorkBoardToolbar({
             variant="ghost"
             variant="ghost"
             size="icon"
             size="icon"
             className="h-8 w-8"
             className="h-8 w-8"
-            onClick={view === "week" ? onNextWeek : onNextDay}
-            aria-label={view === "week" ? t("nextWeek") : t("nextDay")}
+            onClick={view === 'week' ? onNextWeek : onNextDay}
+            aria-label={view === 'week' ? t('nextWeek') : t('nextDay')}
           >
           >
             <ChevronRight className="h-4 w-4" />
             <ChevronRight className="h-4 w-4" />
           </Button>
           </Button>
         </div>
         </div>
       </div>
       </div>
-      <div className="flex items-center gap-2">
-        {/* View toggle */}
-        <div className="flex rounded-md border">
-          <button
-            type="button"
-            onClick={() => onViewChange("day")}
-            className={cn(
-              "flex items-center gap-1 rounded-l-md px-2.5 py-1.5 text-xs font-medium transition-colors",
-              view === "day"
-                ? "bg-primary text-primary-foreground"
-                : "text-muted-foreground hover:bg-muted",
-            )}
+
+      <div className="flex flex-wrap items-center gap-2">
+        {/* Every one of these applies to a day as much as to a week now that
+            both are the same board, so the row stays put when the period
+            changes. Weekends is the exception and is greyed rather than
+            removed: a control that vanishes takes its neighbours with it. */}
+        <Select value={layout} onValueChange={(value) => onLayoutChange(value as BoardLayout)}>
+          <SelectTrigger size="sm" className="min-w-[130px]" aria-label={t('layout')}>
+            <LayoutGrid className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent>
+            <SelectItem value="timeline">{t('layoutTimeline')}</SelectItem>
+            <SelectItem value="cards">{t('layoutCards')}</SelectItem>
+          </SelectContent>
+        </Select>
+
+        <Select value={grouping} onValueChange={(value) => onGroupingChange(value as LaneGrouping)}>
+          {/* Cards lays its lanes out as rows, so the same setting has to
+                  be described differently there or it names the wrong axis. */}
+          <SelectTrigger
+            size="sm"
+            className="min-w-[150px]"
+            aria-label={layout === 'cards' ? t('rows') : t('groupBy')}
+          >
+            <Columns3 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent>
+            <SelectItem value="technician">
+              {layout === 'cards' ? t('rowPerTechnician') : t('byTechnician')}
+            </SelectItem>
+            <SelectItem value="bay">{layout === 'cards' ? t('rowPerBay') : t('byBay')}</SelectItem>
+            <SelectItem value="none">
+              {layout === 'cards' ? t('rowNothing') : t('byNothing')}
+            </SelectItem>
+          </SelectContent>
+        </Select>
+
+        <DropdownMenu>
+          <DropdownMenuTrigger asChild>
+            <Button
+              variant={hiddenLaneIds.length > 0 ? 'secondary' : 'outline'}
+              size="sm"
+              disabled={lanes.length <= 1}
+            >
+              <ListFilter className="mr-1.5 h-3.5 w-3.5" />
+              {t('lanes')}
+              <span className="ml-1.5 tabular-nums text-muted-foreground">
+                {lanes.length - hiddenLaneIds.length}/{lanes.length}
+              </span>
+            </Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent
+            align="end"
+            className="w-60 p-0"
+            // Returning focus to the trigger on close scrolls whatever
+            // ancestor has to move to show it, which yanked the board
+            // underneath the menu.
+            onCloseAutoFocus={(event) => event.preventDefault()}
+          >
+            <div className="p-1">
+              <DropdownMenuItem onClick={onShowAllLanes}>{t('showAllLanes')}</DropdownMenuItem>
+              <DropdownMenuItem onClick={onShowBusyLanes} disabled={busyLaneIds.length === 0}>
+                {t('onlyBusyLanes')}
+              </DropdownMenuItem>
+            </div>
+            <DropdownMenuSeparator className="my-0" />
+            {/* Only the list scrolls, so the two actions above stay put
+                      however many lanes a shop has. */}
+            <div className="max-h-[50vh] overflow-y-auto p-1">
+              <DropdownMenuLabel className="py-1">{t('lanes')}</DropdownMenuLabel>
+              {lanes.map((lane) => (
+                <DropdownMenuCheckboxItem
+                  key={lane.id}
+                  checked={!hiddenLaneIds.includes(lane.id)}
+                  onSelect={(event) => {
+                    // Keep the menu open: hiding lanes is done in batches.
+                    event.preventDefault()
+                    onToggleLane(lane.id)
+                  }}
+                >
+                  <span className="flex items-center gap-2">
+                    <span
+                      className="h-2 w-2 shrink-0 rounded-full"
+                      style={{ backgroundColor: lane.color }}
+                    />
+                    {lane.name}
+                  </span>
+                </DropdownMenuCheckboxItem>
+              ))}
+            </div>
+          </DropdownMenuContent>
+        </DropdownMenu>
+
+        <Button
+          variant={showWeekends ? 'secondary' : 'outline'}
+          size="sm"
+          disabled={view === 'day'}
+          onClick={onToggleWeekends}
+          aria-pressed={showWeekends}
+          title={view === 'day' ? t('weekendsDayHint') : undefined}
+        >
+          <CalendarRange className="mr-1.5 h-3.5 w-3.5" />
+          {t('weekends')}
+        </Button>
+
+        <ButtonGroup>
+          <Button
+            variant={view === 'day' ? 'default' : 'outline'}
+            size="sm"
+            onClick={() => onViewChange('day')}
+            aria-pressed={view === 'day'}
           >
           >
             <Clock className="h-3.5 w-3.5" />
             <Clock className="h-3.5 w-3.5" />
-            {t("day")}
-          </button>
-          <button
-            type="button"
-            onClick={() => onViewChange("week")}
-            className={cn(
-              "flex items-center gap-1 rounded-r-md px-2.5 py-1.5 text-xs font-medium transition-colors",
-              view === "week"
-                ? "bg-primary text-primary-foreground"
-                : "text-muted-foreground hover:bg-muted",
-            )}
+            {t('day')}
+          </Button>
+          <Button
+            variant={view === 'week' ? 'default' : 'outline'}
+            size="sm"
+            onClick={() => onViewChange('week')}
+            aria-pressed={view === 'week'}
           >
           >
             <CalendarDays className="h-3.5 w-3.5" />
             <CalendarDays className="h-3.5 w-3.5" />
-            {t("week")}
-          </button>
-        </div>
+            {t('week')}
+          </Button>
+        </ButtonGroup>
+
         <Button variant="outline" size="sm" asChild>
         <Button variant="outline" size="sm" asChild>
-          <Link href="/work-board/presenter" target="_blank">
+          {/* Carry what you are looking at into the display, so opening the
+              presenter shows the same thing rather than resetting to defaults. */}
+          <Link
+            href={`/work-board/presenter?view=week&group=${grouping}&layout=${layout}`}
+            target="_blank"
+          >
             <Monitor className="mr-1.5 h-3.5 w-3.5" />
             <Monitor className="mr-1.5 h-3.5 w-3.5" />
-            {t("presenter")}
+            {t('presenter')}
           </Link>
           </Link>
         </Button>
         </Button>
-        <Button size="sm" onClick={onAddTech}>
-          <Plus className="mr-1.5 h-3.5 w-3.5" />
-          {t("addTechnician")}
-        </Button>
+
+        <DropdownMenu>
+          <DropdownMenuTrigger asChild>
+            <Button size="sm">
+              <Plus className="mr-1.5 h-3.5 w-3.5" />
+              {t('add')}
+            </Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent align="end">
+            <DropdownMenuItem onClick={onAddTech}>{t('addTechnician')}</DropdownMenuItem>
+            <DropdownMenuItem onClick={onAddBay}>{t('addBay')}</DropdownMenuItem>
+          </DropdownMenuContent>
+        </DropdownMenu>
       </div>
       </div>
     </div>
     </div>
-  );
+  )
 }
 }

+ 77 - 27
src/features/workboard/Schema/workboardSchema.ts

@@ -1,56 +1,106 @@
-import { z } from "zod";
+import { z } from 'zod'
+
+const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a valid hex color')
 
 
 export const createTechnicianSchema = z.object({
 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"),
+  name: z.string().min(1, 'Name is required').max(100),
+  color: hexColor.default('#3b82f6'),
   userId: z.string().optional(),
   userId: z.string().optional(),
-});
+})
 
 
 export const updateTechnicianSchema = z.object({
 export const updateTechnicianSchema = z.object({
   id: z.string().min(1),
   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(),
+  name: z.string().min(1, 'Name is required').max(100).optional(),
+  color: hexColor.optional(),
   isActive: z.boolean().optional(),
   isActive: z.boolean().optional(),
   sortOrder: z.number().int().min(0).optional(),
   sortOrder: z.number().int().min(0).optional(),
   dailyCapacity: z.number().int().min(60).max(720).optional(),
   dailyCapacity: z.number().int().min(60).max(720).optional(),
   userId: z.string().nullable().optional(),
   userId: z.string().nullable().optional(),
-});
+})
+
+export const createWorkBaySchema = z.object({
+  name: z.string().min(1, 'Name is required').max(100),
+  color: hexColor.default('#64748b'),
+  dailyCapacity: z.number().int().min(60).max(1440).optional(),
+})
+
+export const updateWorkBaySchema = z.object({
+  id: z.string().min(1),
+  name: z.string().min(1, 'Name is required').max(100).optional(),
+  color: hexColor.optional(),
+  isActive: z.boolean().optional(),
+  sortOrder: z.number().int().min(0).optional(),
+  dailyCapacity: z.number().int().min(60).max(1440).optional(),
+})
+
+export const deleteWorkBaySchema = z.object({
+  id: z.string().min(1),
+})
 
 
 export const assignTechnicianSchema = z.object({
 export const assignTechnicianSchema = z.object({
   id: z.string().min(1),
   id: z.string().min(1),
   technicianId: z.string().min(1),
   technicianId: z.string().min(1),
-  type: z.enum(["serviceRecord", "inspection"]),
+  type: z.enum(['serviceRecord', 'inspection']),
   startDateTime: z.coerce.date().optional(),
   startDateTime: z.coerce.date().optional(),
   endDateTime: z.coerce.date().optional(),
   endDateTime: z.coerce.date().optional(),
-});
+})
 
 
 export const moveJobSchema = z.object({
 export const moveJobSchema = z.object({
   id: z.string().min(1),
   id: z.string().min(1),
   technicianId: z.string().min(1),
   technicianId: z.string().min(1),
   sortOrder: z.number().int().min(0).default(0),
   sortOrder: z.number().int().min(0).default(0),
-  type: z.enum(["serviceRecord", "inspection"]),
-});
+  type: z.enum(['serviceRecord', 'inspection']),
+})
 
 
 export const unassignJobSchema = z.object({
 export const unassignJobSchema = z.object({
   id: z.string().min(1),
   id: z.string().min(1),
-  type: z.enum(["serviceRecord", "inspection"]),
-});
+  type: z.enum(['serviceRecord', 'inspection']),
+})
 
 
 export const updateServiceTimesSchema = z.object({
 export const updateServiceTimesSchema = z.object({
   id: z.string().min(1),
   id: z.string().min(1),
   startDateTime: z.coerce.date(),
   startDateTime: z.coerce.date(),
   endDateTime: z.coerce.date(),
   endDateTime: z.coerce.date(),
-});
-
-export type CreateTechnicianInput = z.infer<typeof createTechnicianSchema>;
-export type UpdateTechnicianInput = z.infer<typeof updateTechnicianSchema>;
-export type AssignTechnicianInput = z.infer<typeof assignTechnicianSchema>;
-export type MoveJobInput = z.infer<typeof moveJobSchema>;
-export type UnassignJobInput = z.infer<typeof unassignJobSchema>;
-export type UpdateServiceTimesInput = z.infer<typeof updateServiceTimesSchema>;
+})
+
+/**
+ * One write for everything a board drag can change: which lane the job sits in
+ * and when it runs. Dragging used to fire `moveJob` and `updateServiceTimes`
+ * back to back, which meant two round trips, two websocket events, and a window
+ * where other viewers saw the job in its new lane at its old time.
+ *
+ * Every field is optional and `null` is meaningful: omit to leave a field
+ * alone, pass `null` to clear it (drop a job out of a lane without unassigning
+ * it from the other grouping).
+ */
+export const scheduleJobSchema = z
+  .object({
+    id: z.string().min(1),
+    type: z.enum(['serviceRecord', 'inspection']),
+    technicianId: z.string().min(1).nullable().optional(),
+    workBayId: z.string().min(1).nullable().optional(),
+    startDateTime: z.coerce.date().optional(),
+    endDateTime: z.coerce.date().optional(),
+    sortOrder: z.number().int().min(0).optional(),
+  })
+  .refine((d) => (d.startDateTime === undefined) === (d.endDateTime === undefined), {
+    message: 'Start and end must be given together',
+    path: ['endDateTime'],
+  })
+  .refine(
+    (d) =>
+      d.startDateTime === undefined ||
+      d.endDateTime === undefined ||
+      d.endDateTime > d.startDateTime,
+    { message: 'End time must be after start time', path: ['endDateTime'] }
+  )
+
+export type CreateTechnicianInput = z.infer<typeof createTechnicianSchema>
+export type UpdateTechnicianInput = z.infer<typeof updateTechnicianSchema>
+export type CreateWorkBayInput = z.infer<typeof createWorkBaySchema>
+export type UpdateWorkBayInput = z.infer<typeof updateWorkBaySchema>
+export type AssignTechnicianInput = z.infer<typeof assignTechnicianSchema>
+export type MoveJobInput = z.infer<typeof moveJobSchema>
+export type UnassignJobInput = z.infer<typeof unassignJobSchema>
+export type UpdateServiceTimesInput = z.infer<typeof updateServiceTimesSchema>
+export type ScheduleJobInput = z.infer<typeof scheduleJobSchema>

+ 129 - 0
src/features/workboard/hooks/useBoardPreferences.ts

@@ -0,0 +1,129 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { type LaneGrouping, isLaneGrouping } from '../utils/lanes'
+
+/**
+ * How far the week timeline is zoomed in, as a multiple of the height that
+ * fits the whole day on screen.
+ *
+ * A continuous number rather than named steps, because the gesture that drives
+ * it is a wheel: 1 fills the board exactly, and anything above it makes the
+ * hours taller and scrolls.
+ */
+export const MIN_ZOOM = 1
+export const MAX_ZOOM = 6
+export const ZOOM_STEP = 0.15
+
+export function clampZoom(value: number): number {
+  if (!Number.isFinite(value)) return MIN_ZOOM
+  return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(value * 100) / 100))
+}
+
+/**
+ * How the week draws itself.
+ *
+ * `timeline` puts every lane on a clock, which is what a shop planning by the
+ * hour wants and what the whole feature was asked for. `cards` is a row per
+ * lane and a column per day: no times, but it fits a fifteen-technician shop on
+ * one screen, which the timeline cannot.
+ */
+export type BoardLayout = 'timeline' | 'cards'
+
+export const BOARD_LAYOUTS: BoardLayout[] = ['timeline', 'cards']
+
+export function isBoardLayout(value: unknown): value is BoardLayout {
+  return typeof value === 'string' && (BOARD_LAYOUTS as string[]).includes(value)
+}
+
+/** Minutes a drag snaps to. Kept separate from the visible slot lines. */
+export const SNAP_CHOICES = [5, 10, 15, 30] as const
+export type SnapMinutes = (typeof SNAP_CHOICES)[number]
+
+export type BoardPreferences = {
+  grouping: LaneGrouping
+  layout: BoardLayout
+  zoom: number
+  /** Saturday and Sunday columns. Most shops do not want them. */
+  showWeekends: boolean
+  snapMinutes: SnapMinutes
+  /**
+   * Lanes this person has taken off their board.
+   *
+   * A fifteen-technician shop is seventy-five columns across a week, which is
+   * three screens of sideways scrolling and names truncated to nothing. Most of
+   * the time someone is looking at their own half of the shop, so the board
+   * lets them say which lanes those are.
+   */
+  hiddenLaneIds: string[]
+}
+
+export const DEFAULT_PREFERENCES: BoardPreferences = {
+  grouping: 'technician',
+  layout: 'timeline',
+  zoom: MIN_ZOOM,
+  showWeekends: false,
+  snapMinutes: 15,
+  hiddenLaneIds: [],
+}
+
+const STORAGE_KEY = 'workboard.preferences'
+
+function readStored(): Partial<BoardPreferences> {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY)
+    if (!raw) return {}
+    const parsed = JSON.parse(raw) as Record<string, unknown>
+    const out: Partial<BoardPreferences> = {}
+    if (isLaneGrouping(parsed.grouping)) out.grouping = parsed.grouping
+    if (isBoardLayout(parsed.layout)) out.layout = parsed.layout
+    if (typeof parsed.zoom === 'number') out.zoom = clampZoom(parsed.zoom)
+    if (typeof parsed.showWeekends === 'boolean') out.showWeekends = parsed.showWeekends
+    if (SNAP_CHOICES.includes(parsed.snapMinutes as SnapMinutes)) {
+      out.snapMinutes = parsed.snapMinutes as SnapMinutes
+    }
+    if (Array.isArray(parsed.hiddenLaneIds)) {
+      out.hiddenLaneIds = parsed.hiddenLaneIds.filter((id): id is string => typeof id === 'string')
+    }
+    return out
+  } catch {
+    return {}
+  }
+}
+
+/**
+ * Board preferences, kept on the device.
+ *
+ * These are how one person likes to look at the week, not shop configuration,
+ * so they live in localStorage next to the fullscreen preference rather than in
+ * workshop settings. They are read after mount so the server-rendered markup
+ * and the first client render agree.
+ */
+export function useBoardPreferences(overrides?: Partial<BoardPreferences>) {
+  const [preferences, setPreferences] = useState<BoardPreferences>({
+    ...DEFAULT_PREFERENCES,
+    ...overrides,
+  })
+
+  useEffect(() => {
+    const stored = readStored()
+    if (Object.keys(stored).length === 0) return
+    setPreferences((current) => ({ ...current, ...stored, ...overrides }))
+    // Overrides come from the URL and are fixed for the life of the page.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  const update = useCallback((patch: Partial<BoardPreferences>) => {
+    setPreferences((current) => {
+      const next = { ...current, ...patch }
+      try {
+        localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
+      } catch {
+        // A browser refusing storage should not stop the board from changing.
+      }
+      return next
+    })
+  }, [])
+
+  return { preferences, update }
+}

+ 96 - 86
src/features/workboard/hooks/useWorkBoardWebSocket.ts

@@ -1,152 +1,162 @@
-"use client";
+'use client'
 
 
-import { useEffect, useRef } from "react";
-import { useWorkBoardStore } from "../store/workboardStore";
-import type { WorkBoardJob } from "../Actions/boardActions";
+import { useEffect, useRef } from 'react'
+import { useWorkBoardStore } from '../store/workboardStore'
+import type { WorkBoardJob } from '../Actions/boardActions'
 
 
 export function useWorkBoardWebSocket() {
 export function useWorkBoardWebSocket() {
-  const wsRef = useRef<WebSocket | null>(null);
-  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
-  const mountedRef = useRef(true);
+  const wsRef = useRef<WebSocket | null>(null)
+  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
+  const mountedRef = useRef(true)
 
 
   useEffect(() => {
   useEffect(() => {
-    mountedRef.current = true;
+    mountedRef.current = true
 
 
     function connect() {
     function connect() {
-      if (!mountedRef.current) return;
+      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;
+      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 = () => {
       ws.onopen = () => {
-        useWorkBoardStore.getState().setConnected(true);
-      };
+        useWorkBoardStore.getState().setConnection('open')
+      }
 
 
       ws.onmessage = (event) => {
       ws.onmessage = (event) => {
         try {
         try {
-          const msg = JSON.parse(event.data);
-          if (msg.type !== "workboard") return;
+          const msg = JSON.parse(event.data)
+          if (msg.type !== 'workboard') return
 
 
-          const store = useWorkBoardStore.getState();
-          const data = msg.data;
+          const store = useWorkBoardStore.getState()
+          const data = msg.data
 
 
           switch (data.type) {
           switch (data.type) {
-            case "job_assigned":
-              store.addJob(data.job as WorkBoardJob);
+            case 'job_assigned':
+              store.addJob(data.job as WorkBoardJob)
               // Remove from unassigned lists based on job type and id
               // Remove from unassigned lists based on job type and id
-              if (data.job.type === "serviceRecord") {
-                store.removeFromUnassigned(data.job.id, "serviceRecord");
-              } else if (data.job.type === "inspection") {
-                store.removeFromUnassigned(data.job.id, "inspection");
+              if (data.job.type === 'serviceRecord') {
+                store.removeFromUnassigned(data.job.id, 'serviceRecord')
+              } else if (data.job.type === 'inspection') {
+                store.removeFromUnassigned(data.job.id, 'inspection')
               }
               }
-              break;
+              break
 
 
-            case "job_moved":
-              store.updateJob(data.job as WorkBoardJob);
-              break;
+            case 'job_moved':
+            case 'job_scheduled':
+              store.updateJob(data.job as WorkBoardJob)
+              break
 
 
-            case "job_unassigned":
-              store.removeJob(data.jobId);
-              break;
+            case 'job_unassigned':
+              store.removeJob(data.jobId)
+              break
 
 
-            case "technician_created":
-              store.addTechnician(data.technician);
-              break;
+            case 'technician_created':
+              store.addTechnician(data.technician)
+              break
 
 
-            case "technician_updated":
-            case "technician_removed":
+            case 'work_bay_created':
+            case 'work_bay_updated':
+              store.upsertWorkBay(data.workBay)
+              break
+
+            case 'work_bay_removed':
+              store.removeWorkBay(data.workBayId as string)
+              break
+
+            case 'technician_updated':
+            case 'technician_removed':
               // Reload full technician list for updates/removals
               // Reload full technician list for updates/removals
-              import("../Actions/technicianActions").then(({ getTechnicians }) => {
+              import('../Actions/technicianActions').then(({ getTechnicians }) => {
                 getTechnicians().then((res) => {
                 getTechnicians().then((res) => {
                   if (res.success && res.data) {
                   if (res.success && res.data) {
-                    store.setTechnicians(res.data as Parameters<typeof store.setTechnicians>[0]);
+                    store.setTechnicians(res.data as Parameters<typeof store.setTechnicians>[0])
                   }
                   }
-                });
-              });
-              break;
+                })
+              })
+              break
 
 
-            case "service_times_updated": {
-              const { serviceRecordId, startDateTime, endDateTime } = data;
+            case 'service_times_updated': {
+              const { serviceRecordId, startDateTime, endDateTime } = data
               const updatedJobs = store.jobs.map((j) =>
               const updatedJobs = store.jobs.map((j) =>
-                j.type === "serviceRecord" && j.id === serviceRecordId
+                j.type === 'serviceRecord' && j.id === serviceRecordId
                   ? { ...j, startDateTime, endDateTime }
                   ? { ...j, startDateTime, endDateTime }
-                  : j,
-              );
-              store.setJobs(updatedJobs);
-              break;
+                  : j
+              )
+              store.setJobs(updatedJobs)
+              break
             }
             }
 
 
-            case "inspection_times_updated": {
-              const { inspectionId, startDateTime, endDateTime } = data;
+            case 'inspection_times_updated': {
+              const { inspectionId, startDateTime, endDateTime } = data
               const updatedJobs = store.jobs.map((j) =>
               const updatedJobs = store.jobs.map((j) =>
-                j.type === "inspection" && j.id === inspectionId
+                j.type === 'inspection' && j.id === inspectionId
                   ? { ...j, startDateTime, endDateTime }
                   ? { ...j, startDateTime, endDateTime }
-                  : j,
-              );
-              store.setJobs(updatedJobs);
-              break;
+                  : j
+              )
+              store.setJobs(updatedJobs)
+              break
             }
             }
 
 
-            case "job_status_changed": {
-              const { serviceRecordId, inspectionId, status, serviceRecord } = data;
-              const activeStatuses = ["pending", "in-progress", "waiting-parts", "scheduled"];
+            case 'job_status_changed': {
+              const { serviceRecordId, inspectionId, status, serviceRecord } = data
+              const activeStatuses = ['pending', 'in-progress', 'waiting-parts', 'scheduled']
 
 
               // Update the status on matching jobs in-place
               // Update the status on matching jobs in-place
               const updated = store.jobs.map((j) => {
               const updated = store.jobs.map((j) => {
-                if (serviceRecordId && j.type === "serviceRecord" && j.id === serviceRecordId) {
-                  return { ...j, status };
+                if (serviceRecordId && j.type === 'serviceRecord' && j.id === serviceRecordId) {
+                  return { ...j, status }
                 }
                 }
-                if (inspectionId && j.type === "inspection" && j.id === inspectionId) {
-                  return { ...j, status };
+                if (inspectionId && j.type === 'inspection' && j.id === inspectionId) {
+                  return { ...j, status }
                 }
                 }
-                return j;
-              });
-              store.setJobs(updated);
+                return j
+              })
+              store.setJobs(updated)
 
 
               // Add/remove from unassigned pool for service records
               // Add/remove from unassigned pool for service records
               if (serviceRecordId && serviceRecord) {
               if (serviceRecordId && serviceRecord) {
                 const isAssigned = store.jobs.some(
                 const isAssigned = store.jobs.some(
-                  (j) => j.type === "serviceRecord" && j.id === serviceRecordId,
-                );
+                  (j) => j.type === 'serviceRecord' && j.id === serviceRecordId
+                )
                 const isAlreadyUnassigned = store.unassignedServiceRecords.some(
                 const isAlreadyUnassigned = store.unassignedServiceRecords.some(
-                  (sr) => sr.id === serviceRecordId,
-                );
+                  (sr) => sr.id === serviceRecordId
+                )
 
 
                 if (activeStatuses.includes(status) && !isAssigned && !isAlreadyUnassigned) {
                 if (activeStatuses.includes(status) && !isAssigned && !isAlreadyUnassigned) {
-                  store.addToUnassigned(serviceRecord, "serviceRecord");
+                  store.addToUnassigned(serviceRecord, 'serviceRecord')
                 } else if (!activeStatuses.includes(status)) {
                 } else if (!activeStatuses.includes(status)) {
-                  store.removeFromUnassigned(serviceRecordId, "serviceRecord");
+                  store.removeFromUnassigned(serviceRecordId, 'serviceRecord')
                 }
                 }
               }
               }
-              break;
+              break
             }
             }
           }
           }
         } catch {
         } catch {
           // ignore malformed messages
           // ignore malformed messages
         }
         }
-      };
+      }
 
 
       ws.onclose = () => {
       ws.onclose = () => {
-        useWorkBoardStore.getState().setConnected(false);
-        wsRef.current = null;
+        useWorkBoardStore.getState().setConnection('closed')
+        wsRef.current = null
         if (mountedRef.current) {
         if (mountedRef.current) {
-          reconnectTimer.current = setTimeout(connect, 3000);
+          reconnectTimer.current = setTimeout(connect, 3000)
         }
         }
-      };
+      }
 
 
       ws.onerror = () => {
       ws.onerror = () => {
-        ws.close();
-      };
+        ws.close()
+      }
     }
     }
 
 
-    connect();
+    connect()
 
 
     return () => {
     return () => {
-      mountedRef.current = false;
-      clearTimeout(reconnectTimer.current);
-      wsRef.current?.close();
-    };
-  }, []);
+      mountedRef.current = false
+      clearTimeout(reconnectTimer.current)
+      wsRef.current?.close()
+    }
+  }, [])
 }
 }

+ 118 - 76
src/features/workboard/store/workboardStore.ts

@@ -1,90 +1,127 @@
-import { create } from "zustand";
-import type { WorkBoardJob } from "../Actions/boardActions";
+import { create } from 'zustand'
+import type { WorkBay, WorkBoardJob } from '../Actions/boardActions'
 
 
 type UnassignedServiceRecord = {
 type UnassignedServiceRecord = {
-  id: string;
-  title: string;
-  status: string;
+  id: string
+  title: string
+  status: string
   // null for parts-only counter sales
   // null for parts-only counter sales
   vehicle: {
   vehicle: {
-    id: string;
-    make: string;
-    model: string;
-    year: number;
-    licensePlate: string | null;
-  } | null;
-};
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+  } | null
+}
 
 
 type UnassignedInspection = {
 type UnassignedInspection = {
-  id: string;
-  status: string;
+  id: string
+  status: string
   vehicle: {
   vehicle: {
-    id: string;
-    make: string;
-    model: string;
-    year: number;
-    licensePlate: string | null;
-  };
-  template: { name: string };
-};
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+  }
+  template: { name: string }
+}
 
 
 export type Technician = {
 export type Technician = {
-  id: string;
-  name: string;
-  color: string;
-  isActive: boolean;
-  sortOrder: number;
-  dailyCapacity: number;
-  userId: string | null;
-  organizationId: string;
-};
+  id: string
+  name: string
+  color: string
+  isActive: boolean
+  sortOrder: number
+  dailyCapacity: number
+  userId: string | null
+  organizationId: string
+}
 
 
 type WorkBoardState = {
 type WorkBoardState = {
-  technicians: Technician[];
-  jobs: WorkBoardJob[];
-  unassignedServiceRecords: UnassignedServiceRecord[];
-  unassignedInspections: UnassignedInspection[];
-  weekStart: string;
-  isConnected: boolean;
-
-  setTechnicians: (techs: Technician[]) => void;
-  addTechnician: (tech: Technician) => void;
-  removeTechnician: (id: string) => void;
-
-  setJobs: (jobs: WorkBoardJob[]) => void;
-  addJob: (job: WorkBoardJob) => void;
-  updateJob: (job: WorkBoardJob) => void;
-  removeJob: (id: string) => void;
+  technicians: Technician[]
+  workBays: WorkBay[]
+  jobs: WorkBoardJob[]
+  unassignedServiceRecords: UnassignedServiceRecord[]
+  unassignedInspections: UnassignedInspection[]
+  weekStart: string
+  /**
+   * Three states, not two.
+   *
+   * A board that has not finished opening its socket is not disconnected, and
+   * saying so on first paint put a red warning on screen for the split second
+   * before the connection landed.
+   */
+  connection: 'connecting' | 'open' | 'closed'
+
+  setTechnicians: (techs: Technician[]) => void
+  addTechnician: (tech: Technician) => void
+  removeTechnician: (id: string) => void
+
+  setWorkBays: (bays: WorkBay[]) => void
+  upsertWorkBay: (bay: WorkBay) => void
+  removeWorkBay: (id: string) => void
+
+  setJobs: (jobs: WorkBoardJob[]) => void
+  addJob: (job: WorkBoardJob) => void
+  updateJob: (job: WorkBoardJob) => void
+  removeJob: (id: string) => void
 
 
   setUnassigned: (
   setUnassigned: (
     serviceRecords: UnassignedServiceRecord[],
     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;
-
-  updateServiceTimes: (jobId: string, startDateTime: string, endDateTime: string) => void;
-  optimisticMove: (jobId: string, newTechId: string) => void;
-};
+    inspections: UnassignedInspection[]
+  ) => void
+  removeFromUnassigned: (jobId: string, type: 'serviceRecord' | 'inspection') => void
+  addToUnassigned: (
+    job: UnassignedServiceRecord | UnassignedInspection,
+    type: 'serviceRecord' | 'inspection'
+  ) => void
+
+  setWeekStart: (weekStart: string) => void
+  setConnection: (connection: 'connecting' | 'open' | 'closed') => void
+
+  updateServiceTimes: (jobId: string, startDateTime: string, endDateTime: string) => void
+  optimisticMove: (jobId: string, newTechId: string) => void
+  /** Apply a lane and/or time change to one job without waiting for the server. */
+  optimisticSchedule: (
+    jobId: string,
+    patch: Partial<
+      Pick<WorkBoardJob, 'technicianId' | 'workBayId' | 'startDateTime' | 'endDateTime'>
+    >
+  ) => void
+}
 
 
 export const useWorkBoardStore = create<WorkBoardState>((set) => ({
 export const useWorkBoardStore = create<WorkBoardState>((set) => ({
   technicians: [],
   technicians: [],
+  workBays: [],
   jobs: [],
   jobs: [],
   unassignedServiceRecords: [],
   unassignedServiceRecords: [],
   unassignedInspections: [],
   unassignedInspections: [],
-  weekStart: "",
-  isConnected: false,
+  weekStart: '',
+  connection: 'connecting',
 
 
   setTechnicians: (technicians) => set({ technicians }),
   setTechnicians: (technicians) => set({ technicians }),
   addTechnician: (tech) =>
   addTechnician: (tech) =>
     set((s) => ({
     set((s) => ({
       technicians: [...s.technicians.filter((t) => t.id !== tech.id), tech],
       technicians: [...s.technicians.filter((t) => t.id !== tech.id), tech],
     })),
     })),
-  removeTechnician: (id) =>
-    set((s) => ({ technicians: s.technicians.filter((t) => t.id !== id) })),
+  removeTechnician: (id) => set((s) => ({ technicians: s.technicians.filter((t) => t.id !== id) })),
+
+  setWorkBays: (workBays) => set({ workBays }),
+  upsertWorkBay: (bay) =>
+    set((s) => ({
+      workBays: s.workBays.some((b) => b.id === bay.id)
+        ? s.workBays.map((b) => (b.id === bay.id ? bay : b))
+        : [...s.workBays, bay].sort((a, b) => a.sortOrder - b.sortOrder),
+    })),
+  removeWorkBay: (id) =>
+    set((s) => ({
+      workBays: s.workBays.filter((b) => b.id !== id),
+      // The bay is gone; its jobs stay scheduled and fall into the "no bay"
+      // lane, mirroring the SetNull the database just performed.
+      jobs: s.jobs.map((j) => (j.workBayId === id ? { ...j, workBayId: null } : j)),
+    })),
 
 
   setJobs: (jobs) => set({ jobs }),
   setJobs: (jobs) => set({ jobs }),
   addJob: (job) =>
   addJob: (job) =>
@@ -95,38 +132,43 @@ export const useWorkBoardStore = create<WorkBoardState>((set) => ({
     set((s) => ({
     set((s) => ({
       jobs: s.jobs.map((j) => (j.id === job.id ? job : j)),
       jobs: s.jobs.map((j) => (j.id === job.id ? job : j)),
     })),
     })),
-  removeJob: (id) =>
-    set((s) => ({ jobs: s.jobs.filter((j) => j.id !== id) })),
+  removeJob: (id) => set((s) => ({ jobs: s.jobs.filter((j) => j.id !== id) })),
 
 
   setUnassigned: (serviceRecords, inspections) =>
   setUnassigned: (serviceRecords, inspections) =>
     set({ unassignedServiceRecords: serviceRecords, unassignedInspections: inspections }),
     set({ unassignedServiceRecords: serviceRecords, unassignedInspections: inspections }),
   removeFromUnassigned: (jobId, type) =>
   removeFromUnassigned: (jobId, type) =>
     set((s) =>
     set((s) =>
-      type === "serviceRecord"
+      type === 'serviceRecord'
         ? { unassignedServiceRecords: s.unassignedServiceRecords.filter((sr) => sr.id !== jobId) }
         ? { unassignedServiceRecords: s.unassignedServiceRecords.filter((sr) => sr.id !== jobId) }
-        : { unassignedInspections: s.unassignedInspections.filter((i) => i.id !== jobId) },
+        : { unassignedInspections: s.unassignedInspections.filter((i) => i.id !== jobId) }
     ),
     ),
   addToUnassigned: (job, type) =>
   addToUnassigned: (job, type) =>
     set((s) =>
     set((s) =>
-      type === "serviceRecord"
-        ? { unassignedServiceRecords: [job as UnassignedServiceRecord, ...s.unassignedServiceRecords] }
-        : { unassignedInspections: [job as UnassignedInspection, ...s.unassignedInspections] },
+      type === 'serviceRecord'
+        ? {
+            unassignedServiceRecords: [
+              job as UnassignedServiceRecord,
+              ...s.unassignedServiceRecords,
+            ],
+          }
+        : { unassignedInspections: [job as UnassignedInspection, ...s.unassignedInspections] }
     ),
     ),
 
 
   updateServiceTimes: (jobId, startDateTime, endDateTime) =>
   updateServiceTimes: (jobId, startDateTime, endDateTime) =>
     set((s) => ({
     set((s) => ({
-      jobs: s.jobs.map((j) =>
-        j.id === jobId ? { ...j, startDateTime, endDateTime } : j,
-      ),
+      jobs: s.jobs.map((j) => (j.id === jobId ? { ...j, startDateTime, endDateTime } : j)),
     })),
     })),
 
 
   setWeekStart: (weekStart) => set({ weekStart }),
   setWeekStart: (weekStart) => set({ weekStart }),
-  setConnected: (isConnected) => set({ isConnected }),
+  setConnection: (connection) => set({ connection }),
 
 
   optimisticMove: (jobId, newTechId) =>
   optimisticMove: (jobId, newTechId) =>
     set((s) => ({
     set((s) => ({
-      jobs: s.jobs.map((j) =>
-        j.id === jobId ? { ...j, technicianId: newTechId } : j,
-      ),
+      jobs: s.jobs.map((j) => (j.id === jobId ? { ...j, technicianId: newTechId } : j)),
+    })),
+
+  optimisticSchedule: (jobId, patch) =>
+    set((s) => ({
+      jobs: s.jobs.map((j) => (j.id === jobId ? { ...j, ...patch } : j)),
     })),
     })),
-}));
+}))

+ 33 - 0
src/features/workboard/utils/clock.ts

@@ -0,0 +1,33 @@
+/**
+ * Times as the workshop reads them.
+ *
+ * The board is a grid of clock faces, so it has to speak the shop's convention:
+ * a shop set to 12-hour time should never see 13:00 on its own planner. The
+ * short forms matter as much as the correctness, because these labels sit in a
+ * 60px gutter and inside blocks that can be one line tall.
+ */
+
+export type ClockFormat = '12h' | '24h'
+
+/** Minutes from midnight to a label: "07:00", or "7 AM" / "7:30 AM". */
+export function formatClock(mins: number, format: ClockFormat): string {
+  const clamped = Math.max(0, Math.min(1440, Math.round(mins)))
+  const hours = Math.floor(clamped / 60)
+  const minutes = clamped % 60
+
+  if (format === '24h') {
+    return `${String(hours % 24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
+  }
+
+  const suffix = hours < 12 || hours === 24 ? 'AM' : 'PM'
+  const hour12 = hours % 12 === 0 ? 12 : hours % 12
+  // On the hour the minutes are noise; the gutter has room for four characters.
+  return minutes === 0
+    ? `${hour12} ${suffix}`
+    : `${hour12}:${String(minutes).padStart(2, '0')} ${suffix}`
+}
+
+/** A start and end pair, as shown on a block being dragged. */
+export function formatClockRange(startMins: number, endMins: number, format: ClockFormat): string {
+  return `${formatClock(startMins, format)} – ${formatClock(endMins, format)}`
+}

+ 28 - 0
src/features/workboard/utils/job-colors.ts

@@ -0,0 +1,28 @@
+/**
+ * Block fills for the week timeline.
+ *
+ * A whole week of work is too much to read job by job, so colour carries the
+ * status: the eye should find every job still waiting for parts without
+ * reading a single label. The hues match the status badges used in the tables
+ * (`src/lib/table-utils.ts`), at a fill strong enough to read as a solid block.
+ */
+
+const STATUS_BLOCK_COLORS: Record<string, string> = {
+  pending: 'bg-yellow-400/80 text-yellow-950 dark:bg-yellow-600/70 dark:text-yellow-50',
+  scheduled: 'bg-sky-400/80 text-sky-950 dark:bg-sky-600/70 dark:text-sky-50',
+  'in-progress': 'bg-blue-400/80 text-blue-950 dark:bg-blue-600/70 dark:text-blue-50',
+  'waiting-parts': 'bg-orange-400/80 text-orange-950 dark:bg-orange-600/70 dark:text-orange-50',
+  completed: 'bg-emerald-400/80 text-emerald-950 dark:bg-emerald-600/70 dark:text-emerald-50',
+  cancelled: 'bg-slate-300/80 text-slate-700 dark:bg-slate-700/70 dark:text-slate-200',
+}
+
+const FALLBACK = 'bg-slate-400/80 text-slate-950 dark:bg-slate-600/70 dark:text-slate-50'
+
+/** Inspections spell their statuses with underscores; work orders with dashes. */
+export function normalizeStatus(status: string): string {
+  return status.replace(/_/g, '-').toLowerCase()
+}
+
+export function statusBlockColor(status: string): string {
+  return STATUS_BLOCK_COLORS[normalizeStatus(status)] ?? FALLBACK
+}

+ 144 - 0
src/features/workboard/utils/lanes.ts

@@ -0,0 +1,144 @@
+/**
+ * Lanes are what the board draws its columns from.
+ *
+ * A workshop plans by whichever resource is scarce: the person on some days,
+ * the lift on others. Rather than hard-wiring technicians into the board, every
+ * view asks for lanes under a grouping and gets back the same shape, so adding
+ * a third way to slice the week later is a new `LaneGrouping` and nothing else.
+ */
+
+import type { WorkBay, WorkBoardJob } from '../Actions/boardActions'
+import type { Technician } from '../store/workboardStore'
+
+export type LaneGrouping = 'technician' | 'bay' | 'none'
+
+export const LANE_GROUPINGS: LaneGrouping[] = ['technician', 'bay', 'none']
+
+export function isLaneGrouping(value: unknown): value is LaneGrouping {
+  return typeof value === 'string' && (LANE_GROUPINGS as string[]).includes(value)
+}
+
+/** Lane id for work that has no value for the current grouping. */
+export const UNLANED = '__unlaned__'
+/** Lane id when the board is not split at all. */
+export const ALL_LANE = '__all__'
+
+export type BoardLane = {
+  id: string
+  name: string
+  color: string
+  /** Bookable minutes per day. 0 means "do not show utilisation". */
+  dailyCapacity: number
+  grouping: LaneGrouping
+  /** True for the catch-all lane, which nothing can be permanently assigned to. */
+  isPlaceholder: boolean
+}
+
+/**
+ * Which lane a job belongs in under a grouping.
+ *
+ * Pass `known` to fold work that points at a lane the board is not showing (a
+ * technician since made inactive, a bay deleted in another tab) into the
+ * catch-all lane. Without it such a job would be bucketed under an id nothing
+ * renders and would quietly vanish from the week.
+ */
+export function laneIdForJob(
+  job: WorkBoardJob,
+  grouping: LaneGrouping,
+  known?: ReadonlySet<string>
+): string {
+  if (grouping === 'none') return ALL_LANE
+  const id = grouping === 'technician' ? job.technicianId : job.workBayId
+  if (!id) return UNLANED
+  return known && !known.has(id) ? UNLANED : id
+}
+
+/** The field a drop into a lane writes, ready to spread into `scheduleJob`. */
+export function laneAssignment(
+  laneId: string,
+  grouping: LaneGrouping
+): { technicianId?: string | null; workBayId?: string | null } {
+  if (grouping === 'none' || laneId === ALL_LANE) return {}
+  const value = laneId === UNLANED ? null : laneId
+  return grouping === 'technician' ? { technicianId: value } : { workBayId: value }
+}
+
+export function buildLanes({
+  grouping,
+  technicians,
+  workBays,
+  jobs,
+  labels,
+}: {
+  grouping: LaneGrouping
+  technicians: Technician[]
+  workBays: WorkBay[]
+  jobs: WorkBoardJob[]
+  labels: { unlaned: string; all: string }
+}): BoardLane[] {
+  if (grouping === 'none') {
+    return [
+      {
+        id: ALL_LANE,
+        name: labels.all,
+        color: '#64748b',
+        dailyCapacity: technicians.reduce((sum, t) => sum + t.dailyCapacity, 0),
+        grouping,
+        isPlaceholder: false,
+      },
+    ]
+  }
+
+  const lanes: BoardLane[] =
+    grouping === 'technician'
+      ? technicians.map((t) => ({
+          id: t.id,
+          name: t.name,
+          color: t.color,
+          dailyCapacity: t.dailyCapacity,
+          grouping,
+          isPlaceholder: false,
+        }))
+      : workBays.map((b) => ({
+          id: b.id,
+          name: b.name,
+          color: b.color,
+          dailyCapacity: b.dailyCapacity,
+          grouping,
+          isPlaceholder: false,
+        }))
+
+  // Grouping by bay in a shop that has only just added bays would otherwise
+  // hide every job on the board. The catch-all lane appears only when it has
+  // something in it, and disappears again once everything is placed.
+  const known = new Set(lanes.map((lane) => lane.id))
+  const hasUnlaned = jobs.some((job) => laneIdForJob(job, grouping, known) === UNLANED)
+  if (hasUnlaned) {
+    lanes.push({
+      id: UNLANED,
+      name: labels.unlaned,
+      color: '#94a3b8',
+      dailyCapacity: 0,
+      grouping,
+      isPlaceholder: true,
+    })
+  }
+
+  return lanes
+}
+
+/** Jobs bucketed by lane id, so a lane column never scans the whole week. */
+export function groupJobsByLane(
+  jobs: WorkBoardJob[],
+  grouping: LaneGrouping,
+  known?: ReadonlySet<string>
+): Map<string, WorkBoardJob[]> {
+  const map = new Map<string, WorkBoardJob[]>()
+  for (const job of jobs) {
+    const laneId = laneIdForJob(job, grouping, known)
+    const bucket = map.get(laneId)
+    if (bucket) bucket.push(job)
+    else map.set(laneId, [job])
+  }
+  return map
+}

+ 242 - 0
src/features/workboard/utils/layout.ts

@@ -0,0 +1,242 @@
+/**
+ * Pure geometry for the week timeline.
+ *
+ * Everything here answers one of two questions: where does a job sit on a given
+ * day's time axis, and how do jobs that overlap share the width of a lane. It
+ * is deliberately free of React and of Date formatting so it can be tested
+ * directly and reused by any view that draws jobs against a clock.
+ */
+
+import type { WorkBoardJob } from '../Actions/boardActions'
+import { getJobDateRange } from './datetime'
+
+export const MINUTES_IN_DAY = 1440
+
+export type TimeWindow = {
+  /** Minutes from midnight of the first visible row. */
+  startMins: number
+  /** Minutes from midnight of the last visible row. Always > startMins. */
+  endMins: number
+}
+
+/** A job placed on one day's axis, ready to be given a height and an offset. */
+export type PositionedJob = {
+  job: WorkBoardJob
+  /** Clamped to the visible window. */
+  startMins: number
+  endMins: number
+  /** Zero-based column inside its overlap cluster. */
+  column: number
+  /** How many columns that cluster needs. */
+  columns: number
+  /** The job really starts before the visible window (earlier day, or earlier hour). */
+  continuesBefore: boolean
+  /** The job really ends after it. */
+  continuesAfter: boolean
+}
+
+export function minutesOfDay(d: Date): number {
+  return d.getHours() * 60 + d.getMinutes()
+}
+
+/** Local midnight of a YYYY-MM-DD day string. */
+export function dayStartDate(dateStr: string): Date {
+  return new Date(`${dateStr}T00:00:00`)
+}
+
+/**
+ * Where a job sits on one calendar day, before overlap resolution.
+ *
+ * Returns null when the job does not touch that day or has no times at all.
+ * A job running Tuesday 16:00 to Wednesday 09:00 resolves on both days, with
+ * `continuesAfter` on Tuesday and `continuesBefore` on Wednesday, so neither
+ * end of it can be mistaken for a real start or finish.
+ */
+export function resolveJobOnDay(
+  job: WorkBoardJob,
+  dateStr: string,
+  window: TimeWindow
+): Omit<PositionedJob, 'column' | 'columns'> | null {
+  const { start, end } = getJobDateRange(job)
+  if (!start || !end) return null
+
+  const dayStart = dayStartDate(dateStr)
+  const dayEnd = new Date(dayStart)
+  dayEnd.setDate(dayEnd.getDate() + 1)
+
+  if (!(start < dayEnd && end > dayStart)) return null
+
+  const rawStart = start <= dayStart ? 0 : minutesOfDay(start)
+  const rawEnd = end >= dayEnd ? MINUTES_IN_DAY : minutesOfDay(end)
+
+  const startMins = Math.max(rawStart, window.startMins)
+  const endMins = Math.min(Math.max(rawEnd, startMins), window.endMins)
+
+  // A job entirely outside the visible hours would collapse to a zero-height
+  // sliver at one edge; the caller shows those in the out-of-hours strip.
+  if (rawEnd <= window.startMins || rawStart >= window.endMins) return null
+
+  return {
+    job,
+    startMins,
+    endMins,
+    continuesBefore: rawStart < window.startMins || start < dayStart,
+    continuesAfter: rawEnd > window.endMins || end > dayEnd,
+  }
+}
+
+/**
+ * Lay out one lane's jobs for one day: side by side where they overlap, full
+ * width where they do not.
+ *
+ * Jobs are grouped into clusters of transitively overlapping work; every job in
+ * a cluster reports the same column count, so a cluster of three renders as
+ * three even columns and a lone job either side of it still renders full width.
+ */
+export function layoutLaneDay(
+  jobs: WorkBoardJob[],
+  dateStr: string,
+  window: TimeWindow
+): PositionedJob[] {
+  const resolved = jobs
+    .map((job) => resolveJobOnDay(job, dateStr, window))
+    .filter((r): r is NonNullable<typeof r> => r !== null)
+    .sort(
+      (a, b) =>
+        a.startMins - b.startMins || b.endMins - a.endMins || a.job.id.localeCompare(b.job.id)
+    )
+
+  const positioned: PositionedJob[] = []
+  /** End time of the job currently occupying each column. */
+  let columnEnds: number[] = []
+  let clusterStart = 0
+  let clusterEnd = -1
+
+  const closeCluster = () => {
+    const columns = columnEnds.length
+    for (let i = clusterStart; i < positioned.length; i++) {
+      positioned[i].columns = columns
+    }
+    columnEnds = []
+    clusterStart = positioned.length
+  }
+
+  for (const item of resolved) {
+    if (item.startMins >= clusterEnd && columnEnds.length > 0) {
+      closeCluster()
+      clusterEnd = -1
+    }
+
+    let column = columnEnds.findIndex((end) => end <= item.startMins)
+    if (column === -1) {
+      column = columnEnds.length
+      columnEnds.push(item.endMins)
+    } else {
+      columnEnds[column] = item.endMins
+    }
+
+    clusterEnd = Math.max(clusterEnd, item.endMins)
+    positioned.push({ ...item, column, columns: 1 })
+  }
+  if (columnEnds.length > 0) closeCluster()
+
+  return positioned
+}
+
+/** Jobs with a lane but no times: real work that nobody has put on the clock yet. */
+export function isUnscheduled(job: WorkBoardJob): boolean {
+  return !job.startDateTime || !job.endDateTime
+}
+
+/**
+ * The hours the board shows.
+ *
+ * The configured work day is the floor. Anything booked outside it stretches
+ * the axis to the nearest hour rather than being hidden, because a job that
+ * runs to 19:00 in a shop that closes at 17:00 is exactly the job someone needs
+ * to see.
+ */
+export function computeTimeWindow(
+  jobs: WorkBoardJob[],
+  days: string[],
+  workDayStart: number,
+  workDayEnd: number
+): TimeWindow {
+  let startMins = workDayStart
+  let endMins = Math.max(workDayEnd, workDayStart + 60)
+
+  if (days.length > 0) {
+    const first = dayStartDate(days[0])
+    const last = dayStartDate(days[days.length - 1])
+    last.setDate(last.getDate() + 1)
+
+    /** Pull the axis open until it contains this time of day. */
+    const include = (mins: number) => {
+      startMins = Math.min(startMins, Math.floor(mins / 60) * 60)
+      endMins = Math.max(endMins, Math.ceil(mins / 60) * 60)
+    }
+
+    for (const job of jobs) {
+      const { start, end } = getJobDateRange(job)
+      if (!start || !end) continue
+      if (!(start < last && end > first)) continue
+
+      // Only the endpoints that actually fall in the shown days can widen the
+      // axis. A job running across several days covers its middle days
+      // completely; letting that widen the axis to midnight would flatten the
+      // whole week for one long job, so those days render clamped with a
+      // continuation marker instead.
+      if (start >= first && start < last) include(minutesOfDay(start))
+      if (end > first && end <= last) {
+        const endOfDay = minutesOfDay(end)
+        // Midnight reads as the end of the previous day, not the start of a new one.
+        include(endOfDay === 0 ? MINUTES_IN_DAY : endOfDay)
+      }
+    }
+  }
+
+  return {
+    startMins: Math.max(0, Math.min(startMins, MINUTES_IN_DAY - 60)),
+    endMins: Math.min(MINUTES_IN_DAY, Math.max(endMins, startMins + 60)),
+  }
+}
+
+/** Hour marks for the time gutter, inclusive of the closing hour. */
+export function hourMarks(window: TimeWindow): number[] {
+  const marks: number[] = []
+  const first = Math.floor(window.startMins / 60) * 60
+  for (let m = first; m <= window.endMins; m += 60) {
+    if (m >= window.startMins) marks.push(m)
+  }
+  return marks
+}
+
+/** Snap a minute value to the nearest step, keeping it inside the window. */
+export function snapToStep(mins: number, step: number, window: TimeWindow): number {
+  const snapped = Math.round(mins / step) * step
+  return Math.max(window.startMins, Math.min(window.endMins, snapped))
+}
+
+/**
+ * Minutes of work a lane carries on one day.
+ *
+ * Clamped to the calendar day rather than to the visible hours: a job that runs
+ * past closing time is still work someone has to do, and the utilisation figure
+ * is the one number that tells a planner whether the day is full.
+ */
+export function bookedMinutesOnDay(jobs: WorkBoardJob[], dateStr: string): number {
+  const dayStart = dayStartDate(dateStr)
+  const dayEnd = new Date(dayStart)
+  dayEnd.setDate(dayEnd.getDate() + 1)
+
+  let total = 0
+  for (const job of jobs) {
+    const { start, end } = getJobDateRange(job)
+    if (!start || !end) continue
+    if (!(start < dayEnd && end > dayStart)) continue
+    const from = start < dayStart ? dayStart : start
+    const to = end > dayEnd ? dayEnd : end
+    total += Math.max(0, Math.round((to.getTime() - from.getTime()) / 60000))
+  }
+  return total
+}

+ 7 - 0
src/lib/backup/manifest.ts

@@ -93,6 +93,13 @@ export const BACKUP_ENTITIES: readonly BackupEntity[] = [
     restore: 'replace',
     restore: 'replace',
     clearOrder: 70,
     clearOrder: 70,
   },
   },
+  {
+    model: 'WorkBay',
+    key: 'workBays',
+    option: 'technicians',
+    restore: 'replace',
+    clearOrder: 71,
+  },
   {
   {
     model: 'InspectionTemplate',
     model: 'InspectionTemplate',
     key: 'inspectionTemplates',
     key: 'inspectionTemplates',