Sfoglia il codice sorgente

Close the workshop on public holidays and set technicians on leave (#552)

* Close the workshop on public holidays and set technicians on leave

Opening hours per weekday, public holidays by country and region (Nager.Date),
holiday calendars from .ics links or files, the workshop's own closed days,
and leave or out of office per technician. The work board, presenter,
calendar and work order scheduler show them and warn; online booking and the
customer portal refuse closed days. The whole feature can be switched off.

* Set leave from the calendar and show opening hours on the portal

Leave can be added, edited and removed from the calendar's Create menu,
a day's right-click menu and a leave entry's card. The customer portal
shows the week's opening hours and the closed days coming up. Google
holiday calendars leave observances open by default. Scheduler clashes
with leave read as days, the technician picker follows a date change,
the booking page shows times on the workshop's clock, and date ranges
render the same on the server and in the browser.

* Harden holiday calendar reads and leave access

Holiday calendar links are fetched with a lookup that refuses private
addresses at connect time, not only when the link is checked. Calendar
list writes take a per-workshop lock and a refresh patches only the
days it read, so it cannot undo a switch or bring back a removed
calendar, and an emptied calendar clears its days. A stale public
holiday sync gives way to a country change.

The board's calendar read needs work board access, and the leave
controls follow withAuth's rules. A portal request is checked against
the day it is stored on, whatever form the date came in. Board spans
are placed by clock time on the nights the clocks change. The settings
page links to its docs.

* Leave two unrelated test files as they were

* Read online booking's whole horizon, and drop unread responses

The booking page read jobs, closed days and leave only 45 days ahead,
while its horizon can reach further, so a closed day in week eight was
offered and could be confirmed. It now reads the whole horizon, and a
confirmation reads the day being booked. Redirect and error responses
from a holiday calendar link are dropped instead of drained.
Bernt Christian Egeland 6 ore fa
parent
commit
3e72dfc22f
100 ha cambiato i file con 6145 aggiunte e 240 eliminazioni
  1. 159 0
      e2e/specs/scheduling/closures.spec.ts
  2. 34 0
      e2e/support/db.ts
  3. 18 5
      messages/de/calendar.json
  4. 7 0
      messages/de/portal.json
  5. 17 3
      messages/de/settings.json
  6. 174 0
      messages/de/workshopCalendar.json
  7. 18 5
      messages/en/calendar.json
  8. 7 0
      messages/en/portal.json
  9. 17 3
      messages/en/settings.json
  10. 174 0
      messages/en/workshopCalendar.json
  11. 18 5
      messages/es/calendar.json
  12. 7 0
      messages/es/portal.json
  13. 17 3
      messages/es/settings.json
  14. 174 0
      messages/es/workshopCalendar.json
  15. 18 5
      messages/fr/calendar.json
  16. 7 0
      messages/fr/portal.json
  17. 17 3
      messages/fr/settings.json
  18. 174 0
      messages/fr/workshopCalendar.json
  19. 18 5
      messages/it/calendar.json
  20. 7 0
      messages/it/portal.json
  21. 17 3
      messages/it/settings.json
  22. 174 0
      messages/it/workshopCalendar.json
  23. 18 5
      messages/lt/calendar.json
  24. 7 0
      messages/lt/portal.json
  25. 17 3
      messages/lt/settings.json
  26. 174 0
      messages/lt/workshopCalendar.json
  27. 18 5
      messages/nb/calendar.json
  28. 7 0
      messages/nb/portal.json
  29. 17 3
      messages/nb/settings.json
  30. 174 0
      messages/nb/workshopCalendar.json
  31. 18 5
      messages/nl/calendar.json
  32. 7 0
      messages/nl/portal.json
  33. 17 3
      messages/nl/settings.json
  34. 174 0
      messages/nl/workshopCalendar.json
  35. 18 5
      messages/pl/calendar.json
  36. 7 0
      messages/pl/portal.json
  37. 17 3
      messages/pl/settings.json
  38. 174 0
      messages/pl/workshopCalendar.json
  39. 18 5
      messages/pt-BR/calendar.json
  40. 7 0
      messages/pt-BR/portal.json
  41. 17 3
      messages/pt-BR/settings.json
  42. 174 0
      messages/pt-BR/workshopCalendar.json
  43. 18 5
      messages/pt-PT/calendar.json
  44. 7 0
      messages/pt-PT/portal.json
  45. 17 3
      messages/pt-PT/settings.json
  46. 174 0
      messages/pt-PT/workshopCalendar.json
  47. 18 5
      messages/ru/calendar.json
  48. 7 0
      messages/ru/portal.json
  49. 17 3
      messages/ru/settings.json
  50. 174 0
      messages/ru/workshopCalendar.json
  51. 18 5
      messages/tr/calendar.json
  52. 7 0
      messages/tr/portal.json
  53. 17 3
      messages/tr/settings.json
  54. 174 0
      messages/tr/workshopCalendar.json
  55. 50 0
      prisma/migrations/20261109152957_workshop_closures/migration.sql
  56. 60 0
      prisma/schema/closures.prisma
  57. 2 0
      prisma/schema/organization.prisma
  58. 1 0
      prisma/schema/technicians.prisma
  59. 95 0
      src/__tests__/features/inspection-reminders/booking-window.test.ts
  60. 115 0
      src/__tests__/features/portal/closed-days.test.ts
  61. 117 0
      src/__tests__/features/portal/opening-hours.test.tsx
  62. 5 0
      src/__tests__/features/workorders/create-workorder.test.ts
  63. 407 0
      src/__tests__/features/workshop-calendar/actions.test.ts
  64. 295 0
      src/__tests__/features/workshop-calendar/calendar.test.ts
  65. 218 0
      src/__tests__/features/workshop-calendar/components.test.tsx
  66. 264 0
      src/__tests__/features/workshop-calendar/holidays.test.ts
  67. 100 0
      src/__tests__/features/workshop-calendar/ics-fetch.test.ts
  68. 242 0
      src/__tests__/features/workshop-calendar/scheduling.test.ts
  69. 1 0
      src/__tests__/lib/backup/build-data.test.ts
  70. 55 0
      src/__tests__/lib/cron/workshop-holidays.test.ts
  71. 1 0
      src/__tests__/setup.ts
  72. 20 0
      src/app/(authenticated)/calendar/page.tsx
  73. 137 0
      src/app/(authenticated)/settings/opening-hours/opening-hours-settings.tsx
  74. 42 0
      src/app/(authenticated)/settings/opening-hours/page.tsx
  75. 9 28
      src/app/(authenticated)/settings/workshop/workshop-settings.tsx
  76. 17 4
      src/app/(public)/b/[token]/booking-client.tsx
  77. 5 2
      src/app/(public)/portal/[orgId]/dashboard/page.tsx
  78. 19 2
      src/app/(public)/portal/[orgId]/request-service/page.tsx
  79. 1 0
      src/components/page-header.tsx
  80. 1 0
      src/cronTasks.ts
  81. 94 1
      src/features/calendar/Actions/calendarActions.ts
  82. 69 3
      src/features/calendar/Components/CalendarClient.tsx
  83. 15 2
      src/features/calendar/Components/CalendarSidebar.tsx
  84. 16 2
      src/features/calendar/Components/CalendarToolbar.tsx
  85. 13 1
      src/features/calendar/Components/DayContextMenu.tsx
  86. 17 5
      src/features/calendar/Components/EventChip.tsx
  87. 60 10
      src/features/calendar/Components/EventPeek.tsx
  88. 23 0
      src/features/calendar/Components/calendar-utils.ts
  89. 70 22
      src/features/inspection-reminders/Lib/booking.ts
  90. 112 2
      src/features/portal/Actions/portalActions.ts
  91. 93 0
      src/features/portal/Components/PortalOpeningHours.tsx
  92. 29 2
      src/features/portal/Components/ServiceRequestForm.tsx
  93. 1 0
      src/features/settings/Lib/featureHints.ts
  94. 2 0
      src/features/settings/Lib/searchIndex/index.ts
  95. 62 0
      src/features/settings/Lib/searchIndex/openingHours.ts
  96. 0 14
      src/features/settings/Lib/searchIndex/workshop.ts
  97. 2 0
      src/features/settings/Lib/settingsPageIcons.ts
  98. 2 0
      src/features/settings/Lib/settingsPages.ts
  99. 19 0
      src/features/settings/Schema/settingsSchema.ts
  100. 166 36
      src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx

+ 159 - 0
e2e/specs/scheduling/closures.spec.ts

@@ -0,0 +1,159 @@
+import { expect, test } from '@playwright/test'
+import {
+  absencesOf,
+  closuresNamed,
+  deleteClosuresNamed,
+  deleteTechnicians,
+  insertTechnician,
+  ownerOrganizationId,
+} from '../../support/db'
+import { fillSettled, settle } from '../../support/hydration'
+
+/**
+ * Leave and closed days, from where they are entered to the work board.
+ *
+ * A technician set on leave from the board's own Leave button shows as away
+ * in their lane, leave set from the calendar shows there and can be removed
+ * from its card, and a closed day added under Settings → Opening hours and
+ * holidays is named in the board's day header. Both are what a service
+ * advisor looks at before handing out work; nothing here stops a booking.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TECH = `E2E Leave ${stamp}`
+const CLOSURE = `E2E Closed ${stamp}`
+
+/** A Wednesday a week or two out: always a working day, never today. */
+function nextWednesday(): string {
+  const d = new Date()
+  d.setUTCDate(d.getUTCDate() + 7 + ((3 - d.getUTCDay() + 7) % 7))
+  return d.toISOString().slice(0, 10)
+}
+const DAY = nextWednesday()
+/** The board's week starts on Monday unless the workshop says otherwise. */
+const WEEK = new Date(Date.parse(`${DAY}T12:00:00Z`) - 2 * 86_400_000).toISOString().slice(0, 10)
+const BOARD = `/work-board?view=week&week=${WEEK}&date=${DAY}&group=technician`
+
+let technicianId = ''
+
+test.beforeAll(async () => {
+  technicianId = await insertTechnician(await ownerOrganizationId(), TECH)
+})
+
+test.afterAll(async () => {
+  await deleteClosuresNamed(CLOSURE)
+  // Time off goes with the technician.
+  if (technicianId) await deleteTechnicians([technicianId])
+})
+
+test('a technician set on leave from the board shows as away in their lane', async ({ page }) => {
+  await page.goto(BOARD)
+  await settle(page)
+
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Leave', exact: true }).click()
+    await expect(page.getByRole('dialog', { name: 'Leave and out of office' })).toBeVisible()
+  }).toPass()
+  await page.getByRole('button', { name: 'Set on leave or out of office' }).click()
+
+  const dialog = page.getByRole('dialog', { name: 'Set on leave or out of office' })
+  await dialog.getByLabel('Technician').click()
+  await page.getByRole('option', { name: TECH }).click()
+  await fillSettled(dialog.locator('#time-off-from'), DAY)
+  await dialog.locator('#time-off-from').press('Tab')
+  await fillSettled(dialog.locator('#time-off-to'), DAY)
+  await dialog.locator('#time-off-to').press('Tab')
+  await dialog.getByRole('button', { name: 'Save' }).click()
+  await expect(dialog).toBeHidden()
+
+  await expect
+    .poll(() => absencesOf(technicianId))
+    .toEqual([{ startDate: DAY, endDate: DAY, reason: 'holiday', startTime: null }])
+
+  // The manager lists it, then the board says so in the lane.
+  const manager = page.getByRole('dialog', { name: 'Leave and out of office' })
+  await expect(manager.getByTestId('time-off-row').filter({ hasText: TECH })).toBeVisible()
+  await page.keyboard.press('Escape')
+
+  await expect(
+    page.getByTestId('lane-away').filter({ hasText: 'On leave' }).filter({ visible: true }).first()
+  ).toBeVisible()
+})
+
+test('leave set from the calendar shows there and can be taken back', async ({ page }) => {
+  // The Thursday after the board's leave, so the two never meet.
+  const day = new Date(Date.parse(`${DAY}T12:00:00Z`) + 86_400_000).toISOString().slice(0, 10)
+  await page.goto(`/calendar?view=week&date=${day}`)
+  await settle(page)
+
+  // The Create menu starts it on the day the calendar is on.
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Create', exact: true }).click()
+    await expect(
+      page.getByRole('menuitem', { name: 'Set on leave or out of office' })
+    ).toBeVisible()
+  }).toPass()
+  await page.getByRole('menuitem', { name: 'Set on leave or out of office' }).click()
+
+  const dialog = page.getByRole('dialog', { name: 'Set on leave or out of office' })
+  await dialog.getByLabel('Technician').click()
+  await page.getByRole('option', { name: TECH }).click()
+  await dialog.getByLabel('Reason').click()
+  await page.getByRole('option', { name: 'Sick leave' }).click()
+  await dialog.getByRole('button', { name: 'Save' }).click()
+  await expect(dialog).toBeHidden()
+
+  await expect
+    .poll(async () => (await absencesOf(technicianId)).filter((a) => a.startDate === day))
+    .toEqual([{ startDate: day, endDate: day, reason: 'sick', startTime: null }])
+
+  const entry = page.getByRole('button', { name: `${TECH}: Sick leave` }).first()
+  await expect(entry).toBeVisible()
+
+  // Its card offers Edit, and the dialog it opens removes it.
+  await entry.click()
+  await page.getByRole('button', { name: 'Edit', exact: true }).click()
+  const edit = page.getByRole('dialog', { name: 'Edit leave or out of office' })
+  await edit.getByRole('button', { name: 'Delete' }).click()
+  await page
+    .getByRole('alertdialog', { name: 'Remove this leave?' })
+    .getByRole('button', { name: 'Delete' })
+    .click()
+
+  await expect
+    .poll(async () => (await absencesOf(technicianId)).filter((a) => a.startDate === day))
+    .toEqual([])
+  await expect(page.getByRole('button', { name: `${TECH}: Sick leave` })).toHaveCount(0)
+})
+
+test('a closed day added in settings is named on the board', async ({ page }) => {
+  await page.goto('/settings/opening-hours')
+  await settle(page)
+
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Add closed day' }).click()
+    await expect(page.getByRole('dialog', { name: 'Add closed day' })).toBeVisible()
+  }).toPass()
+  const dialog = page.getByRole('dialog', { name: 'Add closed day' })
+  await fillSettled(dialog.getByLabel('Name'), CLOSURE)
+  await fillSettled(dialog.locator('#closure-from'), DAY)
+  await dialog.locator('#closure-from').press('Tab')
+  await dialog.getByRole('button', { name: 'Save' }).click()
+  await expect(dialog).toBeHidden()
+
+  await expect
+    .poll(() => closuresNamed(CLOSURE))
+    .toEqual([{ startDate: DAY, endDate: DAY, repeatsYearly: false }])
+  await expect(page.getByTestId('closure-row').filter({ hasText: CLOSURE })).toBeVisible()
+
+  await page.goto(BOARD)
+  await settle(page)
+  await expect(
+    page
+      .getByTestId('day-closed')
+      .filter({ hasText: `Closed: ${CLOSURE}` })
+      .first()
+  ).toBeVisible()
+})

+ 34 - 0
e2e/support/db.ts

@@ -2091,3 +2091,37 @@ export async function quoteConversion(
     return row
   })
 }
+
+/** A technician's time off as stored, soonest first. */
+export async function absencesOf(
+  technicianId: string
+): Promise<{ startDate: string; endDate: string; reason: string; startTime: string | null }[]> {
+  return withDb(async (db) => {
+    const result = await db.query(
+      `select to_char("startDate", 'YYYY-MM-DD') as "startDate",
+              to_char("endDate", 'YYYY-MM-DD') as "endDate", reason, "startTime"
+         from technician_absences where "technicianId" = $1 order by "startDate"`,
+      [technicianId]
+    )
+    return result.rows
+  })
+}
+
+/** The workshop's own closed days with this name, as stored. */
+export async function closuresNamed(
+  name: string
+): Promise<{ startDate: string; endDate: string; repeatsYearly: boolean }[]> {
+  return withDb(async (db) => {
+    const result = await db.query(
+      `select to_char("startDate", 'YYYY-MM-DD') as "startDate",
+              to_char("endDate", 'YYYY-MM-DD') as "endDate", "repeatsYearly"
+         from workshop_closures where name = $1`,
+      [name]
+    )
+    return result.rows
+  })
+}
+
+export async function deleteClosuresNamed(name: string): Promise<void> {
+  await withDb((db) => db.query(`delete from workshop_closures where name = $1`, [name]))
+}

+ 18 - 5
messages/de/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Erinnerung öffnen",
       "quote": "Angebot öffnen",
       "message": "Nachrichten öffnen",
-      "external": "Öffnen"
+      "external": "Öffnen",
+      "closure": "Öffnungszeiten öffnen",
+      "absence": "Auftragsboard öffnen"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Erinnerungen",
     "quotes": "Angebote",
     "messages": "Nachrichten",
-    "external": "Verbundene Kalender"
+    "external": "Verbundene Kalender",
+    "closures": "Schließtage",
+    "absences": "Abwesenheiten"
   },
   "selectVehicle": "Fahrzeug für Arbeitsauftrag auswählen",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Bevorstehend",
       "failed": "Fehlgeschlagen",
       "queued": "In Warteschlange",
-      "busy": "Belegt"
+      "busy": "Belegt",
+      "closed": "Geschlossen",
+      "away": "Abwesend"
     },
     "type": {
       "service": "Arbeitsauftrag",
       "reminder": "Erinnerung",
       "quote": "Angebot",
       "message": "Nachricht",
-      "external": "Verbundener Kalender"
+      "external": "Verbundener Kalender",
+      "closure": "Geschlossen",
+      "absence": "Abwesenheit"
     },
     "noEvents": "Keine Ereignisse",
     "selectDay": "Wählen Sie einen Tag mit Ereignissen oder erstellen Sie einen neuen Arbeitsauftrag.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Vorheriger Monat",
-  "nextMonth": "Nächster Monat"
+  "nextMonth": "Nächster Monat",
+  "range": {
+    "from": "Ab {time}",
+    "until": "Bis {time}"
+  },
+  "timeOffMissing": "Diese Abwesenheit wurde inzwischen geändert oder entfernt."
 }

+ 7 - 0
messages/de/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "In Arbeit",
       "completed": "Abgeschlossen"
     }
+  },
+  "openingHours": {
+    "title": "Öffnungszeiten",
+    "closed": "Geschlossen",
+    "today": "Heute",
+    "closedSoon": "Demnächst geschlossen",
+    "openOnly": "Geöffnet {hours}"
   }
 }

+ 17 - 3
messages/de/settings.json

@@ -79,6 +79,10 @@
         "title": "Werkstatt",
         "description": "Techniker- & Arbeitsstandards"
       },
+      "openingHours": {
+        "title": "Öffnungszeiten und Feiertage",
+        "description": "Zeiten, Feiertage, Schließtage, Abwesenheiten"
+      },
       "laborPresets": {
         "title": "Arbeitspakete",
         "description": "Vordefinierte Arbeitspakete"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Aufträge konnten nicht zugewiesen werden",
     "noTechnician": "Keine",
     "weekStartDay": "Wochenstart",
-    "workDayStart": "Arbeitsbeginn",
-    "workDayEnd": "Arbeitsende",
     "titleTemplateTitle": "Titel des Arbeitsauftrags",
     "titleTemplateDescription": "Wie ein neuer Arbeitsauftrag heißt, bevor jemand einen Titel eingibt. Setzen Sie ihn aus den Tags unten zusammen; jeder Auftrag öffnet mit ausgefüllten Tags, und der Titel lässt sich am Auftrag weiterhin ändern.",
     "titleTemplate": "Titelvorlage",
@@ -1249,7 +1251,9 @@
       "4": "Donnerstag",
       "5": "Freitag",
       "6": "Samstag"
-    }
+    },
+    "openingHoursMoved": "Öffnungszeiten werden für jeden Wochentag festgelegt, zusammen mit Feiertagen, Schließtagen und Abwesenheiten.",
+    "openingHoursLink": "Öffnungszeiten und Feiertage öffnen"
   },
   "maintenance": {
     "title": "Vorhergesagte Wartung",
@@ -2938,5 +2942,15 @@
       "cross": "Kreuz",
       "hex": "Sechseck"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Öffnungszeiten",
+      "switch": "Schließtage und Abwesenheiten",
+      "publicHolidays": "Feiertage",
+      "calendars": "Feiertagskalender",
+      "closedDays": "Schließtage",
+      "timeOff": "Urlaub und Abwesenheit"
+    }
   }
 }

+ 174 - 0
messages/de/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Wann die Werkstatt geöffnet ist, an welchen Tagen sie geschlossen ist und wer abwesend ist. Auftragsboard, Planung, Kalender, Online-Buchung und Kundenportal richten sich danach."
+  },
+  "switch": {
+    "title": "Schließtage und Abwesenheiten",
+    "label": "Schließtage, Feiertage und Abwesenheiten verwenden",
+    "hint": "Ist dies ausgeschaltet, wirkt sich auf dieser Seite nur noch die Öffnungszeit auf Auftragsboard, Planung, Kalender und Online-Buchung aus. Alle Eingaben bleiben erhalten.",
+    "saved": "Gespeichert",
+    "failed": "Speichern fehlgeschlagen"
+  },
+  "hours": {
+    "title": "Öffnungszeiten",
+    "description": "Wann die Werkstatt an jedem Wochentag geöffnet ist. Online-Buchung und der nächste freie Termin bieten nur diese Zeiten an, und auf dem Auftragsboard ist die übrige Zeit schattiert.",
+    "open": "Geöffnet",
+    "closed": "Geschlossen",
+    "opens": "Öffnet",
+    "closes": "Schließt",
+    "copyMonday": "Montag auf die anderen Wochentage übertragen",
+    "save": "Öffnungszeiten speichern",
+    "saved": "Öffnungszeiten gespeichert",
+    "invalid": "{day}: Die Schließzeit muss nach der Öffnungszeit liegen",
+    "allClosed": "Alle Tage sind geschlossen, daher kann online nichts gebucht werden."
+  },
+  "holidays": {
+    "title": "Feiertage",
+    "description": "Wählen Sie Ihr Land und, wo die Feiertage abweichen, Ihr Bundesland oder Ihre Region, um an den gesetzlichen Feiertagen zu schließen. Die Daten stammen von Nager.Date und werden täglich aktualisiert.",
+    "country": "Land",
+    "noCountry": "Keine Feiertage",
+    "searchCountry": "Länder suchen",
+    "regions": "Bundesländer und Regionen",
+    "regionsHint": "Ein Feiertag, der nur in einigen Regionen gilt, zählt, wenn eine Ihrer Regionen angehakt ist. Landesweite Feiertage zählen immer.",
+    "countriesFailed": "Die Länderliste konnte nicht geladen werden ({error}). Versuchen Sie es später erneut.",
+    "list": "Feiertage {first} und {second}",
+    "closed": "Geschlossen",
+    "open": "Geöffnet",
+    "regional": "Nur in {regions}",
+    "notPublic": "Kein gesetzlicher Feiertag, daher geöffnet, sofern Sie nicht schließen",
+    "changed": "Abweichend vom Standard",
+    "reset": "Standard verwenden",
+    "refresh": "Jetzt aktualisieren",
+    "updated": "Aktualisiert {when}",
+    "neverUpdated": "Noch nicht aktualisiert",
+    "fetchError": "Die Feiertage konnten nicht aktualisiert werden: {error}. Die bereits aufgeführten Tage gelten weiterhin.",
+    "saved": "Feiertage aktualisiert",
+    "none": "Nager.Date kennt keine Feiertage für dieses Land.",
+    "pickCountry": "Wählen Sie ein Land, um seine Feiertage aufzulisten.",
+    "source": "Feiertagsdaten von Nager.Date"
+  },
+  "feeds": {
+    "title": "Feiertagskalender",
+    "description": "Fügen Sie einen beliebigen Feiertagskalender als .ics-Link oder -Datei hinzu: einen Feed einer Behörde, die Feiertagskalender von Google oder Ihren eigenen. Ein Link wird täglich neu gelesen.",
+    "addLink": "Link hinzufügen",
+    "linkPlaceholder": "https://- oder webcal://-Link zu einem .ics-Kalender",
+    "uploadFile": ".ics-Datei hochladen",
+    "added": "Kalender hinzugefügt",
+    "addFailed": "Der Kalender konnte nicht hinzugefügt werden",
+    "tooLarge": "Die Datei ist zu groß. Feiertagskalender sind deutlich kleiner als 900 KB.",
+    "remove": "Entfernen",
+    "removeTitle": "{name} entfernen?",
+    "removeDescription": "Seine Tage schließen die Werkstatt nicht mehr. Sie können ihn später wieder hinzufügen.",
+    "refresh": "Neu lesen",
+    "refreshed": "Kalender neu gelesen",
+    "fromLink": "Wird täglich von {host} gelesen",
+    "fromFile": "Aus der Datei {file}",
+    "updated": "Gelesen {when}",
+    "error": "Letztes Lesen fehlgeschlagen: {error}",
+    "days": "{count, plural, one {# Tag} other {# Tage}} in {first} und {second}",
+    "closeAll": "An allen schließen",
+    "openAll": "An allen öffnen",
+    "choose": "Jeder Tag schließt die Werkstatt, sofern Sie ihn nicht ausschalten. Tage, die ein Google-Kalender als Gedenk- oder Aktionstag markiert, wie der Valentinstag, sind zu Beginn ausgeschaltet.",
+    "show": "Tage anzeigen",
+    "hide": "Tage ausblenden",
+    "empty": "Keine Kalender hinzugefügt."
+  },
+  "closures": {
+    "title": "Schließtage",
+    "description": "Tage, an denen die Werkstatt aus eigenen Gründen geschlossen ist: Betriebsferien, ein Teamtag, früher Feierabend vor einem Feiertag.",
+    "add": "Schließtag hinzufügen",
+    "edit": "Schließtag bearbeiten",
+    "name": "Name",
+    "namePlaceholder": "Betriebsferien Sommer",
+    "nameRequired": "Geben Sie einen Namen ein",
+    "yearly": "Jedes Jahr",
+    "yearlyHint": "Jedes Jahr an denselben Tagen geschlossen.",
+    "empty": "Keine Schließtage hinzugefügt.",
+    "past": "Vergangene Schließtage anzeigen",
+    "hidePast": "Vergangene Schließtage ausblenden",
+    "saved": "Schließtag gespeichert",
+    "deleted": "Schließtag entfernt",
+    "deleteTitle": "{name} entfernen?",
+    "deleteDescription": "Die Werkstatt ist an diesen Tagen wieder wie gewohnt geöffnet."
+  },
+  "stretch": {
+    "from": "Erster Tag",
+    "to": "Letzter Tag",
+    "allDay": "Ganztägig",
+    "startTime": "Ab",
+    "endTime": "Bis",
+    "startTimeHint": "Am ersten Tag. Leer lassen für den ganzen Tag.",
+    "endTimeHint": "Am letzten Tag. Leer lassen für den Rest des Tages.",
+    "endBeforeStart": "Der letzte Tag liegt vor dem ersten",
+    "timesCross": "Die Endzeit liegt vor der Startzeit",
+    "tooLong": "Ein Eintrag kann höchstens ein Jahr umfassen",
+    "pickDate": "Datum wählen"
+  },
+  "timeOff": {
+    "title": "Abwesenheiten",
+    "description": "Tragen Sie einen Techniker als im Urlaub oder abwesend ein: Urlaub, Krankheitstage, Kurse. Während der Abwesenheit wird er für Buchungen nicht angeboten, und Auftragsboard und Planung zeigen sie an.",
+    "add": "Urlaub oder Abwesenheit eintragen",
+    "edit": "Urlaub oder Abwesenheit bearbeiten",
+    "technician": "Techniker",
+    "pickTechnician": "Techniker wählen",
+    "reason": "Grund",
+    "note": "Notiz",
+    "notePlaceholder": "Optional, etwa der Name des Kurses",
+    "empty": "In den kommenden Wochen ist niemand im Urlaub oder abwesend.",
+    "saved": "Abwesenheit gespeichert",
+    "deleted": "Abwesenheit entfernt",
+    "deleteTitle": "Diese Abwesenheit entfernen?",
+    "deleteDescription": "{name} gilt an diesen Tagen wieder als verfügbar.",
+    "noTechnicians": "Fügen Sie zuerst Techniker auf dem Auftragsboard hinzu.",
+    "manage": "Urlaub und Abwesenheit",
+    "upcoming": "Kommende und kürzliche Abwesenheiten",
+    "disabled": "Abwesenheiten sind für diese Werkstatt ausgeschaltet. Schalten Sie sie unter Einstellungen, Öffnungszeiten und Feiertage ein.",
+    "button": "Abwesenheit",
+    "setForTechnician": "Urlaub oder Abwesenheit für {name} eintragen"
+  },
+  "reasons": {
+    "holiday": "Urlaub",
+    "sick": "Krank",
+    "training": "Schulung",
+    "other": "Abwesend"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, ab {time}",
+    "until": "{dates}, bis {time}",
+    "between": "{dates}, {start} bis {end}",
+    "fromUntil": "{dates}, ab {start} am ersten Tag bis {end} am letzten"
+  },
+  "board": {
+    "closedDay": "Geschlossen",
+    "closedFor": "Geschlossen: {name}",
+    "shortDay": "Verkürzter Tag: {name}",
+    "closedWeekday": "Die Werkstatt ist an diesem Tag geschlossen",
+    "away": "{reason}",
+    "awayAllDay": "{name} ist abwesend: {reason}",
+    "awayPart": "{name} ist {when} abwesend: {reason}",
+    "awayTooltip": "Abwesend: {reason}",
+    "dropClosed": "Die Werkstatt ist dann geschlossen ({name}). Der Auftrag wird trotzdem eingeplant.",
+    "dropClosedWeekday": "Die Werkstatt ist dann normalerweise geschlossen. Der Auftrag wird trotzdem eingeplant.",
+    "dropAway": "{name} ist dann abwesend ({reason}). Der Auftrag wird trotzdem eingeplant."
+  },
+  "schedule": {
+    "closed": "Die Werkstatt ist dann geschlossen: {names}",
+    "closedWeekday": "Die Werkstatt ist an diesem Tag normalerweise geschlossen.",
+    "away": "Abwesend: {reason}",
+    "awayPart": "Abwesend {when}",
+    "awayConflict": "Abwesend ({reason})",
+    "awayHeading": "Der Techniker ist dann abwesend",
+    "bookedAnyway": "Sie können diese Zeit trotzdem behalten, wenn die Arbeit so vereinbart ist."
+  },
+  "calendar": {
+    "closure": "Geschlossen",
+    "absence": "Abwesenheit",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "Die Werkstatt ist an diesem Tag geschlossen ({name}). Bitte wählen Sie einen anderen Tag.",
+    "closedWeekday": "Die Werkstatt ist an diesem Tag geschlossen. Bitte wählen Sie einen anderen Tag.",
+    "upcomingClosed": "Geschlossen am: {days}"
+  }
+}

+ 18 - 5
messages/en/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Open reminder",
       "quote": "Open quote",
       "message": "Open messages",
-      "external": "Open"
+      "external": "Open",
+      "closure": "Open opening hours",
+      "absence": "Open the work board"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Reminders",
     "quotes": "Quotes",
     "messages": "Messages",
-    "external": "Connected calendars"
+    "external": "Connected calendars",
+    "closures": "Closed days",
+    "absences": "Leave"
   },
   "selectVehicle": "Select Vehicle for Work Order",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Upcoming",
       "failed": "Failed",
       "queued": "Queued",
-      "busy": "Busy"
+      "busy": "Busy",
+      "closed": "Closed",
+      "away": "Away"
     },
     "type": {
       "service": "Work order",
       "reminder": "Reminder",
       "quote": "Quote",
       "message": "Message",
-      "external": "Connected calendar"
+      "external": "Connected calendar",
+      "closure": "Closed",
+      "absence": "Leave"
     },
     "noEvents": "No events",
     "selectDay": "Select a day with events or create a new work order.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Previous month",
-  "nextMonth": "Next month"
+  "nextMonth": "Next month",
+  "range": {
+    "from": "From {time}",
+    "until": "Until {time}"
+  },
+  "timeOffMissing": "That leave was changed or removed in the meantime."
 }

+ 7 - 0
messages/en/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "In progress",
       "completed": "Completed"
     }
+  },
+  "openingHours": {
+    "title": "Opening hours",
+    "closed": "Closed",
+    "today": "Today",
+    "closedSoon": "Closed soon",
+    "openOnly": "Open {hours}"
   }
 }

+ 17 - 3
messages/en/settings.json

@@ -79,6 +79,10 @@
         "title": "Workshop",
         "description": "Technician & labor defaults"
       },
+      "openingHours": {
+        "title": "Opening hours and holidays",
+        "description": "Hours, public holidays, closed days, leave"
+      },
       "laborPresets": {
         "title": "Labor Presets",
         "description": "Predefined labor packages"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Failed to assign work orders",
     "noTechnician": "None",
     "weekStartDay": "Week Start Day",
-    "workDayStart": "Work Day Start",
-    "workDayEnd": "Work Day End",
     "titleTemplateTitle": "Work Order Title",
     "titleTemplateDescription": "What a new work order is called before anybody types a title. Build it from the tags below; every job opens with them filled in, and the title can still be changed on the job.",
     "titleTemplate": "Title template",
@@ -1249,7 +1251,9 @@
       "4": "Thursday",
       "5": "Friday",
       "6": "Saturday"
-    }
+    },
+    "openingHoursMoved": "Opening hours are set for each day of the week, beside public holidays, closed days and leave.",
+    "openingHoursLink": "Open opening hours and holidays"
   },
   "maintenance": {
     "title": "Predicted Maintenance",
@@ -2938,5 +2942,15 @@
       "cross": "Cross",
       "hex": "Hexagon"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Opening hours",
+      "switch": "Closed days and leave",
+      "publicHolidays": "Public holidays",
+      "calendars": "Holiday calendars",
+      "closedDays": "Closed days",
+      "timeOff": "Leave and out of office"
+    }
   }
 }

+ 174 - 0
messages/en/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "When the workshop is open, the days it is closed, and who is away. The work board, scheduling, the calendar, online booking and the customer portal all follow it."
+  },
+  "switch": {
+    "title": "Closed days and leave",
+    "label": "Use closed days, public holidays and leave",
+    "hint": "When this is off, nothing on this page except the opening hours changes the work board, scheduling, the calendar or online booking. Everything you entered is kept.",
+    "saved": "Saved",
+    "failed": "Could not save"
+  },
+  "hours": {
+    "title": "Opening hours",
+    "description": "When the workshop is open on each day of the week. Online booking and the next free slot only offer these hours, and the work board shades the rest.",
+    "open": "Open",
+    "closed": "Closed",
+    "opens": "Opens",
+    "closes": "Closes",
+    "copyMonday": "Copy Monday to the other weekdays",
+    "save": "Save opening hours",
+    "saved": "Opening hours saved",
+    "invalid": "{day}: the closing time must be after the opening time",
+    "allClosed": "Every day is closed, so nothing can be booked online."
+  },
+  "holidays": {
+    "title": "Public holidays",
+    "description": "Pick your country, and your state or region where holidays differ, to close on its public holidays. The dates come from Nager.Date and are updated every day.",
+    "country": "Country",
+    "noCountry": "No public holidays",
+    "searchCountry": "Search countries",
+    "regions": "States and regions",
+    "regionsHint": "A holiday kept only in some regions counts when one of yours is ticked. Nationwide holidays always count.",
+    "countriesFailed": "Could not load the list of countries ({error}). Try again later.",
+    "list": "Holidays in {first} and {second}",
+    "closed": "Closed",
+    "open": "Open",
+    "regional": "Only in {regions}",
+    "notPublic": "Not a public holiday, so open unless you close it",
+    "changed": "Changed from the default",
+    "reset": "Use default",
+    "refresh": "Update now",
+    "updated": "Updated {when}",
+    "neverUpdated": "Not updated yet",
+    "fetchError": "Could not update the holidays: {error}. The days already listed still count.",
+    "saved": "Public holidays updated",
+    "none": "Nager.Date has no public holidays for this country.",
+    "pickCountry": "Pick a country to list its public holidays.",
+    "source": "Holiday dates by Nager.Date"
+  },
+  "feeds": {
+    "title": "Holiday calendars",
+    "description": "Add any holiday calendar as an .ics link or file: a government feed, Google's holiday calendars, your own. A link is read again every day.",
+    "addLink": "Add link",
+    "linkPlaceholder": "https:// or webcal:// link to an .ics calendar",
+    "uploadFile": "Upload .ics file",
+    "added": "Calendar added",
+    "addFailed": "Could not add the calendar",
+    "tooLarge": "The file is too large. Holiday calendars are much smaller than 900 KB.",
+    "remove": "Remove",
+    "removeTitle": "Remove {name}?",
+    "removeDescription": "Its days stop closing the workshop. You can add it again later.",
+    "refresh": "Read again",
+    "refreshed": "Calendar read again",
+    "fromLink": "Read every day from {host}",
+    "fromFile": "From the file {file}",
+    "updated": "Read {when}",
+    "error": "Last read failed: {error}",
+    "days": "{count, plural, one {# day} other {# days}} in {first} and {second}",
+    "closeAll": "Close on all",
+    "openAll": "Open on all",
+    "choose": "Each day closes the workshop unless you switch it off. Days a Google calendar marks as observances, such as Valentine's Day, start switched off.",
+    "show": "Show days",
+    "hide": "Hide days",
+    "empty": "No calendars added."
+  },
+  "closures": {
+    "title": "Closed days",
+    "description": "Days the workshop is closed for its own reasons: a shutdown week, a staff day, closing early before a holiday.",
+    "add": "Add closed day",
+    "edit": "Edit closed day",
+    "name": "Name",
+    "namePlaceholder": "Summer shutdown",
+    "nameRequired": "Give it a name",
+    "yearly": "Every year",
+    "yearlyHint": "Closed on the same dates every year.",
+    "empty": "No closed days added.",
+    "past": "Show past closures",
+    "hidePast": "Hide past closures",
+    "saved": "Closed day saved",
+    "deleted": "Closed day removed",
+    "deleteTitle": "Remove {name}?",
+    "deleteDescription": "The workshop opens as usual on these days again."
+  },
+  "stretch": {
+    "from": "First day",
+    "to": "Last day",
+    "allDay": "All day",
+    "startTime": "From",
+    "endTime": "Until",
+    "startTimeHint": "On the first day. Leave empty for the whole day.",
+    "endTimeHint": "On the last day. Leave empty for the rest of the day.",
+    "endBeforeStart": "The last day is before the first",
+    "timesCross": "The end time is before the start time",
+    "tooLong": "One entry can cover at most a year",
+    "pickDate": "Pick a date"
+  },
+  "timeOff": {
+    "title": "Leave and out of office",
+    "description": "Set a technician on leave or out of office: holidays, sick days, courses. While away they are not offered for bookings, and the work board and scheduling show it.",
+    "add": "Set on leave or out of office",
+    "edit": "Edit leave or out of office",
+    "technician": "Technician",
+    "pickTechnician": "Pick a technician",
+    "reason": "Reason",
+    "note": "Note",
+    "notePlaceholder": "Optional, such as the name of the course",
+    "empty": "Nobody is on leave or out of office in the coming weeks.",
+    "saved": "Leave saved",
+    "deleted": "Leave removed",
+    "deleteTitle": "Remove this leave?",
+    "deleteDescription": "{name} counts as available on these days again.",
+    "noTechnicians": "Add technicians on the work board first.",
+    "manage": "Leave and out of office",
+    "upcoming": "Upcoming and recent leave",
+    "disabled": "Leave is switched off for this workshop. Turn it on under Settings, Opening hours and holidays.",
+    "button": "Leave",
+    "setForTechnician": "Set {name} on leave or out of office"
+  },
+  "reasons": {
+    "holiday": "On leave",
+    "sick": "Sick leave",
+    "training": "Training",
+    "other": "Out of office"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, from {time}",
+    "until": "{dates}, until {time}",
+    "between": "{dates}, {start} to {end}",
+    "fromUntil": "{dates}, from {start} on the first day until {end} on the last"
+  },
+  "board": {
+    "closedDay": "Closed",
+    "closedFor": "Closed: {name}",
+    "shortDay": "Short day: {name}",
+    "closedWeekday": "The workshop is closed on this day",
+    "away": "{reason}",
+    "awayAllDay": "{name} is away: {reason}",
+    "awayPart": "{name} is away {when}: {reason}",
+    "awayTooltip": "Away: {reason}",
+    "dropClosed": "The workshop is closed then ({name}). The job is scheduled anyway.",
+    "dropClosedWeekday": "The workshop is normally closed then. The job is scheduled anyway.",
+    "dropAway": "{name} is away then ({reason}). The job is scheduled anyway."
+  },
+  "schedule": {
+    "closed": "The workshop is closed then: {names}",
+    "closedWeekday": "The workshop is normally closed on this day.",
+    "away": "Away: {reason}",
+    "awayPart": "Away {when}",
+    "awayConflict": "Away ({reason})",
+    "awayHeading": "The technician is away then",
+    "bookedAnyway": "You can still keep this time if the work is agreed."
+  },
+  "calendar": {
+    "closure": "Closed",
+    "absence": "Leave",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "The workshop is closed on that day ({name}). Please pick another day.",
+    "closedWeekday": "The workshop is closed on that day. Please pick another day.",
+    "upcomingClosed": "Closed on: {days}"
+  }
+}

+ 18 - 5
messages/es/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Abrir recordatorio",
       "quote": "Abrir presupuesto",
       "message": "Abrir mensajes",
-      "external": "Abrir"
+      "external": "Abrir",
+      "closure": "Abrir horario de apertura",
+      "absence": "Abrir el tablero de trabajo"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Recordatorios",
     "quotes": "Presupuestos",
     "messages": "Mensajes",
-    "external": "Calendarios conectados"
+    "external": "Calendarios conectados",
+    "closures": "Días de cierre",
+    "absences": "Ausencias"
   },
   "selectVehicle": "Seleccionar vehículo para orden de trabajo",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Próximo",
       "failed": "Fallido",
       "queued": "En cola",
-      "busy": "Ocupado"
+      "busy": "Ocupado",
+      "closed": "Cerrado",
+      "away": "Ausente"
     },
     "type": {
       "service": "Orden de trabajo",
       "reminder": "Recordatorio",
       "quote": "Presupuesto",
       "message": "Mensaje",
-      "external": "Calendario conectado"
+      "external": "Calendario conectado",
+      "closure": "Cerrado",
+      "absence": "Ausencia"
     },
     "noEvents": "Sin eventos",
     "selectDay": "Seleccione un día con eventos o cree una nueva orden de trabajo.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Mes anterior",
-  "nextMonth": "Mes siguiente"
+  "nextMonth": "Mes siguiente",
+  "range": {
+    "from": "Desde las {time}",
+    "until": "Hasta las {time}"
+  },
+  "timeOffMissing": "Esa ausencia se ha cambiado o eliminado mientras tanto."
 }

+ 7 - 0
messages/es/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "En curso",
       "completed": "Completada"
     }
+  },
+  "openingHours": {
+    "title": "Horario",
+    "closed": "Cerrado",
+    "today": "Hoy",
+    "closedSoon": "Próximos cierres",
+    "openOnly": "Abierto {hours}"
   }
 }

+ 17 - 3
messages/es/settings.json

@@ -79,6 +79,10 @@
         "title": "Taller",
         "description": "Valores predeterminados de técnico y mano de obra"
       },
+      "openingHours": {
+        "title": "Horario y festivos",
+        "description": "Horario, festivos, días de cierre, ausencias"
+      },
       "laborPresets": {
         "title": "Paquetes de mano de obra",
         "description": "Paquetes de trabajo predefinidos"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Error al asignar órdenes de trabajo",
     "noTechnician": "Ninguno",
     "weekStartDay": "Inicio de semana",
-    "workDayStart": "Inicio de jornada",
-    "workDayEnd": "Fin de jornada",
     "titleTemplateTitle": "Título de la orden de trabajo",
     "titleTemplateDescription": "Cómo se llama una orden de trabajo nueva antes de que alguien escriba un título. Constrúyalo con las etiquetas de abajo; cada trabajo se abre con ellas rellenadas y el título se puede cambiar en el trabajo.",
     "titleTemplate": "Plantilla del título",
@@ -1249,7 +1251,9 @@
       "4": "Jueves",
       "5": "Viernes",
       "6": "Sábado"
-    }
+    },
+    "openingHoursMoved": "El horario de apertura se define para cada día de la semana, junto a los festivos, los días de cierre y las ausencias.",
+    "openingHoursLink": "Abrir horario y festivos"
   },
   "maintenance": {
     "title": "Mantenimiento predictivo",
@@ -2938,5 +2942,15 @@
       "cross": "Cruz",
       "hex": "Hexágono"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Horario de apertura",
+      "switch": "Días de cierre y ausencias",
+      "publicHolidays": "Festivos",
+      "calendars": "Calendarios de festivos",
+      "closedDays": "Días de cierre",
+      "timeOff": "Vacaciones y ausencias"
+    }
   }
 }

+ 174 - 0
messages/es/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Cuándo está abierto el taller, qué días cierra y quién está ausente. El tablero de trabajo, la programación, el calendario, la reserva online y el portal del cliente se rigen por ello."
+  },
+  "switch": {
+    "title": "Días de cierre y ausencias",
+    "label": "Usar días de cierre, festivos y ausencias",
+    "hint": "Si está desactivado, nada de esta página salvo el horario de apertura afecta al tablero de trabajo, la programación, el calendario ni la reserva online. Todo lo que has introducido se conserva.",
+    "saved": "Guardado",
+    "failed": "No se pudo guardar"
+  },
+  "hours": {
+    "title": "Horario de apertura",
+    "description": "Cuándo está abierto el taller cada día de la semana. La reserva online y el siguiente hueco libre solo ofrecen estas horas, y el tablero de trabajo sombrea el resto.",
+    "open": "Abierto",
+    "closed": "Cerrado",
+    "opens": "Abre",
+    "closes": "Cierra",
+    "copyMonday": "Copiar el lunes a los demás días laborables",
+    "save": "Guardar horario",
+    "saved": "Horario guardado",
+    "invalid": "{day}: la hora de cierre debe ser posterior a la de apertura",
+    "allClosed": "Todos los días están cerrados, así que no se puede reservar nada online."
+  },
+  "holidays": {
+    "title": "Festivos",
+    "description": "Elige tu país, y tu estado o región si los festivos varían, para cerrar en sus festivos oficiales. Las fechas vienen de Nager.Date y se actualizan cada día.",
+    "country": "País",
+    "noCountry": "Sin festivos",
+    "searchCountry": "Buscar países",
+    "regions": "Estados y regiones",
+    "regionsHint": "Un festivo que solo se celebra en algunas regiones cuenta si una de las tuyas está marcada. Los festivos nacionales cuentan siempre.",
+    "countriesFailed": "No se pudo cargar la lista de países ({error}). Inténtalo más tarde.",
+    "list": "Festivos en {first} y {second}",
+    "closed": "Cerrado",
+    "open": "Abierto",
+    "regional": "Solo en {regions}",
+    "notPublic": "No es festivo oficial, así que abierto salvo que lo cierres",
+    "changed": "Cambiado respecto al valor predeterminado",
+    "reset": "Usar valor predeterminado",
+    "refresh": "Actualizar ahora",
+    "updated": "Actualizado {when}",
+    "neverUpdated": "Aún sin actualizar",
+    "fetchError": "No se pudieron actualizar los festivos: {error}. Los días ya listados siguen contando.",
+    "saved": "Festivos actualizados",
+    "none": "Nager.Date no tiene festivos para este país.",
+    "pickCountry": "Elige un país para ver sus festivos.",
+    "source": "Fechas de festivos de Nager.Date"
+  },
+  "feeds": {
+    "title": "Calendarios de festivos",
+    "description": "Añade cualquier calendario de festivos como enlace o archivo .ics: un feed oficial, los calendarios de festivos de Google o el tuyo propio. Un enlace se vuelve a leer cada día.",
+    "addLink": "Añadir enlace",
+    "linkPlaceholder": "Enlace https:// o webcal:// a un calendario .ics",
+    "uploadFile": "Subir archivo .ics",
+    "added": "Calendario añadido",
+    "addFailed": "No se pudo añadir el calendario",
+    "tooLarge": "El archivo es demasiado grande. Los calendarios de festivos ocupan mucho menos de 900 KB.",
+    "remove": "Quitar",
+    "removeTitle": "¿Quitar {name}?",
+    "removeDescription": "Sus días dejan de cerrar el taller. Puedes volver a añadirlo más tarde.",
+    "refresh": "Volver a leer",
+    "refreshed": "Calendario leído de nuevo",
+    "fromLink": "Se lee cada día desde {host}",
+    "fromFile": "Del archivo {file}",
+    "updated": "Leído {when}",
+    "error": "La última lectura falló: {error}",
+    "days": "{count, plural, one {# día} other {# días}} en {first} y {second}",
+    "closeAll": "Cerrar todos",
+    "openAll": "Abrir todos",
+    "choose": "Cada día cierra el taller salvo que lo desactives. Los días que un calendario de Google marca como conmemoraciones, como San Valentín, empiezan desactivados.",
+    "show": "Mostrar días",
+    "hide": "Ocultar días",
+    "empty": "No hay calendarios añadidos."
+  },
+  "closures": {
+    "title": "Días de cierre",
+    "description": "Días en que el taller cierra por motivos propios: una semana de cierre, un día para el personal, cerrar antes la víspera de un festivo.",
+    "add": "Añadir día de cierre",
+    "edit": "Editar día de cierre",
+    "name": "Nombre",
+    "namePlaceholder": "Cierre de verano",
+    "nameRequired": "Ponle un nombre",
+    "yearly": "Cada año",
+    "yearlyHint": "Cerrado en las mismas fechas cada año.",
+    "empty": "No hay días de cierre añadidos.",
+    "past": "Mostrar cierres pasados",
+    "hidePast": "Ocultar cierres pasados",
+    "saved": "Día de cierre guardado",
+    "deleted": "Día de cierre quitado",
+    "deleteTitle": "¿Quitar {name}?",
+    "deleteDescription": "El taller vuelve a abrir con normalidad estos días."
+  },
+  "stretch": {
+    "from": "Primer día",
+    "to": "Último día",
+    "allDay": "Todo el día",
+    "startTime": "Desde",
+    "endTime": "Hasta",
+    "startTimeHint": "El primer día. Déjalo vacío para todo el día.",
+    "endTimeHint": "El último día. Déjalo vacío para el resto del día.",
+    "endBeforeStart": "El último día es anterior al primero",
+    "timesCross": "La hora de fin es anterior a la de inicio",
+    "tooLong": "Una entrada puede abarcar como máximo un año",
+    "pickDate": "Elige una fecha"
+  },
+  "timeOff": {
+    "title": "Ausencias",
+    "description": "Marca a un técnico de vacaciones o ausente: vacaciones, bajas por enfermedad, cursos. Mientras está fuera no se ofrece para reservas, y el tablero de trabajo y la programación lo muestran.",
+    "add": "Marcar vacaciones o ausencia",
+    "edit": "Editar vacaciones o ausencia",
+    "technician": "Técnico",
+    "pickTechnician": "Elige un técnico",
+    "reason": "Motivo",
+    "note": "Nota",
+    "notePlaceholder": "Opcional, como el nombre del curso",
+    "empty": "Nadie está de vacaciones ni ausente en las próximas semanas.",
+    "saved": "Ausencia guardada",
+    "deleted": "Ausencia quitada",
+    "deleteTitle": "¿Quitar esta ausencia?",
+    "deleteDescription": "{name} vuelve a contar como disponible estos días.",
+    "noTechnicians": "Añade primero técnicos en el tablero de trabajo.",
+    "manage": "Vacaciones y ausencias",
+    "upcoming": "Ausencias próximas y recientes",
+    "disabled": "Las ausencias están desactivadas para este taller. Actívalas en Configuración, Horario y festivos.",
+    "button": "Ausencia",
+    "setForTechnician": "Marcar vacaciones o ausencia de {name}"
+  },
+  "reasons": {
+    "holiday": "Vacaciones",
+    "sick": "Baja por enfermedad",
+    "training": "Formación",
+    "other": "Ausente"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, desde {time}",
+    "until": "{dates}, hasta {time}",
+    "between": "{dates}, de {start} a {end}",
+    "fromUntil": "{dates}, desde {start} el primer día hasta {end} el último"
+  },
+  "board": {
+    "closedDay": "Cerrado",
+    "closedFor": "Cerrado: {name}",
+    "shortDay": "Jornada reducida: {name}",
+    "closedWeekday": "El taller cierra este día",
+    "away": "{reason}",
+    "awayAllDay": "{name} está ausente: {reason}",
+    "awayPart": "{name} está ausente {when}: {reason}",
+    "awayTooltip": "Ausente: {reason}",
+    "dropClosed": "El taller está cerrado en ese momento ({name}). El trabajo se programa igualmente.",
+    "dropClosedWeekday": "El taller suele estar cerrado en ese momento. El trabajo se programa igualmente.",
+    "dropAway": "{name} está ausente en ese momento ({reason}). El trabajo se programa igualmente."
+  },
+  "schedule": {
+    "closed": "El taller está cerrado en ese momento: {names}",
+    "closedWeekday": "El taller suele cerrar este día.",
+    "away": "Ausente: {reason}",
+    "awayPart": "Ausente {when}",
+    "awayConflict": "Ausente ({reason})",
+    "awayHeading": "El técnico no está disponible entonces",
+    "bookedAnyway": "Puedes mantener esta hora si el trabajo está acordado."
+  },
+  "calendar": {
+    "closure": "Cerrado",
+    "absence": "Ausencia",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "El taller está cerrado ese día ({name}). Elige otro día, por favor.",
+    "closedWeekday": "El taller está cerrado ese día. Elige otro día, por favor.",
+    "upcomingClosed": "Cerrado: {days}"
+  }
+}

+ 18 - 5
messages/fr/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Ouvrir le rappel",
       "quote": "Ouvrir le devis",
       "message": "Ouvrir les messages",
-      "external": "Ouvrir"
+      "external": "Ouvrir",
+      "closure": "Ouvrir les horaires d'ouverture",
+      "absence": "Ouvrir le tableau de travail"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Rappels",
     "quotes": "Devis",
     "messages": "Messages",
-    "external": "Calendriers connectés"
+    "external": "Calendriers connectés",
+    "closures": "Jours de fermeture",
+    "absences": "Absences"
   },
   "selectVehicle": "Sélectionner un véhicule pour l’ordre de travail",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "À venir",
       "failed": "Échec",
       "queued": "En file",
-      "busy": "Occupé"
+      "busy": "Occupé",
+      "closed": "Fermé",
+      "away": "Absent"
     },
     "type": {
       "service": "Ordre de travail",
       "reminder": "Rappel",
       "quote": "Devis",
       "message": "Message",
-      "external": "Calendrier connecté"
+      "external": "Calendrier connecté",
+      "closure": "Fermé",
+      "absence": "Absence"
     },
     "noEvents": "Aucun événement",
     "selectDay": "Sélectionnez un jour avec des événements ou créez un nouvel ordre de travail.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Mois précédent",
-  "nextMonth": "Mois suivant"
+  "nextMonth": "Mois suivant",
+  "range": {
+    "from": "À partir de {time}",
+    "until": "Jusqu'à {time}"
+  },
+  "timeOffMissing": "Cette absence a été modifiée ou supprimée entre-temps."
 }

+ 7 - 0
messages/fr/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "En cours",
       "completed": "Terminée"
     }
+  },
+  "openingHours": {
+    "title": "Horaires d'ouverture",
+    "closed": "Fermé",
+    "today": "Aujourd'hui",
+    "closedSoon": "Fermetures à venir",
+    "openOnly": "Ouvert {hours}"
   }
 }

+ 17 - 3
messages/fr/settings.json

@@ -79,6 +79,10 @@
         "title": "Atelier",
         "description": "Valeurs par défaut technicien et main-d'oeuvre"
       },
+      "openingHours": {
+        "title": "Horaires et jours fériés",
+        "description": "Horaires, jours fériés, jours de fermeture, congés"
+      },
       "laborPresets": {
         "title": "Forfaits main-d'oeuvre",
         "description": "Forfaits de travail predefinis"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Échec de l'assignation des ordres",
     "noTechnician": "Aucun",
     "weekStartDay": "Début de semaine",
-    "workDayStart": "Début de journee",
-    "workDayEnd": "Fin de journee",
     "titleTemplateTitle": "Titre de l'ordre de travail",
     "titleTemplateDescription": "Le nom d'un nouvel ordre de travail avant que quelqu'un saisisse un titre. Composez-le avec les balises ci-dessous ; chaque travail s'ouvre avec les balises remplies et le titre reste modifiable sur le travail.",
     "titleTemplate": "Modèle de titre",
@@ -1249,7 +1251,9 @@
       "4": "Jeudi",
       "5": "Vendredi",
       "6": "Samedi"
-    }
+    },
+    "openingHoursMoved": "Les horaires d'ouverture se définissent pour chaque jour de la semaine, à côté des jours fériés, des jours de fermeture et des congés.",
+    "openingHoursLink": "Ouvrir les horaires et jours fériés"
   },
   "maintenance": {
     "title": "Maintenance predictive",
@@ -2938,5 +2942,15 @@
       "cross": "Croix",
       "hex": "Hexagone"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Horaires d'ouverture",
+      "switch": "Jours de fermeture et absences",
+      "publicHolidays": "Jours fériés",
+      "calendars": "Calendriers de jours fériés",
+      "closedDays": "Jours de fermeture",
+      "timeOff": "Congés et absences"
+    }
   }
 }

+ 174 - 0
messages/fr/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Quand l'atelier est ouvert, les jours où il est fermé et qui est absent. Le tableau de travail, la planification, le calendrier, la réservation en ligne et le portail client s'y conforment."
+  },
+  "switch": {
+    "title": "Jours de fermeture et absences",
+    "label": "Utiliser les jours de fermeture, les jours fériés et les absences",
+    "hint": "Lorsque c'est désactivé, rien sur cette page, hormis les horaires d'ouverture, ne modifie le tableau de travail, la planification, le calendrier ou la réservation en ligne. Tout ce que vous avez saisi est conservé.",
+    "saved": "Enregistré",
+    "failed": "Impossible d'enregistrer"
+  },
+  "hours": {
+    "title": "Horaires d'ouverture",
+    "description": "Quand l'atelier est ouvert chaque jour de la semaine. La réservation en ligne et le prochain créneau libre ne proposent que ces horaires, et le tableau de travail grise le reste.",
+    "open": "Ouvert",
+    "closed": "Fermé",
+    "opens": "Ouverture",
+    "closes": "Fermeture",
+    "copyMonday": "Copier le lundi sur les autres jours de semaine",
+    "save": "Enregistrer les horaires",
+    "saved": "Horaires d'ouverture enregistrés",
+    "invalid": "{day} : l'heure de fermeture doit être après l'heure d'ouverture",
+    "allClosed": "Tous les jours sont fermés, donc rien ne peut être réservé en ligne."
+  },
+  "holidays": {
+    "title": "Jours fériés",
+    "description": "Choisissez votre pays, et votre État ou région lorsque les jours fériés diffèrent, pour fermer les jours fériés officiels. Les dates proviennent de Nager.Date et sont mises à jour chaque jour.",
+    "country": "Pays",
+    "noCountry": "Aucun jour férié",
+    "searchCountry": "Rechercher un pays",
+    "regions": "États et régions",
+    "regionsHint": "Un jour férié observé seulement dans certaines régions compte si l'une des vôtres est cochée. Les jours fériés nationaux comptent toujours.",
+    "countriesFailed": "Impossible de charger la liste des pays ({error}). Réessayez plus tard.",
+    "list": "Jours fériés en {first} et {second}",
+    "closed": "Fermé",
+    "open": "Ouvert",
+    "regional": "Uniquement en {regions}",
+    "notPublic": "Pas un jour férié officiel, donc ouvert sauf si vous fermez",
+    "changed": "Modifié par rapport à la valeur par défaut",
+    "reset": "Utiliser la valeur par défaut",
+    "refresh": "Mettre à jour",
+    "updated": "Mis à jour {when}",
+    "neverUpdated": "Pas encore mis à jour",
+    "fetchError": "Impossible de mettre à jour les jours fériés : {error}. Les jours déjà listés comptent toujours.",
+    "saved": "Jours fériés mis à jour",
+    "none": "Nager.Date n'a aucun jour férié pour ce pays.",
+    "pickCountry": "Choisissez un pays pour afficher ses jours fériés.",
+    "source": "Dates des jours fériés fournies par Nager.Date"
+  },
+  "feeds": {
+    "title": "Calendriers de jours fériés",
+    "description": "Ajoutez n'importe quel calendrier de jours fériés sous forme de lien ou de fichier .ics : un flux officiel, les calendriers de jours fériés de Google, le vôtre. Un lien est relu chaque jour.",
+    "addLink": "Ajouter un lien",
+    "linkPlaceholder": "Lien https:// ou webcal:// vers un calendrier .ics",
+    "uploadFile": "Importer un fichier .ics",
+    "added": "Calendrier ajouté",
+    "addFailed": "Impossible d'ajouter le calendrier",
+    "tooLarge": "Le fichier est trop volumineux. Les calendriers de jours fériés font bien moins de 900 Ko.",
+    "remove": "Retirer",
+    "removeTitle": "Retirer {name} ?",
+    "removeDescription": "Ses jours ne ferment plus l'atelier. Vous pourrez l'ajouter de nouveau plus tard.",
+    "refresh": "Relire",
+    "refreshed": "Calendrier relu",
+    "fromLink": "Relu chaque jour depuis {host}",
+    "fromFile": "Depuis le fichier {file}",
+    "updated": "Lu {when}",
+    "error": "Échec de la dernière lecture : {error}",
+    "days": "{count, plural, one {# jour} other {# jours}} en {first} et {second}",
+    "closeAll": "Tout fermer",
+    "openAll": "Tout ouvrir",
+    "choose": "Chaque jour ferme l'atelier, sauf si vous le désactivez. Les journées qu'un calendrier Google marque comme journées particulières, comme la Saint-Valentin, sont désactivées au départ.",
+    "show": "Afficher les jours",
+    "hide": "Masquer les jours",
+    "empty": "Aucun calendrier ajouté."
+  },
+  "closures": {
+    "title": "Jours de fermeture",
+    "description": "Jours où l'atelier est fermé pour ses propres raisons : une semaine de fermeture, une journée du personnel, une fermeture anticipée avant un jour férié.",
+    "add": "Ajouter un jour de fermeture",
+    "edit": "Modifier le jour de fermeture",
+    "name": "Nom",
+    "namePlaceholder": "Fermeture estivale",
+    "nameRequired": "Donnez-lui un nom",
+    "yearly": "Chaque année",
+    "yearlyHint": "Fermé aux mêmes dates chaque année.",
+    "empty": "Aucun jour de fermeture ajouté.",
+    "past": "Afficher les fermetures passées",
+    "hidePast": "Masquer les fermetures passées",
+    "saved": "Jour de fermeture enregistré",
+    "deleted": "Jour de fermeture retiré",
+    "deleteTitle": "Retirer {name} ?",
+    "deleteDescription": "L'atelier ouvre de nouveau normalement ces jours-là."
+  },
+  "stretch": {
+    "from": "Premier jour",
+    "to": "Dernier jour",
+    "allDay": "Toute la journée",
+    "startTime": "De",
+    "endTime": "Jusqu'à",
+    "startTimeHint": "Le premier jour. Laissez vide pour toute la journée.",
+    "endTimeHint": "Le dernier jour. Laissez vide pour le reste de la journée.",
+    "endBeforeStart": "Le dernier jour est avant le premier",
+    "timesCross": "L'heure de fin est avant l'heure de début",
+    "tooLong": "Une entrée peut couvrir un an au maximum",
+    "pickDate": "Choisir une date"
+  },
+  "timeOff": {
+    "title": "Absences",
+    "description": "Indiquez qu'un technicien est en congé ou absent : congés, arrêts maladie, formations. Pendant son absence, il n'est pas proposé pour les réservations, et le tableau de travail et la planification l'affichent.",
+    "add": "Indiquer un congé ou une absence",
+    "edit": "Modifier le congé ou l'absence",
+    "technician": "Technicien",
+    "pickTechnician": "Choisir un technicien",
+    "reason": "Motif",
+    "note": "Note",
+    "notePlaceholder": "Facultatif, par exemple le nom de la formation",
+    "empty": "Personne n'est en congé ou absent dans les semaines à venir.",
+    "saved": "Absence enregistrée",
+    "deleted": "Absence retirée",
+    "deleteTitle": "Retirer cette absence ?",
+    "deleteDescription": "{name} est de nouveau considéré comme disponible ces jours-là.",
+    "noTechnicians": "Ajoutez d'abord des techniciens sur le tableau de travail.",
+    "manage": "Congés et absences",
+    "upcoming": "Absences à venir et récentes",
+    "disabled": "Les absences sont désactivées pour cet atelier. Activez-les dans Paramètres, Horaires et jours fériés.",
+    "button": "Absence",
+    "setForTechnician": "Indiquer un congé ou une absence pour {name}"
+  },
+  "reasons": {
+    "holiday": "Congés",
+    "sick": "Arrêt maladie",
+    "training": "Formation",
+    "other": "Absent"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, à partir de {time}",
+    "until": "{dates}, jusqu'à {time}",
+    "between": "{dates}, de {start} à {end}",
+    "fromUntil": "{dates}, de {start} le premier jour jusqu'à {end} le dernier"
+  },
+  "board": {
+    "closedDay": "Fermé",
+    "closedFor": "Fermé : {name}",
+    "shortDay": "Journée courte : {name}",
+    "closedWeekday": "L'atelier est fermé ce jour-là",
+    "away": "{reason}",
+    "awayAllDay": "{name} est absent : {reason}",
+    "awayPart": "{name} est absent {when} : {reason}",
+    "awayTooltip": "Absent : {reason}",
+    "dropClosed": "L'atelier est fermé à ce moment-là ({name}). La tâche est planifiée quand même.",
+    "dropClosedWeekday": "L'atelier est normalement fermé à ce moment-là. La tâche est planifiée quand même.",
+    "dropAway": "{name} est absent à ce moment-là ({reason}). La tâche est planifiée quand même."
+  },
+  "schedule": {
+    "closed": "L'atelier est fermé à ce moment-là : {names}",
+    "closedWeekday": "L'atelier est normalement fermé ce jour-là.",
+    "away": "Absent : {reason}",
+    "awayPart": "Absent {when}",
+    "awayConflict": "Absent ({reason})",
+    "awayHeading": "Le technicien est absent à ce moment-là",
+    "bookedAnyway": "Vous pouvez garder ce créneau si le travail est convenu."
+  },
+  "calendar": {
+    "closure": "Fermé",
+    "absence": "Absence",
+    "absenceTitle": "{name} : {reason}"
+  },
+  "portal": {
+    "closedDay": "L'atelier est fermé ce jour-là ({name}). Veuillez choisir un autre jour.",
+    "closedWeekday": "L'atelier est fermé ce jour-là. Veuillez choisir un autre jour.",
+    "upcomingClosed": "Fermé les : {days}"
+  }
+}

+ 18 - 5
messages/it/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Apri promemoria",
       "quote": "Apri preventivo",
       "message": "Apri messaggi",
-      "external": "Apri"
+      "external": "Apri",
+      "closure": "Apri orari di apertura",
+      "absence": "Apri la bacheca lavori"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Promemoria",
     "quotes": "Preventivi",
     "messages": "Messaggi",
-    "external": "Calendari collegati"
+    "external": "Calendari collegati",
+    "closures": "Giorni di chiusura",
+    "absences": "Assenze"
   },
   "selectVehicle": "Seleziona veicolo per ordine di lavoro",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "In arrivo",
       "failed": "Non riuscito",
       "queued": "In coda",
-      "busy": "Occupato"
+      "busy": "Occupato",
+      "closed": "Chiuso",
+      "away": "Assente"
     },
     "type": {
       "service": "Ordine di lavoro",
       "reminder": "Promemoria",
       "quote": "Preventivo",
       "message": "Messaggio",
-      "external": "Calendario collegato"
+      "external": "Calendario collegato",
+      "closure": "Chiuso",
+      "absence": "Assenza"
     },
     "noEvents": "Nessun evento",
     "selectDay": "Seleziona un giorno con eventi o crea un nuovo ordine di lavoro.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Mese precedente",
-  "nextMonth": "Mese successivo"
+  "nextMonth": "Mese successivo",
+  "range": {
+    "from": "Dalle {time}",
+    "until": "Fino alle {time}"
+  },
+  "timeOffMissing": "Quell'assenza è stata modificata o rimossa nel frattempo."
 }

+ 7 - 0
messages/it/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "In corso",
       "completed": "Completata"
     }
+  },
+  "openingHours": {
+    "title": "Orari di apertura",
+    "closed": "Chiuso",
+    "today": "Oggi",
+    "closedSoon": "Prossime chiusure",
+    "openOnly": "Aperto {hours}"
   }
 }

+ 17 - 3
messages/it/settings.json

@@ -79,6 +79,10 @@
         "title": "Officina",
         "description": "Tecnico e valori predefiniti manodopera"
       },
+      "openingHours": {
+        "title": "Orari e festività",
+        "description": "Orari, festività, giorni di chiusura, assenze"
+      },
       "laborPresets": {
         "title": "Pacchetti manodopera",
         "description": "Pacchetti di lavoro predefiniti"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Impossibile assegnare gli ordini di lavoro",
     "noTechnician": "Nessuno",
     "weekStartDay": "Inizio settimana",
-    "workDayStart": "Inizio giornata",
-    "workDayEnd": "Fine giornata",
     "titleTemplateTitle": "Titolo dell'ordine di lavoro",
     "titleTemplateDescription": "Come si chiama un nuovo ordine di lavoro prima che qualcuno scriva un titolo. Componilo con i tag qui sotto; ogni lavoro si apre con i tag compilati e il titolo resta modificabile sul lavoro.",
     "titleTemplate": "Modello del titolo",
@@ -1249,7 +1251,9 @@
       "4": "Giovedi",
       "5": "Venerdi",
       "6": "Sabato"
-    }
+    },
+    "openingHoursMoved": "Gli orari di apertura si impostano per ogni giorno della settimana, accanto a festività, giorni di chiusura e assenze.",
+    "openingHoursLink": "Apri orari e festività"
   },
   "maintenance": {
     "title": "Manutenzione prevista",
@@ -2938,5 +2942,15 @@
       "cross": "Croce",
       "hex": "Esagono"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Orari di apertura",
+      "switch": "Giorni di chiusura e assenze",
+      "publicHolidays": "Festività",
+      "calendars": "Calendari delle festività",
+      "closedDays": "Giorni di chiusura",
+      "timeOff": "Ferie e assenze"
+    }
   }
 }

+ 174 - 0
messages/it/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Quando l'officina è aperta, i giorni in cui è chiusa e chi è assente. La bacheca lavori, la pianificazione, il calendario, la prenotazione online e il portale clienti ne tengono conto."
+  },
+  "switch": {
+    "title": "Giorni di chiusura e assenze",
+    "label": "Usa giorni di chiusura, festività e assenze",
+    "hint": "Quando è disattivato, niente in questa pagina tranne gli orari di apertura modifica la bacheca lavori, la pianificazione, il calendario o la prenotazione online. Tutto ciò che hai inserito viene conservato.",
+    "saved": "Salvato",
+    "failed": "Impossibile salvare"
+  },
+  "hours": {
+    "title": "Orari di apertura",
+    "description": "Quando l'officina è aperta in ogni giorno della settimana. La prenotazione online e il primo orario libero propongono solo queste ore, e la bacheca lavori ombreggia il resto.",
+    "open": "Aperto",
+    "closed": "Chiuso",
+    "opens": "Apre",
+    "closes": "Chiude",
+    "copyMonday": "Copia il lunedì sugli altri giorni feriali",
+    "save": "Salva orari di apertura",
+    "saved": "Orari di apertura salvati",
+    "invalid": "{day}: l'orario di chiusura deve essere dopo quello di apertura",
+    "allClosed": "Tutti i giorni sono chiusi, quindi non si può prenotare nulla online."
+  },
+  "holidays": {
+    "title": "Festività",
+    "description": "Scegli il tuo paese, e il tuo stato o la tua regione dove le festività cambiano, per chiudere nei giorni festivi. Le date provengono da Nager.Date e vengono aggiornate ogni giorno.",
+    "country": "Paese",
+    "noCountry": "Nessuna festività",
+    "searchCountry": "Cerca paesi",
+    "regions": "Stati e regioni",
+    "regionsHint": "Una festività osservata solo in alcune regioni conta quando una delle tue è selezionata. Le festività nazionali contano sempre.",
+    "countriesFailed": "Impossibile caricare l'elenco dei paesi ({error}). Riprova più tardi.",
+    "list": "Festività nel {first} e nel {second}",
+    "closed": "Chiuso",
+    "open": "Aperto",
+    "regional": "Solo in {regions}",
+    "notPublic": "Non è un giorno festivo, quindi aperto a meno che tu non chiuda",
+    "changed": "Modificato rispetto al predefinito",
+    "reset": "Usa predefinito",
+    "refresh": "Aggiorna ora",
+    "updated": "Aggiornato {when}",
+    "neverUpdated": "Non ancora aggiornato",
+    "fetchError": "Impossibile aggiornare le festività: {error}. I giorni già elencati restano validi.",
+    "saved": "Festività aggiornate",
+    "none": "Nager.Date non ha festività per questo paese.",
+    "pickCountry": "Scegli un paese per elencarne le festività.",
+    "source": "Date delle festività da Nager.Date"
+  },
+  "feeds": {
+    "title": "Calendari delle festività",
+    "description": "Aggiungi qualsiasi calendario delle festività come link o file .ics: un feed istituzionale, i calendari delle festività di Google, il tuo. Un link viene riletto ogni giorno.",
+    "addLink": "Aggiungi link",
+    "linkPlaceholder": "Link https:// o webcal:// a un calendario .ics",
+    "uploadFile": "Carica file .ics",
+    "added": "Calendario aggiunto",
+    "addFailed": "Impossibile aggiungere il calendario",
+    "tooLarge": "Il file è troppo grande. I calendari delle festività sono molto più piccoli di 900 KB.",
+    "remove": "Rimuovi",
+    "removeTitle": "Rimuovere {name}?",
+    "removeDescription": "I suoi giorni non chiudono più l'officina. Puoi aggiungerlo di nuovo in seguito.",
+    "refresh": "Rileggi",
+    "refreshed": "Calendario riletto",
+    "fromLink": "Letto ogni giorno da {host}",
+    "fromFile": "Dal file {file}",
+    "updated": "Letto {when}",
+    "error": "Ultima lettura non riuscita: {error}",
+    "days": "{count, plural, one {# giorno} other {# giorni}} nel {first} e nel {second}",
+    "closeAll": "Chiudi in tutti i giorni",
+    "openAll": "Apri in tutti i giorni",
+    "choose": "Ogni giorno chiude l'officina, a meno che non lo disattivi. I giorni che un calendario Google indica come ricorrenze, come San Valentino, partono disattivati.",
+    "show": "Mostra giorni",
+    "hide": "Nascondi giorni",
+    "empty": "Nessun calendario aggiunto."
+  },
+  "closures": {
+    "title": "Giorni di chiusura",
+    "description": "Giorni in cui l'officina è chiusa per motivi propri: una settimana di chiusura, una giornata del personale, una chiusura anticipata prima di una festività.",
+    "add": "Aggiungi giorno di chiusura",
+    "edit": "Modifica giorno di chiusura",
+    "name": "Nome",
+    "namePlaceholder": "Chiusura estiva",
+    "nameRequired": "Dagli un nome",
+    "yearly": "Ogni anno",
+    "yearlyHint": "Chiuso nelle stesse date ogni anno.",
+    "empty": "Nessun giorno di chiusura aggiunto.",
+    "past": "Mostra chiusure passate",
+    "hidePast": "Nascondi chiusure passate",
+    "saved": "Giorno di chiusura salvato",
+    "deleted": "Giorno di chiusura rimosso",
+    "deleteTitle": "Rimuovere {name}?",
+    "deleteDescription": "In questi giorni l'officina torna ad aprire come al solito."
+  },
+  "stretch": {
+    "from": "Primo giorno",
+    "to": "Ultimo giorno",
+    "allDay": "Tutto il giorno",
+    "startTime": "Dalle",
+    "endTime": "Fino alle",
+    "startTimeHint": "Il primo giorno. Lascia vuoto per l'intera giornata.",
+    "endTimeHint": "L'ultimo giorno. Lascia vuoto per il resto della giornata.",
+    "endBeforeStart": "L'ultimo giorno è prima del primo",
+    "timesCross": "L'ora di fine è prima dell'ora di inizio",
+    "tooLong": "Una voce può coprire al massimo un anno",
+    "pickDate": "Scegli una data"
+  },
+  "timeOff": {
+    "title": "Assenze",
+    "description": "Segna un tecnico in ferie o assente: ferie, malattia, corsi. Mentre è assente non viene proposto per le prenotazioni, e la bacheca lavori e la pianificazione lo mostrano.",
+    "add": "Segna ferie o assenza",
+    "edit": "Modifica ferie o assenza",
+    "technician": "Tecnico",
+    "pickTechnician": "Scegli un tecnico",
+    "reason": "Motivo",
+    "note": "Nota",
+    "notePlaceholder": "Facoltativo, ad esempio il nome del corso",
+    "empty": "Nessuno è in ferie o assente nelle prossime settimane.",
+    "saved": "Assenza salvata",
+    "deleted": "Assenza rimossa",
+    "deleteTitle": "Rimuovere questa assenza?",
+    "deleteDescription": "{name} torna a risultare disponibile in questi giorni.",
+    "noTechnicians": "Aggiungi prima i tecnici nella bacheca lavori.",
+    "manage": "Ferie e assenze",
+    "upcoming": "Assenze in arrivo e recenti",
+    "disabled": "Le assenze sono disattivate per questa officina. Attivale in Impostazioni, Orari e festività.",
+    "button": "Assenza",
+    "setForTechnician": "Segna ferie o assenza per {name}"
+  },
+  "reasons": {
+    "holiday": "Ferie",
+    "sick": "Malattia",
+    "training": "Formazione",
+    "other": "Assente"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, dalle {time}",
+    "until": "{dates}, fino alle {time}",
+    "between": "{dates}, dalle {start} alle {end}",
+    "fromUntil": "{dates}, dalle {start} del primo giorno fino alle {end} dell'ultimo"
+  },
+  "board": {
+    "closedDay": "Chiuso",
+    "closedFor": "Chiuso: {name}",
+    "shortDay": "Orario ridotto: {name}",
+    "closedWeekday": "L'officina è chiusa in questo giorno",
+    "away": "{reason}",
+    "awayAllDay": "{name} è assente: {reason}",
+    "awayPart": "{name} è assente {when}: {reason}",
+    "awayTooltip": "Assente: {reason}",
+    "dropClosed": "L'officina è chiusa in quel momento ({name}). Il lavoro viene pianificato comunque.",
+    "dropClosedWeekday": "L'officina di solito è chiusa in quel momento. Il lavoro viene pianificato comunque.",
+    "dropAway": "{name} è assente in quel momento ({reason}). Il lavoro viene pianificato comunque."
+  },
+  "schedule": {
+    "closed": "L'officina è chiusa in quel momento: {names}",
+    "closedWeekday": "L'officina di solito è chiusa in questo giorno.",
+    "away": "Assente: {reason}",
+    "awayPart": "Assente {when}",
+    "awayConflict": "Assente ({reason})",
+    "awayHeading": "Il tecnico è assente in quel momento",
+    "bookedAnyway": "Puoi comunque mantenere questo orario se il lavoro è concordato."
+  },
+  "calendar": {
+    "closure": "Chiuso",
+    "absence": "Assenza",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "L'officina è chiusa quel giorno ({name}). Scegli un altro giorno.",
+    "closedWeekday": "L'officina è chiusa quel giorno. Scegli un altro giorno.",
+    "upcomingClosed": "Chiuso nei giorni: {days}"
+  }
+}

+ 18 - 5
messages/lt/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Atidaryti priminimą",
       "quote": "Atidaryti pasiūlymą",
       "message": "Atidaryti žinutes",
-      "external": "Atidaryti"
+      "external": "Atidaryti",
+      "closure": "Atidaryti darbo laiką",
+      "absence": "Atidaryti darbų lentą"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Priminimai",
     "quotes": "Pasiūlymai",
     "messages": "Žinutės",
-    "external": "Prijungti kalendoriai"
+    "external": "Prijungti kalendoriai",
+    "closures": "Nedarbo dienos",
+    "absences": "Nebuvimas darbe"
   },
   "selectVehicle": "Pasirinkite transporto priemonę darbo užsakymui",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Artėja",
       "failed": "Nepavyko",
       "queued": "Eilėje",
-      "busy": "Užimta"
+      "busy": "Užimta",
+      "closed": "Uždaryta",
+      "away": "Nėra"
     },
     "type": {
       "service": "Darbo užsakymas",
       "reminder": "Priminimas",
       "quote": "Pasiūlymas",
       "message": "Žinutė",
-      "external": "Prijungtas kalendorius"
+      "external": "Prijungtas kalendorius",
+      "closure": "Uždaryta",
+      "absence": "Nebuvimas darbe"
     },
     "noEvents": "Įvykių nėra",
     "selectDay": "Pasirinkite dieną su įvykiais arba sukurkite naują darbo užsakymą.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Ankstesnis mėnuo",
-  "nextMonth": "Kitas mėnuo"
+  "nextMonth": "Kitas mėnuo",
+  "range": {
+    "from": "Nuo {time}",
+    "until": "Iki {time}"
+  },
+  "timeOffMissing": "Šis nebuvimas per tą laiką buvo pakeistas arba pašalintas."
 }

+ 7 - 0
messages/lt/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "Vykdoma",
       "completed": "Baigta"
     }
+  },
+  "openingHours": {
+    "title": "Darbo laikas",
+    "closed": "Nedirba",
+    "today": "Šiandien",
+    "closedSoon": "Artimiausios nedarbo dienos",
+    "openOnly": "Dirba {hours}"
   }
 }

+ 17 - 3
messages/lt/settings.json

@@ -79,6 +79,10 @@
         "title": "Dirbtuvės",
         "description": "Techniko ir darbų numatytosios reikšmės"
       },
+      "openingHours": {
+        "title": "Darbo laikas ir šventės",
+        "description": "Valandos, šventinės dienos, nedarbo dienos, atostogos"
+      },
       "laborPresets": {
         "title": "Darbų šablonai",
         "description": "Iš anksto apibrėžti darbų paketai"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Nepavyko priskirti darbo užsakymų",
     "noTechnician": "Nėra",
     "weekStartDay": "Savaitės pradžios diena",
-    "workDayStart": "Darbo dienos pradžia",
-    "workDayEnd": "Darbo dienos pabaiga",
     "titleTemplateTitle": "Darbo užsakymo pavadinimas",
     "titleTemplateDescription": "Kaip vadinamas naujas darbo užsakymas, kol niekas neįrašė pavadinimo. Sudarykite jį iš žymų žemiau; kiekvienas darbas atsidaro su užpildytomis žymomis, o pavadinimą vis dar galima keisti pačiame darbe.",
     "titleTemplate": "Pavadinimo šablonas",
@@ -1249,7 +1251,9 @@
       "4": "Ketvirtadienis",
       "5": "Penktadienis",
       "6": "Šeštadienis"
-    }
+    },
+    "openingHoursMoved": "Darbo laikas nustatomas kiekvienai savaitės dienai, kartu su šventinėmis dienomis, nedarbo dienomis ir atostogomis.",
+    "openingHoursLink": "Atidaryti darbo laiką ir šventes"
   },
   "maintenance": {
     "title": "Numatoma priežiūra",
@@ -2938,5 +2942,15 @@
       "cross": "Kryžius",
       "hex": "Šešiakampis"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Darbo laikas",
+      "switch": "Nedarbo dienos ir nebuvimas darbe",
+      "publicHolidays": "Šventinės dienos",
+      "calendars": "Švenčių kalendoriai",
+      "closedDays": "Nedarbo dienos",
+      "timeOff": "Atostogos ir nebuvimas"
+    }
   }
 }

+ 174 - 0
messages/lt/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Kada dirbtuvės dirba, kuriomis dienomis jos uždarytos ir kurių darbuotojų nėra. Darbų lenta, planavimas, kalendorius, rezervacija internetu ir kliento portalas tuo vadovaujasi."
+  },
+  "switch": {
+    "title": "Nedarbo dienos ir nebuvimas darbe",
+    "label": "Naudoti nedarbo dienas, šventines dienas ir nebuvimą darbe",
+    "hint": "Kai tai išjungta, niekas šiame puslapyje, išskyrus darbo laiką, nekeičia darbų lentos, planavimo, kalendoriaus ar rezervacijos internetu. Viskas, ką įvedėte, išsaugoma.",
+    "saved": "Išsaugota",
+    "failed": "Nepavyko išsaugoti"
+  },
+  "hours": {
+    "title": "Darbo laikas",
+    "description": "Kada dirbtuvės dirba kiekvieną savaitės dieną. Rezervacija internetu ir artimiausias laisvas laikas siūlo tik šias valandas, o darbų lentoje likęs laikas užtamsinamas.",
+    "open": "Atidaryta",
+    "closed": "Uždaryta",
+    "opens": "Atsidaro",
+    "closes": "Užsidaro",
+    "copyMonday": "Kopijuoti pirmadienį į kitas darbo dienas",
+    "save": "Išsaugoti darbo laiką",
+    "saved": "Darbo laikas išsaugotas",
+    "invalid": "{day}: uždarymo laikas turi būti vėlesnis nei atidarymo",
+    "allClosed": "Visomis dienomis uždaryta, todėl internetu nieko rezervuoti negalima."
+  },
+  "holidays": {
+    "title": "Šventinės dienos",
+    "description": "Pasirinkite šalį, o jei šventės skiriasi, ir valstiją ar regioną, kad per oficialias šventines dienas dirbtuvės būtų uždarytos. Datos gaunamos iš Nager.Date ir atnaujinamos kasdien.",
+    "country": "Šalis",
+    "noCountry": "Be šventinių dienų",
+    "searchCountry": "Ieškoti šalių",
+    "regions": "Valstijos ir regionai",
+    "regionsHint": "Šventė, minima tik kai kuriuose regionuose, galioja, jei pažymėtas vienas iš jūsų regionų. Visos šalies šventės galioja visada.",
+    "countriesFailed": "Nepavyko įkelti šalių sąrašo ({error}). Bandykite vėliau.",
+    "list": "Šventinės dienos {first} ir {second} m.",
+    "closed": "Uždaryta",
+    "open": "Atidaryta",
+    "regional": "Tik: {regions}",
+    "notPublic": "Ne oficiali šventinė diena, todėl atidaryta, nebent uždarysite",
+    "changed": "Pakeista, palyginti su numatytąja",
+    "reset": "Naudoti numatytąją",
+    "refresh": "Atnaujinti dabar",
+    "updated": "Atnaujinta {when}",
+    "neverUpdated": "Dar neatnaujinta",
+    "fetchError": "Nepavyko atnaujinti šventinių dienų: {error}. Jau išvardytos dienos vis tiek galioja.",
+    "saved": "Šventinės dienos atnaujintos",
+    "none": "Nager.Date neturi šios šalies šventinių dienų.",
+    "pickCountry": "Pasirinkite šalį, kad pamatytumėte jos šventines dienas.",
+    "source": "Šventinių dienų datos iš Nager.Date"
+  },
+  "feeds": {
+    "title": "Švenčių kalendoriai",
+    "description": "Pridėkite bet kurį švenčių kalendorių kaip .ics nuorodą ar failą: oficialų srautą, Google švenčių kalendorius ar savo. Nuoroda perskaitoma kasdien.",
+    "addLink": "Pridėti nuorodą",
+    "linkPlaceholder": "https:// arba webcal:// nuoroda į .ics kalendorių",
+    "uploadFile": "Įkelti .ics failą",
+    "added": "Kalendorius pridėtas",
+    "addFailed": "Nepavyko pridėti kalendoriaus",
+    "tooLarge": "Failas per didelis. Švenčių kalendoriai būna gerokai mažesni nei 900 KB.",
+    "remove": "Pašalinti",
+    "removeTitle": "Pašalinti {name}?",
+    "removeDescription": "Jo dienomis dirbtuvės nebebus uždarytos. Vėliau galėsite jį vėl pridėti.",
+    "refresh": "Perskaityti iš naujo",
+    "refreshed": "Kalendorius perskaitytas iš naujo",
+    "fromLink": "Kasdien skaitoma iš {host}",
+    "fromFile": "Iš failo {file}",
+    "updated": "Perskaityta {when}",
+    "error": "Paskutinis skaitymas nepavyko: {error}",
+    "days": "{count, plural, one {# diena} few {# dienos} many {# dienos} other {# dienų}} {first} ir {second} m.",
+    "closeAll": "Uždaryti visomis dienomis",
+    "openAll": "Atidaryti visomis dienomis",
+    "choose": "Kiekviena diena uždaro dirbtuves, nebent ją išjungsite. Dienos, kurias Google kalendorius žymi kaip minėtinas, pavyzdžiui, Valentino diena, iš pradžių būna išjungtos.",
+    "show": "Rodyti dienas",
+    "hide": "Slėpti dienas",
+    "empty": "Kalendorių nepridėta."
+  },
+  "closures": {
+    "title": "Nedarbo dienos",
+    "description": "Dienos, kai dirbtuvės uždarytos dėl savų priežasčių: uždarymo savaitė, personalo diena, ankstesnis uždarymas prieš šventę.",
+    "add": "Pridėti nedarbo dieną",
+    "edit": "Redaguoti nedarbo dieną",
+    "name": "Pavadinimas",
+    "namePlaceholder": "Vasaros pertrauka",
+    "nameRequired": "Įveskite pavadinimą",
+    "yearly": "Kasmet",
+    "yearlyHint": "Uždaryta tomis pačiomis datomis kasmet.",
+    "empty": "Nedarbo dienų nepridėta.",
+    "past": "Rodyti praėjusias nedarbo dienas",
+    "hidePast": "Slėpti praėjusias nedarbo dienas",
+    "saved": "Nedarbo diena išsaugota",
+    "deleted": "Nedarbo diena pašalinta",
+    "deleteTitle": "Pašalinti {name}?",
+    "deleteDescription": "Šiomis dienomis dirbtuvės vėl dirbs įprastai."
+  },
+  "stretch": {
+    "from": "Pirma diena",
+    "to": "Paskutinė diena",
+    "allDay": "Visą dieną",
+    "startTime": "Nuo",
+    "endTime": "Iki",
+    "startTimeHint": "Pirmąją dieną. Palikite tuščią, jei visą dieną.",
+    "endTimeHint": "Paskutinę dieną. Palikite tuščią, jei iki dienos pabaigos.",
+    "endBeforeStart": "Paskutinė diena yra anksčiau nei pirmoji",
+    "timesCross": "Pabaigos laikas yra anksčiau nei pradžios laikas",
+    "tooLong": "Vienas įrašas gali apimti ne daugiau kaip metus",
+    "pickDate": "Pasirinkite datą"
+  },
+  "timeOff": {
+    "title": "Nebuvimas darbe",
+    "description": "Pažymėkite, kad technikas atostogauja arba nėra darbe: atostogos, ligos dienos, kursai. Kol jo nėra, jis nesiūlomas rezervacijoms, o darbų lenta ir planavimas tai rodo.",
+    "add": "Pažymėti atostogas ar nebuvimą",
+    "edit": "Redaguoti atostogas ar nebuvimą",
+    "technician": "Technikas",
+    "pickTechnician": "Pasirinkite techniką",
+    "reason": "Priežastis",
+    "note": "Pastaba",
+    "notePlaceholder": "Neprivaloma, pvz., kursų pavadinimas",
+    "empty": "Ateinančiomis savaitėmis atostogų ar nebuvimo darbe nėra.",
+    "saved": "Nebuvimas išsaugotas",
+    "deleted": "Nebuvimas pašalintas",
+    "deleteTitle": "Pašalinti šį nebuvimą?",
+    "deleteDescription": "{name} šiomis dienomis vėl laikomas dirbančiu.",
+    "noTechnicians": "Pirmiausia pridėkite technikų darbų lentoje.",
+    "manage": "Atostogos ir nebuvimas",
+    "upcoming": "Būsimas ir neseniai buvęs nebuvimas",
+    "disabled": "Nebuvimas darbe šioms dirbtuvėms išjungtas. Įjunkite jį skiltyje Nustatymai, Darbo laikas ir šventės.",
+    "button": "Nebuvimas",
+    "setForTechnician": "Pažymėti atostogas ar nebuvimą: {name}"
+  },
+  "reasons": {
+    "holiday": "Atostogos",
+    "sick": "Nedarbingumas",
+    "training": "Mokymai",
+    "other": "Ne darbe"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, nuo {time}",
+    "until": "{dates}, iki {time}",
+    "between": "{dates}, nuo {start} iki {end}",
+    "fromUntil": "{dates}, nuo {start} pirmąją dieną iki {end} paskutinę"
+  },
+  "board": {
+    "closedDay": "Uždaryta",
+    "closedFor": "Uždaryta: {name}",
+    "shortDay": "Sutrumpinta diena: {name}",
+    "closedWeekday": "Šią dieną dirbtuvės uždarytos",
+    "away": "{reason}",
+    "awayAllDay": "{name} nėra: {reason}",
+    "awayPart": "{name} nėra {when}: {reason}",
+    "awayTooltip": "Nėra: {reason}",
+    "dropClosed": "Tuo metu dirbtuvės uždarytos ({name}). Darbas vis tiek suplanuotas.",
+    "dropClosedWeekday": "Tuo metu dirbtuvės paprastai uždarytos. Darbas vis tiek suplanuotas.",
+    "dropAway": "{name} tuo metu nėra ({reason}). Darbas vis tiek suplanuotas."
+  },
+  "schedule": {
+    "closed": "Tuo metu dirbtuvės uždarytos: {names}",
+    "closedWeekday": "Šią dieną dirbtuvės paprastai uždarytos.",
+    "away": "Nėra: {reason}",
+    "awayPart": "Nėra {when}",
+    "awayConflict": "Nėra ({reason})",
+    "awayHeading": "Technikas tuo metu nedirba",
+    "bookedAnyway": "Šį laiką vis tiek galite palikti, jei darbas suderintas."
+  },
+  "calendar": {
+    "closure": "Uždaryta",
+    "absence": "Nebuvimas darbe",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "Tą dieną dirbtuvės uždarytos ({name}). Pasirinkite kitą dieną.",
+    "closedWeekday": "Tą dieną dirbtuvės uždarytos. Pasirinkite kitą dieną.",
+    "upcomingClosed": "Uždaryta: {days}"
+  }
+}

+ 18 - 5
messages/nb/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Åpne påminnelse",
       "quote": "Åpne tilbud",
       "message": "Åpne meldinger",
-      "external": "Åpne"
+      "external": "Åpne",
+      "closure": "Åpne åpningstider",
+      "absence": "Åpne arbeidstavlen"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Påminnelser",
     "quotes": "Tilbud",
     "messages": "Meldinger",
-    "external": "Tilkoblede kalendere"
+    "external": "Tilkoblede kalendere",
+    "closures": "Stengte dager",
+    "absences": "Fravær"
   },
   "selectVehicle": "Velg kjøretøy for arbeidsordre",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Kommende",
       "failed": "Mislyktes",
       "queued": "I kø",
-      "busy": "Opptatt"
+      "busy": "Opptatt",
+      "closed": "Stengt",
+      "away": "Borte"
     },
     "type": {
       "service": "Arbeidsordre",
       "reminder": "Påminnelse",
       "quote": "Tilbud",
       "message": "Melding",
-      "external": "Tilkoblet kalender"
+      "external": "Tilkoblet kalender",
+      "closure": "Stengt",
+      "absence": "Fravær"
     },
     "noEvents": "Ingen hendelser",
     "selectDay": "Velg en dag med hendelser eller opprett en ny arbeidsordre.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Forrige måned",
-  "nextMonth": "Neste måned"
+  "nextMonth": "Neste måned",
+  "range": {
+    "from": "Fra {time}",
+    "until": "Til {time}"
+  },
+  "timeOffMissing": "Dette fraværet ble endret eller fjernet i mellomtiden."
 }

+ 7 - 0
messages/nb/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "Pågår",
       "completed": "Fullført"
     }
+  },
+  "openingHours": {
+    "title": "Åpningstider",
+    "closed": "Stengt",
+    "today": "I dag",
+    "closedSoon": "Stengt snart",
+    "openOnly": "Åpent {hours}"
   }
 }

+ 17 - 3
messages/nb/settings.json

@@ -79,6 +79,10 @@
         "title": "Verksted",
         "description": "Tekniker- og arbeidsstandarder"
       },
+      "openingHours": {
+        "title": "Åpningstider og helligdager",
+        "description": "Tider, helligdager, stengte dager, fravær"
+      },
       "laborPresets": {
         "title": "Arbeidspakker",
         "description": "Forhåndsdefinerte arbeidspakker"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Kunne ikke tildele arbeidsordre",
     "noTechnician": "Ingen",
     "weekStartDay": "Ukens startdag",
-    "workDayStart": "Arbeidsdagen starter",
-    "workDayEnd": "Arbeidsdagen slutter",
     "titleTemplateTitle": "Tittel på arbeidsordre",
     "titleTemplateDescription": "Hva en ny arbeidsordre heter før noen skriver inn en tittel. Sett den sammen av taggene under; hver jobb åpnes med taggene fylt ut, og tittelen kan fortsatt endres på jobben.",
     "titleTemplate": "Tittelmal",
@@ -1249,7 +1251,9 @@
       "4": "Torsdag",
       "5": "Fredag",
       "6": "Lørdag"
-    }
+    },
+    "openingHoursMoved": "Åpningstider settes for hver ukedag, sammen med helligdager, stengte dager og fravær.",
+    "openingHoursLink": "Åpne åpningstider og helligdager"
   },
   "maintenance": {
     "title": "Predikert vedlikehold",
@@ -2938,5 +2942,15 @@
       "cross": "Kryss",
       "hex": "Sekskant"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Åpningstider",
+      "switch": "Stengte dager og fravær",
+      "publicHolidays": "Helligdager",
+      "calendars": "Helligdagskalendere",
+      "closedDays": "Stengte dager",
+      "timeOff": "Ferie og fravær"
+    }
   }
 }

+ 174 - 0
messages/nb/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Når verkstedet er åpent, hvilke dager det er stengt, og hvem som er borte. Arbeidstavlen, planleggingen, kalenderen, nettbestillingen og kundeportalen følger dette."
+  },
+  "switch": {
+    "title": "Stengte dager og fravær",
+    "label": "Bruk stengte dager, helligdager og fravær",
+    "hint": "Når dette er av, påvirker ingenting på denne siden unntatt åpningstidene arbeidstavlen, planleggingen, kalenderen eller nettbestillingen. Alt du har lagt inn, blir tatt vare på.",
+    "saved": "Lagret",
+    "failed": "Kunne ikke lagre"
+  },
+  "hours": {
+    "title": "Åpningstider",
+    "description": "Når verkstedet er åpent hver dag i uken. Nettbestilling og neste ledige tid tilbyr bare disse tidene, og arbeidstavlen skraverer resten.",
+    "open": "Åpent",
+    "closed": "Stengt",
+    "opens": "Åpner",
+    "closes": "Stenger",
+    "copyMonday": "Kopier mandag til de andre ukedagene",
+    "save": "Lagre åpningstider",
+    "saved": "Åpningstider lagret",
+    "invalid": "{day}: stengetiden må være etter åpningstiden",
+    "allClosed": "Alle dager er stengt, så ingenting kan bestilles på nett."
+  },
+  "holidays": {
+    "title": "Helligdager",
+    "description": "Velg land, og delstat eller region der helligdagene varierer, for å holde stengt på offentlige helligdager. Datoene kommer fra Nager.Date og oppdateres hver dag.",
+    "country": "Land",
+    "noCountry": "Ingen helligdager",
+    "searchCountry": "Søk etter land",
+    "regions": "Delstater og regioner",
+    "regionsHint": "En helligdag som bare gjelder i noen regioner, teller når en av dine er krysset av. Nasjonale helligdager teller alltid.",
+    "countriesFailed": "Kunne ikke laste listen over land ({error}). Prøv igjen senere.",
+    "list": "Helligdager i {first} og {second}",
+    "closed": "Stengt",
+    "open": "Åpent",
+    "regional": "Bare i {regions}",
+    "notPublic": "Ikke en offentlig helligdag, så åpent med mindre du stenger",
+    "changed": "Endret fra standard",
+    "reset": "Bruk standard",
+    "refresh": "Oppdater nå",
+    "updated": "Oppdatert {when}",
+    "neverUpdated": "Ikke oppdatert ennå",
+    "fetchError": "Kunne ikke oppdatere helligdagene: {error}. Dagene som allerede står i listen, teller fortsatt.",
+    "saved": "Helligdager oppdatert",
+    "none": "Nager.Date har ingen helligdager for dette landet.",
+    "pickCountry": "Velg et land for å se helligdagene.",
+    "source": "Helligdagsdatoer fra Nager.Date"
+  },
+  "feeds": {
+    "title": "Helligdagskalendere",
+    "description": "Legg til en hvilken som helst helligdagskalender som .ics-lenke eller -fil: en offentlig feed, Googles helligdagskalendere eller din egen. En lenke leses på nytt hver dag.",
+    "addLink": "Legg til lenke",
+    "linkPlaceholder": "https://- eller webcal://-lenke til en .ics-kalender",
+    "uploadFile": "Last opp .ics-fil",
+    "added": "Kalender lagt til",
+    "addFailed": "Kunne ikke legge til kalenderen",
+    "tooLarge": "Filen er for stor. Helligdagskalendere er mye mindre enn 900 KB.",
+    "remove": "Fjern",
+    "removeTitle": "Fjerne {name}?",
+    "removeDescription": "Dagene i den stenger ikke lenger verkstedet. Du kan legge den til igjen senere.",
+    "refresh": "Les på nytt",
+    "refreshed": "Kalenderen er lest på nytt",
+    "fromLink": "Leses hver dag fra {host}",
+    "fromFile": "Fra filen {file}",
+    "updated": "Lest {when}",
+    "error": "Siste lesing mislyktes: {error}",
+    "days": "{count, plural, one {# dag} other {# dager}} i {first} og {second}",
+    "closeAll": "Stengt på alle",
+    "openAll": "Åpent på alle",
+    "choose": "Hver dag stenger verkstedet med mindre du slår den av. Dager en Google-kalender merker som merkedager, som valentinsdagen, er slått av fra start.",
+    "show": "Vis dager",
+    "hide": "Skjul dager",
+    "empty": "Ingen kalendere lagt til."
+  },
+  "closures": {
+    "title": "Stengte dager",
+    "description": "Dager verkstedet holder stengt av egne grunner: en ferieuke, en personaldag, tidlig stenging før en helligdag.",
+    "add": "Legg til stengt dag",
+    "edit": "Rediger stengt dag",
+    "name": "Navn",
+    "namePlaceholder": "Sommerstengt",
+    "nameRequired": "Gi den et navn",
+    "yearly": "Hvert år",
+    "yearlyHint": "Stengt på de samme datoene hvert år.",
+    "empty": "Ingen stengte dager lagt til.",
+    "past": "Vis tidligere stengte dager",
+    "hidePast": "Skjul tidligere stengte dager",
+    "saved": "Stengt dag lagret",
+    "deleted": "Stengt dag fjernet",
+    "deleteTitle": "Fjerne {name}?",
+    "deleteDescription": "Verkstedet holder åpent som vanlig disse dagene igjen."
+  },
+  "stretch": {
+    "from": "Første dag",
+    "to": "Siste dag",
+    "allDay": "Hele dagen",
+    "startTime": "Fra",
+    "endTime": "Til",
+    "startTimeHint": "På den første dagen. La stå tomt for hele dagen.",
+    "endTimeHint": "På den siste dagen. La stå tomt for resten av dagen.",
+    "endBeforeStart": "Siste dag er før den første",
+    "timesCross": "Sluttiden er før starttiden",
+    "tooLong": "Én oppføring kan dekke høyst ett år",
+    "pickDate": "Velg en dato"
+  },
+  "timeOff": {
+    "title": "Fravær",
+    "description": "Registrer at en tekniker har ferie eller fravær: ferie, sykedager, kurs. I fraværet blir teknikeren ikke tilbudt for bestillinger, og arbeidstavlen og planleggingen viser det.",
+    "add": "Registrer ferie eller fravær",
+    "edit": "Rediger ferie eller fravær",
+    "technician": "Tekniker",
+    "pickTechnician": "Velg en tekniker",
+    "reason": "Årsak",
+    "note": "Notat",
+    "notePlaceholder": "Valgfritt, for eksempel navnet på kurset",
+    "empty": "Ingen har ferie eller fravær de kommende ukene.",
+    "saved": "Fravær lagret",
+    "deleted": "Fravær fjernet",
+    "deleteTitle": "Fjerne dette fraværet?",
+    "deleteDescription": "{name} regnes som tilgjengelig igjen disse dagene.",
+    "noTechnicians": "Legg til teknikere på arbeidstavlen først.",
+    "manage": "Ferie og fravær",
+    "upcoming": "Kommende og nylig fravær",
+    "disabled": "Fravær er slått av for dette verkstedet. Slå det på under Innstillinger, Åpningstider og helligdager.",
+    "button": "Fravær",
+    "setForTechnician": "Registrer ferie eller fravær for {name}"
+  },
+  "reasons": {
+    "holiday": "Ferie",
+    "sick": "Syk",
+    "training": "Kurs",
+    "other": "Fraværende"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, fra {time}",
+    "until": "{dates}, til {time}",
+    "between": "{dates}, {start} til {end}",
+    "fromUntil": "{dates}, fra {start} den første dagen til {end} den siste"
+  },
+  "board": {
+    "closedDay": "Stengt",
+    "closedFor": "Stengt: {name}",
+    "shortDay": "Kort dag: {name}",
+    "closedWeekday": "Verkstedet er stengt denne dagen",
+    "away": "{reason}",
+    "awayAllDay": "{name} er borte: {reason}",
+    "awayPart": "{name} er borte {when}: {reason}",
+    "awayTooltip": "Borte: {reason}",
+    "dropClosed": "Verkstedet er stengt da ({name}). Jobben er planlagt likevel.",
+    "dropClosedWeekday": "Verkstedet er vanligvis stengt da. Jobben er planlagt likevel.",
+    "dropAway": "{name} er borte da ({reason}). Jobben er planlagt likevel."
+  },
+  "schedule": {
+    "closed": "Verkstedet er stengt da: {names}",
+    "closedWeekday": "Verkstedet er vanligvis stengt denne dagen.",
+    "away": "Borte: {reason}",
+    "awayPart": "Borte {when}",
+    "awayConflict": "Borte ({reason})",
+    "awayHeading": "Teknikeren er borte da",
+    "bookedAnyway": "Du kan likevel beholde denne tiden hvis arbeidet er avtalt."
+  },
+  "calendar": {
+    "closure": "Stengt",
+    "absence": "Fravær",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "Verkstedet er stengt den dagen ({name}). Velg en annen dag.",
+    "closedWeekday": "Verkstedet er stengt den dagen. Velg en annen dag.",
+    "upcomingClosed": "Stengt: {days}"
+  }
+}

+ 18 - 5
messages/nl/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Herinnering openen",
       "quote": "Offerte openen",
       "message": "Berichten openen",
-      "external": "Openen"
+      "external": "Openen",
+      "closure": "Openingstijden openen",
+      "absence": "Werkbord openen"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Herinneringen",
     "quotes": "Offertes",
     "messages": "Berichten",
-    "external": "Gekoppelde agenda's"
+    "external": "Gekoppelde agenda's",
+    "closures": "Sluitingsdagen",
+    "absences": "Verlof"
   },
   "selectVehicle": "Selecteer voertuig voor werkorder",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Aanstaand",
       "failed": "Mislukt",
       "queued": "In wachtrij",
-      "busy": "Bezet"
+      "busy": "Bezet",
+      "closed": "Gesloten",
+      "away": "Afwezig"
     },
     "type": {
       "service": "Werkorder",
       "reminder": "Herinnering",
       "quote": "Offerte",
       "message": "Bericht",
-      "external": "Gekoppelde agenda"
+      "external": "Gekoppelde agenda",
+      "closure": "Gesloten",
+      "absence": "Verlof"
     },
     "noEvents": "Geen evenementen",
     "selectDay": "Selecteer een dag met evenementen of maak een nieuwe werkorder aan.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Vorige maand",
-  "nextMonth": "Volgende maand"
+  "nextMonth": "Volgende maand",
+  "range": {
+    "from": "Vanaf {time}",
+    "until": "Tot {time}"
+  },
+  "timeOffMissing": "Die afwezigheid is intussen gewijzigd of verwijderd."
 }

+ 7 - 0
messages/nl/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "Bezig",
       "completed": "Afgerond"
     }
+  },
+  "openingHours": {
+    "title": "Openingstijden",
+    "closed": "Gesloten",
+    "today": "Vandaag",
+    "closedSoon": "Binnenkort gesloten",
+    "openOnly": "Open {hours}"
   }
 }

+ 17 - 3
messages/nl/settings.json

@@ -79,6 +79,10 @@
         "title": "Werkplaats",
         "description": "Standaard technicus en arbeid"
       },
+      "openingHours": {
+        "title": "Openingstijden en feestdagen",
+        "description": "Tijden, feestdagen, sluitingsdagen, verlof"
+      },
       "laborPresets": {
         "title": "Arbeidspakketten",
         "description": "Voorgedefinieerde arbeidspakketten"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Toewijzen van werkorders mislukt",
     "noTechnician": "Geen",
     "weekStartDay": "Weekstart",
-    "workDayStart": "Werkdag begin",
-    "workDayEnd": "Werkdag einde",
     "titleTemplateTitle": "Titel van de werkorder",
     "titleTemplateDescription": "Hoe een nieuwe werkorder heet voordat iemand een titel typt. Stel hem samen uit de tags hieronder; elke klus opent met de tags ingevuld en de titel blijft op de klus te wijzigen.",
     "titleTemplate": "Titelsjabloon",
@@ -1249,7 +1251,9 @@
       "4": "Donderdag",
       "5": "Vrijdag",
       "6": "Zaterdag"
-    }
+    },
+    "openingHoursMoved": "Openingstijden stel je per dag van de week in, naast feestdagen, sluitingsdagen en verlof.",
+    "openingHoursLink": "Openingstijden en feestdagen openen"
   },
   "maintenance": {
     "title": "Voorspeld onderhoud",
@@ -2938,5 +2942,15 @@
       "cross": "Kruis",
       "hex": "Zeshoek"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Openingstijden",
+      "switch": "Sluitingsdagen en afwezigheid",
+      "publicHolidays": "Feestdagen",
+      "calendars": "Feestdagenagenda's",
+      "closedDays": "Sluitingsdagen",
+      "timeOff": "Verlof en afwezigheid"
+    }
   }
 }

+ 174 - 0
messages/nl/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Wanneer de werkplaats open is, op welke dagen ze gesloten is en wie er afwezig is. Het werkbord, de planning, de kalender, online boeken en het klantenportaal houden zich hieraan."
+  },
+  "switch": {
+    "title": "Sluitingsdagen en afwezigheid",
+    "label": "Sluitingsdagen, feestdagen en afwezigheid gebruiken",
+    "hint": "Als dit uit staat, heeft niets op deze pagina behalve de openingstijden invloed op het werkbord, de planning, de kalender of online boeken. Alles wat je hebt ingevoerd, blijft bewaard.",
+    "saved": "Opgeslagen",
+    "failed": "Opslaan mislukt"
+  },
+  "hours": {
+    "title": "Openingstijden",
+    "description": "Wanneer de werkplaats op elke dag van de week open is. Online boeken en het eerstvolgende vrije tijdslot bieden alleen deze tijden aan, en het werkbord arceert de rest.",
+    "open": "Open",
+    "closed": "Gesloten",
+    "opens": "Opent",
+    "closes": "Sluit",
+    "copyMonday": "Maandag naar de andere weekdagen kopiëren",
+    "save": "Openingstijden opslaan",
+    "saved": "Openingstijden opgeslagen",
+    "invalid": "{day}: de sluitingstijd moet na de openingstijd liggen",
+    "allClosed": "Elke dag is gesloten, dus er kan niets online worden geboekt."
+  },
+  "holidays": {
+    "title": "Feestdagen",
+    "description": "Kies je land, en je staat of regio waar feestdagen verschillen, om op de officiële feestdagen te sluiten. De datums komen van Nager.Date en worden elke dag bijgewerkt.",
+    "country": "Land",
+    "noCountry": "Geen feestdagen",
+    "searchCountry": "Landen zoeken",
+    "regions": "Staten en regio's",
+    "regionsHint": "Een feestdag die alleen in sommige regio's geldt, telt mee als een van jouw regio's is aangevinkt. Landelijke feestdagen tellen altijd mee.",
+    "countriesFailed": "Kan de lijst met landen niet laden ({error}). Probeer het later opnieuw.",
+    "list": "Feestdagen in {first} en {second}",
+    "closed": "Gesloten",
+    "open": "Open",
+    "regional": "Alleen in {regions}",
+    "notPublic": "Geen officiële feestdag, dus open tenzij je sluit",
+    "changed": "Gewijzigd ten opzichte van de standaard",
+    "reset": "Standaard gebruiken",
+    "refresh": "Nu bijwerken",
+    "updated": "Bijgewerkt {when}",
+    "neverUpdated": "Nog niet bijgewerkt",
+    "fetchError": "Kan de feestdagen niet bijwerken: {error}. De dagen die al in de lijst staan, tellen nog mee.",
+    "saved": "Feestdagen bijgewerkt",
+    "none": "Nager.Date heeft geen feestdagen voor dit land.",
+    "pickCountry": "Kies een land om de feestdagen te tonen.",
+    "source": "Datums van feestdagen via Nager.Date"
+  },
+  "feeds": {
+    "title": "Feestdagenagenda's",
+    "description": "Voeg elke feestdagenagenda toe als .ics-link of -bestand: een feed van de overheid, de feestdagenagenda's van Google of je eigen agenda. Een link wordt elke dag opnieuw gelezen.",
+    "addLink": "Link toevoegen",
+    "linkPlaceholder": "https://- of webcal://-link naar een .ics-agenda",
+    "uploadFile": ".ics-bestand uploaden",
+    "added": "Agenda toegevoegd",
+    "addFailed": "Kan de agenda niet toevoegen",
+    "tooLarge": "Het bestand is te groot. Feestdagenagenda's zijn veel kleiner dan 900 KB.",
+    "remove": "Verwijderen",
+    "removeTitle": "{name} verwijderen?",
+    "removeDescription": "De dagen in deze agenda sluiten de werkplaats niet meer. Je kunt hem later opnieuw toevoegen.",
+    "refresh": "Opnieuw lezen",
+    "refreshed": "Agenda opnieuw gelezen",
+    "fromLink": "Elke dag gelezen van {host}",
+    "fromFile": "Uit het bestand {file}",
+    "updated": "Gelezen {when}",
+    "error": "Laatste keer lezen mislukt: {error}",
+    "days": "{count, plural, one {# dag} other {# dagen}} in {first} en {second}",
+    "closeAll": "Op alle dagen sluiten",
+    "openAll": "Op alle dagen open",
+    "choose": "Elke dag sluit de werkplaats, tenzij je hem uitzet. Dagen die een Google-agenda als gedenkdag markeert, zoals Valentijnsdag, staan eerst uit.",
+    "show": "Dagen tonen",
+    "hide": "Dagen verbergen",
+    "empty": "Geen agenda's toegevoegd."
+  },
+  "closures": {
+    "title": "Sluitingsdagen",
+    "description": "Dagen waarop de werkplaats om eigen redenen gesloten is: een vakantieweek, een personeelsdag, eerder dicht voor een feestdag.",
+    "add": "Sluitingsdag toevoegen",
+    "edit": "Sluitingsdag bewerken",
+    "name": "Naam",
+    "namePlaceholder": "Zomersluiting",
+    "nameRequired": "Geef het een naam",
+    "yearly": "Elk jaar",
+    "yearlyHint": "Elk jaar op dezelfde datums gesloten.",
+    "empty": "Geen sluitingsdagen toegevoegd.",
+    "past": "Eerdere sluitingen tonen",
+    "hidePast": "Eerdere sluitingen verbergen",
+    "saved": "Sluitingsdag opgeslagen",
+    "deleted": "Sluitingsdag verwijderd",
+    "deleteTitle": "{name} verwijderen?",
+    "deleteDescription": "De werkplaats is op deze dagen weer gewoon open."
+  },
+  "stretch": {
+    "from": "Eerste dag",
+    "to": "Laatste dag",
+    "allDay": "Hele dag",
+    "startTime": "Vanaf",
+    "endTime": "Tot",
+    "startTimeHint": "Op de eerste dag. Laat leeg voor de hele dag.",
+    "endTimeHint": "Op de laatste dag. Laat leeg voor de rest van de dag.",
+    "endBeforeStart": "De laatste dag ligt voor de eerste",
+    "timesCross": "De eindtijd ligt voor de starttijd",
+    "tooLong": "Eén invoer kan hoogstens een jaar beslaan",
+    "pickDate": "Kies een datum"
+  },
+  "timeOff": {
+    "title": "Afwezigheid",
+    "description": "Zet een monteur op verlof of afwezig: vakantie, ziektedagen, cursussen. Tijdens de afwezigheid wordt de monteur niet aangeboden voor boekingen, en het werkbord en de planning laten het zien.",
+    "add": "Verlof of afwezigheid invoeren",
+    "edit": "Verlof of afwezigheid bewerken",
+    "technician": "Monteur",
+    "pickTechnician": "Kies een monteur",
+    "reason": "Reden",
+    "note": "Notitie",
+    "notePlaceholder": "Optioneel, zoals de naam van de cursus",
+    "empty": "Niemand heeft verlof of is afwezig in de komende weken.",
+    "saved": "Afwezigheid opgeslagen",
+    "deleted": "Afwezigheid verwijderd",
+    "deleteTitle": "Deze afwezigheid verwijderen?",
+    "deleteDescription": "{name} telt op deze dagen weer als beschikbaar.",
+    "noTechnicians": "Voeg eerst monteurs toe op het werkbord.",
+    "manage": "Verlof en afwezigheid",
+    "upcoming": "Komend en recent verlof",
+    "disabled": "Afwezigheid staat uit voor deze werkplaats. Zet het aan onder Instellingen, Openingstijden en feestdagen.",
+    "button": "Verlof",
+    "setForTechnician": "{name} op verlof of afwezig zetten"
+  },
+  "reasons": {
+    "holiday": "Vakantie",
+    "sick": "Ziek",
+    "training": "Cursus",
+    "other": "Afwezig"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, vanaf {time}",
+    "until": "{dates}, tot {time}",
+    "between": "{dates}, {start} tot {end}",
+    "fromUntil": "{dates}, vanaf {start} op de eerste dag tot {end} op de laatste"
+  },
+  "board": {
+    "closedDay": "Gesloten",
+    "closedFor": "Gesloten: {name}",
+    "shortDay": "Korte dag: {name}",
+    "closedWeekday": "De werkplaats is op deze dag gesloten",
+    "away": "{reason}",
+    "awayAllDay": "{name} is afwezig: {reason}",
+    "awayPart": "{name} is afwezig {when}: {reason}",
+    "awayTooltip": "Afwezig: {reason}",
+    "dropClosed": "De werkplaats is dan gesloten ({name}). De klus is toch ingepland.",
+    "dropClosedWeekday": "De werkplaats is dan normaal gesloten. De klus is toch ingepland.",
+    "dropAway": "{name} is dan afwezig ({reason}). De klus is toch ingepland."
+  },
+  "schedule": {
+    "closed": "De werkplaats is dan gesloten: {names}",
+    "closedWeekday": "De werkplaats is op deze dag normaal gesloten.",
+    "away": "Afwezig: {reason}",
+    "awayPart": "Afwezig {when}",
+    "awayConflict": "Afwezig ({reason})",
+    "awayHeading": "De monteur is dan afwezig",
+    "bookedAnyway": "Je kunt deze tijd toch houden als het werk is afgesproken."
+  },
+  "calendar": {
+    "closure": "Gesloten",
+    "absence": "Verlof",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "De werkplaats is op die dag gesloten ({name}). Kies een andere dag.",
+    "closedWeekday": "De werkplaats is op die dag gesloten. Kies een andere dag.",
+    "upcomingClosed": "Gesloten op: {days}"
+  }
+}

+ 18 - 5
messages/pl/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Otwórz przypomnienie",
       "quote": "Otwórz wycenę",
       "message": "Otwórz wiadomości",
-      "external": "Otwórz"
+      "external": "Otwórz",
+      "closure": "Otwórz godziny otwarcia",
+      "absence": "Otwórz tablicę zleceń"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Przypomnienia",
     "quotes": "Wyceny",
     "messages": "Wiadomości",
-    "external": "Połączone kalendarze"
+    "external": "Połączone kalendarze",
+    "closures": "Dni zamknięcia",
+    "absences": "Nieobecności"
   },
   "selectVehicle": "Wybierz pojazd do zlecenia",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Nadchodzące",
       "failed": "Nieudane",
       "queued": "W kolejce",
-      "busy": "Zajęte"
+      "busy": "Zajęte",
+      "closed": "Zamknięte",
+      "away": "Nieobecność"
     },
     "type": {
       "service": "Zlecenie",
       "reminder": "Przypomnienie",
       "quote": "Wycena",
       "message": "Wiadomość",
-      "external": "Połączony kalendarz"
+      "external": "Połączony kalendarz",
+      "closure": "Zamknięte",
+      "absence": "Nieobecność"
     },
     "noEvents": "Brak wydarzeń",
     "selectDay": "Wybierz dzień z wydarzeniami lub utwórz nowe zlecenie.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Poprzedni miesiąc",
-  "nextMonth": "Następny miesiąc"
+  "nextMonth": "Następny miesiąc",
+  "range": {
+    "from": "Od {time}",
+    "until": "Do {time}"
+  },
+  "timeOffMissing": "Ta nieobecność została w międzyczasie zmieniona lub usunięta."
 }

+ 7 - 0
messages/pl/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "W toku",
       "completed": "Zakończona"
     }
+  },
+  "openingHours": {
+    "title": "Godziny otwarcia",
+    "closed": "Zamknięte",
+    "today": "Dzisiaj",
+    "closedSoon": "Najbliższe dni zamknięcia",
+    "openOnly": "Otwarte {hours}"
   }
 }

+ 17 - 3
messages/pl/settings.json

@@ -79,6 +79,10 @@
         "title": "Warsztat",
         "description": "Domyślne ustawienia technika i robocizny"
       },
+      "openingHours": {
+        "title": "Godziny otwarcia i święta",
+        "description": "Godziny, święta, dni zamknięcia, urlopy"
+      },
       "laborPresets": {
         "title": "Pakiety robocizny",
         "description": "Predefiniowane pakiety pracy"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Nie udało się przypisać zleceń",
     "noTechnician": "Brak",
     "weekStartDay": "Poczatek tygodnia",
-    "workDayStart": "Poczatek dnia pracy",
-    "workDayEnd": "Koniec dnia pracy",
     "titleTemplateTitle": "Tytuł zlecenia",
     "titleTemplateDescription": "Jak nazywa się nowe zlecenie, zanim ktoś wpisze tytuł. Zbuduj go z poniższych tagów; każde zlecenie otwiera się z wypełnionymi tagami, a tytuł nadal można zmienić w zleceniu.",
     "titleTemplate": "Szablon tytułu",
@@ -1249,7 +1251,9 @@
       "4": "Czwartek",
       "5": "Piątek",
       "6": "Sobota"
-    }
+    },
+    "openingHoursMoved": "Godziny otwarcia ustawia się dla każdego dnia tygodnia, obok świąt, dni zamknięcia i urlopów.",
+    "openingHoursLink": "Otwórz godziny otwarcia i święta"
   },
   "maintenance": {
     "title": "Przewidywana konserwacja",
@@ -2938,5 +2942,15 @@
       "cross": "Krzyżyk",
       "hex": "Sześciokąt"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Godziny otwarcia",
+      "switch": "Dni zamknięcia i nieobecności",
+      "publicHolidays": "Święta",
+      "calendars": "Kalendarze świąt",
+      "closedDays": "Dni zamknięcia",
+      "timeOff": "Urlopy i nieobecności"
+    }
   }
 }

+ 174 - 0
messages/pl/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Kiedy warsztat jest otwarty, w które dni jest zamknięty i kogo nie ma. Tablica zleceń, planowanie, kalendarz, rezerwacja online i portal klienta się do tego stosują."
+  },
+  "switch": {
+    "title": "Dni zamknięcia i nieobecności",
+    "label": "Uwzględniaj dni zamknięcia, święta i nieobecności",
+    "hint": "Gdy to jest wyłączone, nic na tej stronie poza godzinami otwarcia nie wpływa na tablicę zleceń, planowanie, kalendarz ani rezerwację online. Wszystkie wpisy zostają zachowane.",
+    "saved": "Zapisano",
+    "failed": "Nie udało się zapisać"
+  },
+  "hours": {
+    "title": "Godziny otwarcia",
+    "description": "Kiedy warsztat jest otwarty w poszczególne dni tygodnia. Rezerwacja online i najbliższy wolny termin oferują tylko te godziny, a tablica zleceń zacienia resztę.",
+    "open": "Otwarte",
+    "closed": "Zamknięte",
+    "opens": "Otwarcie",
+    "closes": "Zamknięcie",
+    "copyMonday": "Skopiuj poniedziałek na pozostałe dni robocze",
+    "save": "Zapisz godziny otwarcia",
+    "saved": "Godziny otwarcia zapisane",
+    "invalid": "{day}: godzina zamknięcia musi być późniejsza niż godzina otwarcia",
+    "allClosed": "Wszystkie dni są zamknięte, więc nie można niczego zarezerwować online."
+  },
+  "holidays": {
+    "title": "Święta",
+    "description": "Wybierz kraj oraz stan lub region, jeśli święta się różnią, aby zamykać warsztat w dni ustawowo wolne. Daty pochodzą z Nager.Date i są aktualizowane codziennie.",
+    "country": "Kraj",
+    "noCountry": "Bez świąt",
+    "searchCountry": "Szukaj krajów",
+    "regions": "Stany i regiony",
+    "regionsHint": "Święto obchodzone tylko w niektórych regionach liczy się, gdy zaznaczono jeden z Twoich. Święta ogólnokrajowe liczą się zawsze.",
+    "countriesFailed": "Nie udało się wczytać listy krajów ({error}). Spróbuj ponownie później.",
+    "list": "Święta w latach {first} i {second}",
+    "closed": "Zamknięte",
+    "open": "Otwarte",
+    "regional": "Tylko w: {regions}",
+    "notPublic": "To nie jest dzień ustawowo wolny, więc otwarte, chyba że zamkniesz",
+    "changed": "Zmieniono względem domyślnego",
+    "reset": "Użyj domyślnego",
+    "refresh": "Aktualizuj teraz",
+    "updated": "Zaktualizowano {when}",
+    "neverUpdated": "Jeszcze nie zaktualizowano",
+    "fetchError": "Nie udało się zaktualizować świąt: {error}. Dni już na liście nadal obowiązują.",
+    "saved": "Święta zaktualizowane",
+    "none": "Nager.Date nie ma świąt dla tego kraju.",
+    "pickCountry": "Wybierz kraj, aby wyświetlić jego święta.",
+    "source": "Daty świąt z Nager.Date"
+  },
+  "feeds": {
+    "title": "Kalendarze świąt",
+    "description": "Dodaj dowolny kalendarz świąt jako link lub plik .ics: oficjalny kanał, kalendarze świąt Google lub własny. Link jest odczytywany ponownie codziennie.",
+    "addLink": "Dodaj link",
+    "linkPlaceholder": "Link https:// lub webcal:// do kalendarza .ics",
+    "uploadFile": "Prześlij plik .ics",
+    "added": "Dodano kalendarz",
+    "addFailed": "Nie udało się dodać kalendarza",
+    "tooLarge": "Plik jest za duży. Kalendarze świąt są znacznie mniejsze niż 900 KB.",
+    "remove": "Usuń",
+    "removeTitle": "Usunąć {name}?",
+    "removeDescription": "Jego dni przestaną zamykać warsztat. Możesz dodać go ponownie później.",
+    "refresh": "Odczytaj ponownie",
+    "refreshed": "Kalendarz odczytany ponownie",
+    "fromLink": "Odczytywany codziennie z {host}",
+    "fromFile": "Z pliku {file}",
+    "updated": "Odczytano {when}",
+    "error": "Ostatni odczyt nie powiódł się: {error}",
+    "days": "{count, plural, one {# dzień} few {# dni} many {# dni} other {# dnia}} w latach {first} i {second}",
+    "closeAll": "Zamknij we wszystkie dni",
+    "openAll": "Otwórz we wszystkie dni",
+    "choose": "Każdy dzień zamyka warsztat, chyba że go wyłączysz. Dni, które kalendarz Google oznacza jako święta okolicznościowe, na przykład walentynki, są na początku wyłączone.",
+    "show": "Pokaż dni",
+    "hide": "Ukryj dni",
+    "empty": "Nie dodano kalendarzy."
+  },
+  "closures": {
+    "title": "Dni zamknięcia",
+    "description": "Dni, w które warsztat jest zamknięty z własnych powodów: tydzień przerwy, dzień firmowy, wcześniejsze zamknięcie przed świętem.",
+    "add": "Dodaj dzień zamknięcia",
+    "edit": "Edytuj dzień zamknięcia",
+    "name": "Nazwa",
+    "namePlaceholder": "Przerwa wakacyjna",
+    "nameRequired": "Podaj nazwę",
+    "yearly": "Co roku",
+    "yearlyHint": "Zamknięte w te same dni każdego roku.",
+    "empty": "Nie dodano dni zamknięcia.",
+    "past": "Pokaż minione zamknięcia",
+    "hidePast": "Ukryj minione zamknięcia",
+    "saved": "Dzień zamknięcia zapisany",
+    "deleted": "Dzień zamknięcia usunięty",
+    "deleteTitle": "Usunąć {name}?",
+    "deleteDescription": "W te dni warsztat znów będzie otwarty jak zwykle."
+  },
+  "stretch": {
+    "from": "Pierwszy dzień",
+    "to": "Ostatni dzień",
+    "allDay": "Cały dzień",
+    "startTime": "Od",
+    "endTime": "Do",
+    "startTimeHint": "Pierwszego dnia. Zostaw puste, aby objąć cały dzień.",
+    "endTimeHint": "Ostatniego dnia. Zostaw puste, aby objąć resztę dnia.",
+    "endBeforeStart": "Ostatni dzień jest przed pierwszym",
+    "timesCross": "Godzina zakończenia jest przed godziną rozpoczęcia",
+    "tooLong": "Jeden wpis może obejmować najwyżej rok",
+    "pickDate": "Wybierz datę"
+  },
+  "timeOff": {
+    "title": "Nieobecności",
+    "description": "Oznacz urlop lub nieobecność technika: urlop, zwolnienie lekarskie, szkolenie. W tym czasie technik nie jest proponowany do rezerwacji, a tablica zleceń i planowanie to pokazują.",
+    "add": "Dodaj urlop lub nieobecność",
+    "edit": "Edytuj urlop lub nieobecność",
+    "technician": "Technik",
+    "pickTechnician": "Wybierz technika",
+    "reason": "Powód",
+    "note": "Notatka",
+    "notePlaceholder": "Opcjonalnie, np. nazwa szkolenia",
+    "empty": "W najbliższych tygodniach nie ma urlopów ani nieobecności.",
+    "saved": "Nieobecność zapisana",
+    "deleted": "Nieobecność usunięta",
+    "deleteTitle": "Usunąć tę nieobecność?",
+    "deleteDescription": "{name}: te dni znów liczą się jako dostępne.",
+    "noTechnicians": "Najpierw dodaj techników na tablicy zleceń.",
+    "manage": "Urlopy i nieobecności",
+    "upcoming": "Nadchodzące i niedawne nieobecności",
+    "disabled": "Nieobecności są wyłączone dla tego warsztatu. Włącz je w Ustawienia, Godziny otwarcia i święta.",
+    "button": "Urlop",
+    "setForTechnician": "Dodaj urlop lub nieobecność: {name}"
+  },
+  "reasons": {
+    "holiday": "Urlop",
+    "sick": "Zwolnienie lekarskie",
+    "training": "Szkolenie",
+    "other": "Poza firmą"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, od {time}",
+    "until": "{dates}, do {time}",
+    "between": "{dates}, od {start} do {end}",
+    "fromUntil": "{dates}, od {start} pierwszego dnia do {end} ostatniego"
+  },
+  "board": {
+    "closedDay": "Zamknięte",
+    "closedFor": "Zamknięte: {name}",
+    "shortDay": "Krótszy dzień: {name}",
+    "closedWeekday": "Warsztat jest w tym dniu zamknięty",
+    "away": "{reason}",
+    "awayAllDay": "{name}: nieobecność ({reason})",
+    "awayPart": "{name}: nieobecność {when} ({reason})",
+    "awayTooltip": "Nieobecność: {reason}",
+    "dropClosed": "Warsztat jest wtedy zamknięty ({name}). Zlecenie i tak zostało zaplanowane.",
+    "dropClosedWeekday": "Warsztat jest wtedy zwykle zamknięty. Zlecenie i tak zostało zaplanowane.",
+    "dropAway": "{name}: nieobecność w tym czasie ({reason}). Zlecenie i tak zostało zaplanowane."
+  },
+  "schedule": {
+    "closed": "Warsztat jest wtedy zamknięty: {names}",
+    "closedWeekday": "Warsztat jest zwykle zamknięty w tym dniu.",
+    "away": "Nieobecność: {reason}",
+    "awayPart": "Nieobecność {when}",
+    "awayConflict": "Nieobecność ({reason})",
+    "awayHeading": "Technik jest wtedy nieobecny",
+    "bookedAnyway": "Możesz zachować ten termin, jeśli praca jest uzgodniona."
+  },
+  "calendar": {
+    "closure": "Zamknięte",
+    "absence": "Nieobecność",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "Warsztat jest w tym dniu zamknięty ({name}). Wybierz inny dzień.",
+    "closedWeekday": "Warsztat jest w tym dniu zamknięty. Wybierz inny dzień.",
+    "upcomingClosed": "Zamknięte: {days}"
+  }
+}

+ 18 - 5
messages/pt-BR/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Abrir lembrete",
       "quote": "Abrir orçamento",
       "message": "Abrir mensagens",
-      "external": "Abrir"
+      "external": "Abrir",
+      "closure": "Abrir horário de funcionamento",
+      "absence": "Abrir o quadro de trabalho"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Lembretes",
     "quotes": "Orçamentos",
     "messages": "Mensagens",
-    "external": "Calendários conectados"
+    "external": "Calendários conectados",
+    "closures": "Dias fechados",
+    "absences": "Ausências"
   },
   "selectVehicle": "Selecionar veículo para ordem de serviço",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Próximo",
       "failed": "Falhou",
       "queued": "Na fila",
-      "busy": "Ocupado"
+      "busy": "Ocupado",
+      "closed": "Fechado",
+      "away": "Ausente"
     },
     "type": {
       "service": "Ordem de serviço",
       "reminder": "Lembrete",
       "quote": "Orçamento",
       "message": "Mensagem",
-      "external": "Calendário conectado"
+      "external": "Calendário conectado",
+      "closure": "Fechado",
+      "absence": "Ausência"
     },
     "noEvents": "Sem eventos",
     "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Mês anterior",
-  "nextMonth": "Próximo mês"
+  "nextMonth": "Próximo mês",
+  "range": {
+    "from": "A partir das {time}",
+    "until": "Até às {time}"
+  },
+  "timeOffMissing": "Essa ausência foi alterada ou removida nesse meio tempo."
 }

+ 7 - 0
messages/pt-BR/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "Em andamento",
       "completed": "Concluída"
     }
+  },
+  "openingHours": {
+    "title": "Horário de funcionamento",
+    "closed": "Fechado",
+    "today": "Hoje",
+    "closedSoon": "Fechamentos em breve",
+    "openOnly": "Aberto {hours}"
   }
 }

+ 17 - 3
messages/pt-BR/settings.json

@@ -79,6 +79,10 @@
         "title": "Oficina",
         "description": "Padrões de técnico e mão de obra"
       },
+      "openingHours": {
+        "title": "Horário e feriados",
+        "description": "Horários, feriados, dias fechados, férias"
+      },
       "laborPresets": {
         "title": "Pacotes de mão de obra",
         "description": "Pacotes de trabalho predefinidos"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Falha ao atribuir ordens de serviço",
     "noTechnician": "Nenhum",
     "weekStartDay": "Início da semana",
-    "workDayStart": "Início do expediente",
-    "workDayEnd": "Fim do expediente",
     "titleTemplateTitle": "Título da ordem de serviço",
     "titleTemplateDescription": "Como uma nova ordem de serviço se chama antes que alguém digite um título. Monte-o com as tags abaixo; cada serviço abre com as tags preenchidas e o título ainda pode ser alterado no serviço.",
     "titleTemplate": "Modelo do título",
@@ -1249,7 +1251,9 @@
       "4": "Quinta-feira",
       "5": "Sexta-feira",
       "6": "Sábado"
-    }
+    },
+    "openingHoursMoved": "O horário de funcionamento é definido para cada dia da semana, junto com feriados, dias fechados e férias.",
+    "openingHoursLink": "Abrir horário e feriados"
   },
   "maintenance": {
     "title": "Manutenção prevista",
@@ -2938,5 +2942,15 @@
       "cross": "Cruz",
       "hex": "Hexágono"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Horário de funcionamento",
+      "switch": "Dias fechados e ausências",
+      "publicHolidays": "Feriados",
+      "calendars": "Calendários de feriados",
+      "closedDays": "Dias fechados",
+      "timeOff": "Férias e ausências"
+    }
   }
 }

+ 174 - 0
messages/pt-BR/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Quando a oficina está aberta, os dias em que fica fechada e quem está ausente. O quadro de trabalho, a programação, o calendário, o agendamento online e o portal do cliente seguem essas informações."
+  },
+  "switch": {
+    "title": "Dias fechados e ausências",
+    "label": "Usar dias fechados, feriados e ausências",
+    "hint": "Quando isso está desligado, nada nesta página além do horário de funcionamento altera o quadro de trabalho, a programação, o calendário ou o agendamento online. Tudo o que você inseriu é mantido.",
+    "saved": "Salvo",
+    "failed": "Não foi possível salvar"
+  },
+  "hours": {
+    "title": "Horário de funcionamento",
+    "description": "Quando a oficina está aberta em cada dia da semana. O agendamento online e o próximo horário livre só oferecem esses horários, e o quadro de trabalho sombreia o restante.",
+    "open": "Aberto",
+    "closed": "Fechado",
+    "opens": "Abre",
+    "closes": "Fecha",
+    "copyMonday": "Copiar segunda-feira para os outros dias úteis",
+    "save": "Salvar horário de funcionamento",
+    "saved": "Horário de funcionamento salvo",
+    "invalid": "{day}: o horário de fechamento deve ser depois do de abertura",
+    "allClosed": "Todos os dias estão fechados, então nada pode ser agendado online."
+  },
+  "holidays": {
+    "title": "Feriados",
+    "description": "Escolha seu país, e seu estado ou região quando os feriados variam, para fechar nos feriados oficiais. As datas vêm do Nager.Date e são atualizadas todos os dias.",
+    "country": "País",
+    "noCountry": "Sem feriados",
+    "searchCountry": "Buscar países",
+    "regions": "Estados e regiões",
+    "regionsHint": "Um feriado observado só em algumas regiões conta quando uma das suas está marcada. Feriados nacionais sempre contam.",
+    "countriesFailed": "Não foi possível carregar a lista de países ({error}). Tente novamente mais tarde.",
+    "list": "Feriados em {first} e {second}",
+    "closed": "Fechado",
+    "open": "Aberto",
+    "regional": "Só em {regions}",
+    "notPublic": "Não é feriado oficial, então fica aberto, a menos que você feche",
+    "changed": "Alterado em relação ao padrão",
+    "reset": "Usar padrão",
+    "refresh": "Atualizar agora",
+    "updated": "Atualizado {when}",
+    "neverUpdated": "Ainda não atualizado",
+    "fetchError": "Não foi possível atualizar os feriados: {error}. Os dias já listados continuam valendo.",
+    "saved": "Feriados atualizados",
+    "none": "O Nager.Date não tem feriados para este país.",
+    "pickCountry": "Escolha um país para listar seus feriados.",
+    "source": "Datas de feriados do Nager.Date"
+  },
+  "feeds": {
+    "title": "Calendários de feriados",
+    "description": "Adicione qualquer calendário de feriados como link ou arquivo .ics: um feed oficial, os calendários de feriados do Google ou o seu próprio. Um link é lido novamente todos os dias.",
+    "addLink": "Adicionar link",
+    "linkPlaceholder": "Link https:// ou webcal:// para um calendário .ics",
+    "uploadFile": "Enviar arquivo .ics",
+    "added": "Calendário adicionado",
+    "addFailed": "Não foi possível adicionar o calendário",
+    "tooLarge": "O arquivo é grande demais. Calendários de feriados são bem menores que 900 KB.",
+    "remove": "Remover",
+    "removeTitle": "Remover {name}?",
+    "removeDescription": "Os dias dele deixam de fechar a oficina. Você pode adicioná-lo de novo depois.",
+    "refresh": "Ler novamente",
+    "refreshed": "Calendário lido novamente",
+    "fromLink": "Lido todos os dias de {host}",
+    "fromFile": "Do arquivo {file}",
+    "updated": "Lido {when}",
+    "error": "A última leitura falhou: {error}",
+    "days": "{count, plural, one {# dia} other {# dias}} em {first} e {second}",
+    "closeAll": "Fechar em todos os dias",
+    "openAll": "Abrir em todos os dias",
+    "choose": "Cada dia fecha a oficina, a menos que você o desative. Os dias que um calendário do Google marca como datas comemorativas, como o Dia dos Namorados, começam desativados.",
+    "show": "Mostrar dias",
+    "hide": "Ocultar dias",
+    "empty": "Nenhum calendário adicionado."
+  },
+  "closures": {
+    "title": "Dias fechados",
+    "description": "Dias em que a oficina fecha por motivos próprios: uma semana de férias coletivas, um dia da equipe, fechar mais cedo antes de um feriado.",
+    "add": "Adicionar dia fechado",
+    "edit": "Editar dia fechado",
+    "name": "Nome",
+    "namePlaceholder": "Férias coletivas",
+    "nameRequired": "Dê um nome",
+    "yearly": "Todo ano",
+    "yearlyHint": "Fechado nas mesmas datas todo ano.",
+    "empty": "Nenhum dia fechado adicionado.",
+    "past": "Mostrar fechamentos passados",
+    "hidePast": "Ocultar fechamentos passados",
+    "saved": "Dia fechado salvo",
+    "deleted": "Dia fechado removido",
+    "deleteTitle": "Remover {name}?",
+    "deleteDescription": "A oficina volta a abrir normalmente nesses dias."
+  },
+  "stretch": {
+    "from": "Primeiro dia",
+    "to": "Último dia",
+    "allDay": "O dia todo",
+    "startTime": "De",
+    "endTime": "Até",
+    "startTimeHint": "No primeiro dia. Deixe vazio para o dia todo.",
+    "endTimeHint": "No último dia. Deixe vazio para o resto do dia.",
+    "endBeforeStart": "O último dia é antes do primeiro",
+    "timesCross": "O horário de término é antes do de início",
+    "tooLong": "Um registro pode cobrir no máximo um ano",
+    "pickDate": "Escolha uma data"
+  },
+  "timeOff": {
+    "title": "Ausências",
+    "description": "Marque um técnico de férias ou ausente: férias, licença médica, cursos. Enquanto estiver fora, ele não é oferecido para agendamentos, e o quadro de trabalho e a programação mostram isso.",
+    "add": "Marcar férias ou ausência",
+    "edit": "Editar férias ou ausência",
+    "technician": "Técnico",
+    "pickTechnician": "Escolha um técnico",
+    "reason": "Motivo",
+    "note": "Observação",
+    "notePlaceholder": "Opcional, como o nome do curso",
+    "empty": "Ninguém está de férias ou ausente nas próximas semanas.",
+    "saved": "Ausência salva",
+    "deleted": "Ausência removida",
+    "deleteTitle": "Remover esta ausência?",
+    "deleteDescription": "{name} volta a contar como disponível nesses dias.",
+    "noTechnicians": "Adicione técnicos no quadro de trabalho primeiro.",
+    "manage": "Férias e ausências",
+    "upcoming": "Ausências próximas e recentes",
+    "disabled": "As ausências estão desligadas para esta oficina. Ligue-as em Configurações, Horário e feriados.",
+    "button": "Ausência",
+    "setForTechnician": "Marcar férias ou ausência de {name}"
+  },
+  "reasons": {
+    "holiday": "Férias",
+    "sick": "Licença médica",
+    "training": "Treinamento",
+    "other": "Ausente"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, a partir das {time}",
+    "until": "{dates}, até as {time}",
+    "between": "{dates}, das {start} às {end}",
+    "fromUntil": "{dates}, das {start} no primeiro dia até as {end} no último"
+  },
+  "board": {
+    "closedDay": "Fechado",
+    "closedFor": "Fechado: {name}",
+    "shortDay": "Expediente reduzido: {name}",
+    "closedWeekday": "A oficina fica fechada neste dia",
+    "away": "{reason}",
+    "awayAllDay": "{name} está ausente: {reason}",
+    "awayPart": "{name} está ausente {when}: {reason}",
+    "awayTooltip": "Ausente: {reason}",
+    "dropClosed": "A oficina está fechada nesse horário ({name}). O serviço foi agendado mesmo assim.",
+    "dropClosedWeekday": "A oficina normalmente está fechada nesse horário. O serviço foi agendado mesmo assim.",
+    "dropAway": "{name} está ausente nesse horário ({reason}). O serviço foi agendado mesmo assim."
+  },
+  "schedule": {
+    "closed": "A oficina está fechada nesse horário: {names}",
+    "closedWeekday": "A oficina normalmente fica fechada neste dia.",
+    "away": "Ausente: {reason}",
+    "awayPart": "Ausente {when}",
+    "awayConflict": "Ausente ({reason})",
+    "awayHeading": "O técnico está ausente nesse horário",
+    "bookedAnyway": "Você ainda pode manter este horário se o trabalho estiver combinado."
+  },
+  "calendar": {
+    "closure": "Fechado",
+    "absence": "Ausência",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "A oficina está fechada nesse dia ({name}). Escolha outro dia.",
+    "closedWeekday": "A oficina está fechada nesse dia. Escolha outro dia.",
+    "upcomingClosed": "Fechado em: {days}"
+  }
+}

+ 18 - 5
messages/pt-PT/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Abrir lembrete",
       "quote": "Abrir orçamento",
       "message": "Abrir mensagens",
-      "external": "Abrir"
+      "external": "Abrir",
+      "closure": "Abrir horário de funcionamento",
+      "absence": "Abrir o quadro de trabalho"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Lembretes",
     "quotes": "Orçamentos",
     "messages": "Mensagens",
-    "external": "Calendários ligados"
+    "external": "Calendários ligados",
+    "closures": "Dias de encerramento",
+    "absences": "Ausências"
   },
   "selectVehicle": "Selecionar viatura para a ordem de serviço",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Próximo",
       "failed": "Falhou",
       "queued": "Em fila",
-      "busy": "Ocupado"
+      "busy": "Ocupado",
+      "closed": "Encerrado",
+      "away": "Ausente"
     },
     "type": {
       "service": "Ordem de serviço",
       "reminder": "Lembrete",
       "quote": "Orçamento",
       "message": "Mensagem",
-      "external": "Calendário ligado"
+      "external": "Calendário ligado",
+      "closure": "Encerrado",
+      "absence": "Ausência"
     },
     "noEvents": "Sem eventos",
     "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Mês anterior",
-  "nextMonth": "Mês seguinte"
+  "nextMonth": "Mês seguinte",
+  "range": {
+    "from": "A partir das {time}",
+    "until": "Até às {time}"
+  },
+  "timeOffMissing": "Essa ausência foi alterada ou removida entretanto."
 }

+ 7 - 0
messages/pt-PT/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "Em curso",
       "completed": "Concluída"
     }
+  },
+  "openingHours": {
+    "title": "Horário de funcionamento",
+    "closed": "Fechado",
+    "today": "Hoje",
+    "closedSoon": "Encerramentos em breve",
+    "openOnly": "Aberto {hours}"
   }
 }

+ 17 - 3
messages/pt-PT/settings.json

@@ -79,6 +79,10 @@
         "title": "Oficina",
         "description": "Predefinições de técnicos e mão de obra"
       },
+      "openingHours": {
+        "title": "Horário e feriados",
+        "description": "Horas, feriados, dias de encerramento, férias"
+      },
       "laborPresets": {
         "title": "Modelos de mão de obra",
         "description": "Pacotes de mão de obra predefinidos"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Não foi possível atribuir as ordens de serviço",
     "noTechnician": "Nenhum",
     "weekStartDay": "Início da semana",
-    "workDayStart": "Início do dia de trabalho",
-    "workDayEnd": "Fim do dia de trabalho",
     "titleTemplateTitle": "Título da ordem de serviço",
     "titleTemplateDescription": "O nome de uma nova ordem de serviço antes de alguém escrever um título. Construa-o com as etiquetas abaixo; cada trabalho abre com elas preenchidas e o título pode continuar a ser alterado no trabalho.",
     "titleTemplate": "Modelo do título",
@@ -1249,7 +1251,9 @@
       "4": "Quinta-feira",
       "5": "Sexta-feira",
       "6": "Sábado"
-    }
+    },
+    "openingHoursMoved": "O horário de funcionamento é definido para cada dia da semana, junto dos feriados, dos dias de encerramento e das férias.",
+    "openingHoursLink": "Abrir horário e feriados"
   },
   "maintenance": {
     "title": "Manutenção prevista",
@@ -2938,5 +2942,15 @@
       "cross": "Cruz",
       "hex": "Hexágono"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Horário de funcionamento",
+      "switch": "Dias de encerramento e ausências",
+      "publicHolidays": "Feriados",
+      "calendars": "Calendários de feriados",
+      "closedDays": "Dias de encerramento",
+      "timeOff": "Férias e ausências"
+    }
   }
 }

+ 174 - 0
messages/pt-PT/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Quando a oficina está aberta, os dias em que está encerrada e quem está ausente. O quadro de trabalho, o planeamento, o calendário, as marcações online e o portal do cliente seguem estas definições."
+  },
+  "switch": {
+    "title": "Dias de encerramento e ausências",
+    "label": "Usar dias de encerramento, feriados e ausências",
+    "hint": "Quando isto está desligado, nada nesta página além do horário de funcionamento altera o quadro de trabalho, o planeamento, o calendário ou as marcações online. Tudo o que introduziu é mantido.",
+    "saved": "Guardado",
+    "failed": "Não foi possível guardar"
+  },
+  "hours": {
+    "title": "Horário de funcionamento",
+    "description": "Quando a oficina está aberta em cada dia da semana. As marcações online e a próxima vaga livre só oferecem estas horas, e o quadro de trabalho sombreia o resto.",
+    "open": "Aberto",
+    "closed": "Encerrado",
+    "opens": "Abre",
+    "closes": "Fecha",
+    "copyMonday": "Copiar segunda-feira para os outros dias úteis",
+    "save": "Guardar horário de funcionamento",
+    "saved": "Horário de funcionamento guardado",
+    "invalid": "{day}: a hora de fecho tem de ser posterior à hora de abertura",
+    "allClosed": "Todos os dias estão encerrados, por isso não é possível marcar nada online."
+  },
+  "holidays": {
+    "title": "Feriados",
+    "description": "Escolha o seu país, e o seu estado ou região quando os feriados variam, para encerrar nos feriados oficiais. As datas vêm do Nager.Date e são atualizadas todos os dias.",
+    "country": "País",
+    "noCountry": "Sem feriados",
+    "searchCountry": "Pesquisar países",
+    "regions": "Estados e regiões",
+    "regionsHint": "Um feriado observado apenas em algumas regiões conta quando uma das suas está assinalada. Os feriados nacionais contam sempre.",
+    "countriesFailed": "Não foi possível carregar a lista de países ({error}). Tente novamente mais tarde.",
+    "list": "Feriados em {first} e {second}",
+    "closed": "Encerrado",
+    "open": "Aberto",
+    "regional": "Apenas em {regions}",
+    "notPublic": "Não é feriado oficial, por isso fica aberto, a menos que encerre",
+    "changed": "Alterado em relação à predefinição",
+    "reset": "Usar predefinição",
+    "refresh": "Atualizar agora",
+    "updated": "Atualizado {when}",
+    "neverUpdated": "Ainda não atualizado",
+    "fetchError": "Não foi possível atualizar os feriados: {error}. Os dias já listados continuam a contar.",
+    "saved": "Feriados atualizados",
+    "none": "O Nager.Date não tem feriados para este país.",
+    "pickCountry": "Escolha um país para ver os seus feriados.",
+    "source": "Datas dos feriados do Nager.Date"
+  },
+  "feeds": {
+    "title": "Calendários de feriados",
+    "description": "Adicione qualquer calendário de feriados como ligação ou ficheiro .ics: um feed oficial, os calendários de feriados da Google ou o seu próprio. Uma ligação é lida novamente todos os dias.",
+    "addLink": "Adicionar ligação",
+    "linkPlaceholder": "Ligação https:// ou webcal:// para um calendário .ics",
+    "uploadFile": "Carregar ficheiro .ics",
+    "added": "Calendário adicionado",
+    "addFailed": "Não foi possível adicionar o calendário",
+    "tooLarge": "O ficheiro é demasiado grande. Os calendários de feriados são muito mais pequenos do que 900 KB.",
+    "remove": "Remover",
+    "removeTitle": "Remover {name}?",
+    "removeDescription": "Os dias deste calendário deixam de encerrar a oficina. Pode adicioná-lo novamente mais tarde.",
+    "refresh": "Ler novamente",
+    "refreshed": "Calendário lido novamente",
+    "fromLink": "Lido todos os dias a partir de {host}",
+    "fromFile": "Do ficheiro {file}",
+    "updated": "Lido {when}",
+    "error": "A última leitura falhou: {error}",
+    "days": "{count, plural, one {# dia} other {# dias}} em {first} e {second}",
+    "closeAll": "Encerrar em todos os dias",
+    "openAll": "Abrir em todos os dias",
+    "choose": "Cada dia fecha a oficina, a menos que o desative. Os dias que um calendário do Google marca como datas comemorativas, como o Dia dos Namorados, começam desativados.",
+    "show": "Mostrar dias",
+    "hide": "Ocultar dias",
+    "empty": "Nenhum calendário adicionado."
+  },
+  "closures": {
+    "title": "Dias de encerramento",
+    "description": "Dias em que a oficina encerra por motivos próprios: uma semana de férias coletivas, um dia da equipa, encerrar mais cedo antes de um feriado.",
+    "add": "Adicionar dia de encerramento",
+    "edit": "Editar dia de encerramento",
+    "name": "Nome",
+    "namePlaceholder": "Férias de verão",
+    "nameRequired": "Dê-lhe um nome",
+    "yearly": "Todos os anos",
+    "yearlyHint": "Encerrado nas mesmas datas todos os anos.",
+    "empty": "Nenhum dia de encerramento adicionado.",
+    "past": "Mostrar encerramentos passados",
+    "hidePast": "Ocultar encerramentos passados",
+    "saved": "Dia de encerramento guardado",
+    "deleted": "Dia de encerramento removido",
+    "deleteTitle": "Remover {name}?",
+    "deleteDescription": "A oficina volta a abrir normalmente nestes dias."
+  },
+  "stretch": {
+    "from": "Primeiro dia",
+    "to": "Último dia",
+    "allDay": "Todo o dia",
+    "startTime": "De",
+    "endTime": "Até",
+    "startTimeHint": "No primeiro dia. Deixe em branco para o dia inteiro.",
+    "endTimeHint": "No último dia. Deixe em branco para o resto do dia.",
+    "endBeforeStart": "O último dia é anterior ao primeiro",
+    "timesCross": "A hora de fim é anterior à hora de início",
+    "tooLong": "Um registo pode abranger no máximo um ano",
+    "pickDate": "Escolher uma data"
+  },
+  "timeOff": {
+    "title": "Ausências",
+    "description": "Registe um técnico de férias ou ausente: férias, baixa médica, formações. Enquanto está ausente, não é proposto para marcações, e o quadro de trabalho e o planeamento mostram-no.",
+    "add": "Registar férias ou ausência",
+    "edit": "Editar férias ou ausência",
+    "technician": "Técnico",
+    "pickTechnician": "Escolher um técnico",
+    "reason": "Motivo",
+    "note": "Nota",
+    "notePlaceholder": "Opcional, como o nome da formação",
+    "empty": "Ninguém está de férias ou ausente nas próximas semanas.",
+    "saved": "Ausência guardada",
+    "deleted": "Ausência removida",
+    "deleteTitle": "Remover esta ausência?",
+    "deleteDescription": "{name} volta a contar como disponível nestes dias.",
+    "noTechnicians": "Adicione primeiro técnicos no quadro de trabalho.",
+    "manage": "Férias e ausências",
+    "upcoming": "Ausências próximas e recentes",
+    "disabled": "As ausências estão desligadas para esta oficina. Ligue-as em Definições, Horário e feriados.",
+    "button": "Ausência",
+    "setForTechnician": "Registar férias ou ausência de {name}"
+  },
+  "reasons": {
+    "holiday": "Férias",
+    "sick": "Baixa médica",
+    "training": "Formação",
+    "other": "Ausente"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, a partir das {time}",
+    "until": "{dates}, até às {time}",
+    "between": "{dates}, das {start} às {end}",
+    "fromUntil": "{dates}, das {start} no primeiro dia até às {end} no último"
+  },
+  "board": {
+    "closedDay": "Encerrado",
+    "closedFor": "Encerrado: {name}",
+    "shortDay": "Horário reduzido: {name}",
+    "closedWeekday": "A oficina está encerrada neste dia",
+    "away": "{reason}",
+    "awayAllDay": "{name} está ausente: {reason}",
+    "awayPart": "{name} está ausente {when}: {reason}",
+    "awayTooltip": "Ausente: {reason}",
+    "dropClosed": "A oficina está encerrada nesse momento ({name}). O trabalho foi agendado na mesma.",
+    "dropClosedWeekday": "A oficina está normalmente encerrada nesse momento. O trabalho foi agendado na mesma.",
+    "dropAway": "{name} está ausente nesse momento ({reason}). O trabalho foi agendado na mesma."
+  },
+  "schedule": {
+    "closed": "A oficina está encerrada nesse momento: {names}",
+    "closedWeekday": "A oficina está normalmente encerrada neste dia.",
+    "away": "Ausente: {reason}",
+    "awayPart": "Ausente {when}",
+    "awayConflict": "Ausente ({reason})",
+    "awayHeading": "O técnico está ausente nessa altura",
+    "bookedAnyway": "Pode manter esta hora se o trabalho estiver combinado."
+  },
+  "calendar": {
+    "closure": "Encerrado",
+    "absence": "Ausência",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "A oficina está encerrada nesse dia ({name}). Escolha outro dia, por favor.",
+    "closedWeekday": "A oficina está encerrada nesse dia. Escolha outro dia, por favor.",
+    "upcomingClosed": "Encerrado em: {days}"
+  }
+}

+ 18 - 5
messages/ru/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Открыть напоминание",
       "quote": "Открыть смету",
       "message": "Открыть сообщения",
-      "external": "Открыть"
+      "external": "Открыть",
+      "closure": "Открыть часы работы",
+      "absence": "Открыть доску работ"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Напоминания",
     "quotes": "Предложения",
     "messages": "Сообщения",
-    "external": "Подключённые календари"
+    "external": "Подключённые календари",
+    "closures": "Нерабочие дни",
+    "absences": "Отсутствия"
   },
   "selectVehicle": "Выберите транспортное средство для заказ-наряда",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Предстоящее",
       "failed": "Ошибка",
       "queued": "В очереди",
-      "busy": "Занято"
+      "busy": "Занято",
+      "closed": "Закрыто",
+      "away": "Отсутствует"
     },
     "type": {
       "service": "Заказ-наряд",
       "reminder": "Напоминание",
       "quote": "Предложение",
       "message": "Сообщение",
-      "external": "Подключённый календарь"
+      "external": "Подключённый календарь",
+      "closure": "Закрыто",
+      "absence": "Отсутствие"
     },
     "noEvents": "Нет событий",
     "selectDay": "Выберите день с событиями или создайте новый заказ-наряд.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Предыдущий месяц",
-  "nextMonth": "Следующий месяц"
+  "nextMonth": "Следующий месяц",
+  "range": {
+    "from": "С {time}",
+    "until": "До {time}"
+  },
+  "timeOffMissing": "Это отсутствие тем временем изменили или удалили."
 }

+ 7 - 0
messages/ru/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "В работе",
       "completed": "Завершена"
     }
+  },
+  "openingHours": {
+    "title": "Часы работы",
+    "closed": "Закрыто",
+    "today": "Сегодня",
+    "closedSoon": "Скоро закрыто",
+    "openOnly": "Открыто {hours}"
   }
 }

+ 17 - 3
messages/ru/settings.json

@@ -79,6 +79,10 @@
         "title": "Мастерская",
         "description": "Механики и ставки по умолчанию"
       },
+      "openingHours": {
+        "title": "Часы работы и праздники",
+        "description": "Часы, праздники, нерабочие дни, отпуска"
+      },
       "laborPresets": {
         "title": "Пакеты работ",
         "description": "Предопределённые пакеты работ"
@@ -1220,8 +1224,6 @@
     "assignFailed": "Не удалось назначить заказы",
     "noTechnician": "Нет",
     "weekStartDay": "Первый день недели",
-    "workDayStart": "Начало рабочего дня",
-    "workDayEnd": "Конец рабочего дня",
     "titleTemplateTitle": "Название заказ-наряда",
     "titleTemplateDescription": "Как называется новый заказ-наряд, пока никто не ввёл название. Соберите его из тегов ниже; каждый заказ открывается с заполненными тегами, а название по-прежнему можно изменить в самом заказе.",
     "titleTemplate": "Шаблон названия",
@@ -1249,7 +1251,9 @@
       "4": "Четверг",
       "5": "Пятница",
       "6": "Суббота"
-    }
+    },
+    "openingHoursMoved": "Часы работы задаются для каждого дня недели, рядом с праздниками, нерабочими днями и отпусками.",
+    "openingHoursLink": "Открыть часы работы и праздники"
   },
   "maintenance": {
     "title": "Прогнозируемое ТО",
@@ -2938,5 +2942,15 @@
       "cross": "Крест",
       "hex": "Шестиугольник"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Часы работы",
+      "switch": "Нерабочие дни и отсутствия",
+      "publicHolidays": "Праздники",
+      "calendars": "Календари праздников",
+      "closedDays": "Нерабочие дни",
+      "timeOff": "Отпуска и отсутствия"
+    }
   }
 }

+ 174 - 0
messages/ru/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Когда мастерская открыта, в какие дни она закрыта и кто отсутствует. Доска работ, планирование, календарь, онлайн-запись и портал клиента учитывают это."
+  },
+  "switch": {
+    "title": "Нерабочие дни и отсутствия",
+    "label": "Учитывать нерабочие дни, праздники и отсутствия",
+    "hint": "Когда это выключено, ничто на этой странице, кроме часов работы, не влияет на доску работ, планирование, календарь и онлайн-запись. Всё, что вы ввели, сохраняется.",
+    "saved": "Сохранено",
+    "failed": "Не удалось сохранить"
+  },
+  "hours": {
+    "title": "Часы работы",
+    "description": "Когда мастерская открыта в каждый день недели. Онлайн-запись и ближайшее свободное время предлагают только эти часы, а на доске работ остальное время затенено.",
+    "open": "Открыто",
+    "closed": "Закрыто",
+    "opens": "Открывается",
+    "closes": "Закрывается",
+    "copyMonday": "Скопировать понедельник на остальные будни",
+    "save": "Сохранить часы работы",
+    "saved": "Часы работы сохранены",
+    "invalid": "{day}: время закрытия должно быть позже времени открытия",
+    "allClosed": "Все дни закрыты, поэтому записаться онлайн нельзя."
+  },
+  "holidays": {
+    "title": "Праздники",
+    "description": "Выберите страну, а также штат или регион, если праздники в них различаются, чтобы закрываться в официальные праздники. Даты берутся из Nager.Date и обновляются каждый день.",
+    "country": "Страна",
+    "noCountry": "Без праздников",
+    "searchCountry": "Поиск страны",
+    "regions": "Штаты и регионы",
+    "regionsHint": "Праздник, который отмечают только в некоторых регионах, учитывается, если отмечен один из ваших. Общенациональные праздники учитываются всегда.",
+    "countriesFailed": "Не удалось загрузить список стран ({error}). Попробуйте позже.",
+    "list": "Праздники в {first} и {second} гг.",
+    "closed": "Закрыто",
+    "open": "Открыто",
+    "regional": "Только: {regions}",
+    "notPublic": "Не официальный праздник, поэтому открыто, если вы не закроете",
+    "changed": "Изменено относительно стандартного",
+    "reset": "Вернуть стандартное",
+    "refresh": "Обновить сейчас",
+    "updated": "Обновлено {when}",
+    "neverUpdated": "Ещё не обновлялось",
+    "fetchError": "Не удалось обновить праздники: {error}. Уже указанные дни по-прежнему учитываются.",
+    "saved": "Праздники обновлены",
+    "none": "В Nager.Date нет праздников для этой страны.",
+    "pickCountry": "Выберите страну, чтобы увидеть её праздники.",
+    "source": "Даты праздников из Nager.Date"
+  },
+  "feeds": {
+    "title": "Календари праздников",
+    "description": "Добавьте любой календарь праздников как ссылку или файл .ics: официальный источник, календари праздников Google или свой собственный. Ссылка перечитывается каждый день.",
+    "addLink": "Добавить ссылку",
+    "linkPlaceholder": "Ссылка https:// или webcal:// на календарь .ics",
+    "uploadFile": "Загрузить файл .ics",
+    "added": "Календарь добавлен",
+    "addFailed": "Не удалось добавить календарь",
+    "tooLarge": "Файл слишком большой. Календари праздников намного меньше 900 КБ.",
+    "remove": "Удалить",
+    "removeTitle": "Удалить {name}?",
+    "removeDescription": "Его дни больше не закрывают мастерскую. Позже его можно добавить снова.",
+    "refresh": "Перечитать",
+    "refreshed": "Календарь перечитан",
+    "fromLink": "Читается каждый день с {host}",
+    "fromFile": "Из файла {file}",
+    "updated": "Прочитан {when}",
+    "error": "Последнее чтение не удалось: {error}",
+    "days": "{count, plural, one {# день} few {# дня} many {# дней} other {# дня}} в {first} и {second} гг.",
+    "closeAll": "Закрыть во все дни",
+    "openAll": "Открыть во все дни",
+    "choose": "Каждый день закрывает мастерскую, если вы его не отключите. Дни, которые календарь Google отмечает как памятные, например День святого Валентина, сразу отключены.",
+    "show": "Показать дни",
+    "hide": "Скрыть дни",
+    "empty": "Календари не добавлены."
+  },
+  "closures": {
+    "title": "Нерабочие дни",
+    "description": "Дни, когда мастерская закрыта по своим причинам: неделя летнего перерыва, день для персонала, сокращённый день перед праздником.",
+    "add": "Добавить нерабочий день",
+    "edit": "Изменить нерабочий день",
+    "name": "Название",
+    "namePlaceholder": "Летний перерыв",
+    "nameRequired": "Укажите название",
+    "yearly": "Каждый год",
+    "yearlyHint": "Закрыто в те же даты каждый год.",
+    "empty": "Нерабочие дни не добавлены.",
+    "past": "Показать прошедшие",
+    "hidePast": "Скрыть прошедшие",
+    "saved": "Нерабочий день сохранён",
+    "deleted": "Нерабочий день удалён",
+    "deleteTitle": "Удалить {name}?",
+    "deleteDescription": "В эти дни мастерская снова работает как обычно."
+  },
+  "stretch": {
+    "from": "Первый день",
+    "to": "Последний день",
+    "allDay": "Весь день",
+    "startTime": "С",
+    "endTime": "До",
+    "startTimeHint": "В первый день. Оставьте пустым, чтобы занять весь день.",
+    "endTimeHint": "В последний день. Оставьте пустым, чтобы занять остаток дня.",
+    "endBeforeStart": "Последний день раньше первого",
+    "timesCross": "Время окончания раньше времени начала",
+    "tooLong": "Одна запись может охватывать не больше года",
+    "pickDate": "Выберите дату"
+  },
+  "timeOff": {
+    "title": "Отсутствия",
+    "description": "Отметьте отпуск или отсутствие механика: отпуск, больничный, обучение. Пока механика нет, он не предлагается для записи, а доска работ и планирование это показывают.",
+    "add": "Отметить отпуск или отсутствие",
+    "edit": "Изменить отпуск или отсутствие",
+    "technician": "Механик",
+    "pickTechnician": "Выберите механика",
+    "reason": "Причина",
+    "note": "Примечание",
+    "notePlaceholder": "Необязательно, например название курса",
+    "empty": "В ближайшие недели никто не в отпуске и не отсутствует.",
+    "saved": "Отсутствие сохранено",
+    "deleted": "Отсутствие удалено",
+    "deleteTitle": "Удалить это отсутствие?",
+    "deleteDescription": "{name} снова считается доступным в эти дни.",
+    "noTechnicians": "Сначала добавьте механиков на доске работ.",
+    "manage": "Отпуска и отсутствия",
+    "upcoming": "Ближайшие и недавние отсутствия",
+    "disabled": "Отсутствия отключены для этой мастерской. Включите их в разделе Настройки, Часы работы и праздники.",
+    "button": "Отсутствие",
+    "setForTechnician": "Отметить отпуск или отсутствие: {name}"
+  },
+  "reasons": {
+    "holiday": "Отпуск",
+    "sick": "Больничный",
+    "training": "Обучение",
+    "other": "Нет на месте"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, с {time}",
+    "until": "{dates}, до {time}",
+    "between": "{dates}, с {start} до {end}",
+    "fromUntil": "{dates}, с {start} в первый день до {end} в последний"
+  },
+  "board": {
+    "closedDay": "Закрыто",
+    "closedFor": "Закрыто: {name}",
+    "shortDay": "Сокращённый день: {name}",
+    "closedWeekday": "В этот день мастерская закрыта",
+    "away": "{reason}",
+    "awayAllDay": "{name} отсутствует: {reason}",
+    "awayPart": "{name} отсутствует {when}: {reason}",
+    "awayTooltip": "Отсутствует: {reason}",
+    "dropClosed": "В это время мастерская закрыта ({name}). Работа всё равно запланирована.",
+    "dropClosedWeekday": "Обычно в это время мастерская закрыта. Работа всё равно запланирована.",
+    "dropAway": "{name} в это время отсутствует ({reason}). Работа всё равно запланирована."
+  },
+  "schedule": {
+    "closed": "В это время мастерская закрыта: {names}",
+    "closedWeekday": "Обычно в этот день мастерская закрыта.",
+    "away": "Отсутствует: {reason}",
+    "awayPart": "Отсутствует {when}",
+    "awayConflict": "Отсутствует ({reason})",
+    "awayHeading": "Техник в это время отсутствует",
+    "bookedAnyway": "Это время можно оставить, если работа согласована."
+  },
+  "calendar": {
+    "closure": "Закрыто",
+    "absence": "Отсутствие",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "В этот день мастерская закрыта ({name}). Пожалуйста, выберите другой день.",
+    "closedWeekday": "В этот день мастерская закрыта. Пожалуйста, выберите другой день.",
+    "upcomingClosed": "Закрыто: {days}"
+  }
+}

+ 18 - 5
messages/tr/calendar.json

@@ -68,7 +68,9 @@
       "reminder": "Hatırlatıcıyı aç",
       "quote": "Teklifi aç",
       "message": "Mesajları aç",
-      "external": "Aç"
+      "external": "Aç",
+      "closure": "Çalışma saatlerini aç",
+      "absence": "İş panosunu aç"
     }
   },
   "filters": {
@@ -76,7 +78,9 @@
     "reminders": "Hatırlatıcılar",
     "quotes": "Teklifler",
     "messages": "Mesajlar",
-    "external": "Bağlı takvimler"
+    "external": "Bağlı takvimler",
+    "closures": "Kapalı günler",
+    "absences": "İzinler"
   },
   "selectVehicle": "İş emri için araç seçin",
   "dateChoice": {
@@ -123,14 +127,18 @@
       "upcoming": "Yaklaşan",
       "failed": "Başarısız",
       "queued": "Sırada",
-      "busy": "Meşgul"
+      "busy": "Meşgul",
+      "closed": "Kapalı",
+      "away": "Müsait değil"
     },
     "type": {
       "service": "İş emri",
       "reminder": "Hatırlatıcı",
       "quote": "Teklif",
       "message": "Mesaj",
-      "external": "Bağlı takvim"
+      "external": "Bağlı takvim",
+      "closure": "Kapalı",
+      "absence": "İzin"
     },
     "noEvents": "Etkinlik yok",
     "selectDay": "Etkinliği olan bir gün seçin veya yeni bir iş emri oluşturun.",
@@ -145,5 +153,10 @@
     }
   },
   "previousMonth": "Önceki ay",
-  "nextMonth": "Sonraki ay"
+  "nextMonth": "Sonraki ay",
+  "range": {
+    "from": "{time} itibarıyla",
+    "until": "{time} saatine kadar"
+  },
+  "timeOffMissing": "Bu izin bu arada değiştirildi veya kaldırıldı."
 }

+ 7 - 0
messages/tr/portal.json

@@ -263,5 +263,12 @@
       "inProgress": "Devam ediyor",
       "completed": "Tamamlandı"
     }
+  },
+  "openingHours": {
+    "title": "Çalışma saatleri",
+    "closed": "Kapalı",
+    "today": "Bugün",
+    "closedSoon": "Yakında kapalı",
+    "openOnly": "Açık {hours}"
   }
 }

+ 17 - 3
messages/tr/settings.json

@@ -79,6 +79,10 @@
         "title": "Atölye",
         "description": "Teknisyen & işçilik varsayılanları"
       },
+      "openingHours": {
+        "title": "Çalışma saatleri ve tatiller",
+        "description": "Saatler, resmi tatiller, kapalı günler, izinler"
+      },
       "laborPresets": {
         "title": "İşçilik Paketleri",
         "description": "Önceden tanımlı işçilik paketleri"
@@ -1220,8 +1224,6 @@
     "assignFailed": "İş emirleri atanamadı",
     "noTechnician": "Yok",
     "weekStartDay": "Hafta Baslangici",
-    "workDayStart": "Is Gunu Baslangici",
-    "workDayEnd": "Is Gunu Bitisi",
     "titleTemplateTitle": "İş emri başlığı",
     "titleTemplateDescription": "Biri başlık yazmadan önce yeni bir iş emrinin adı. Aşağıdaki etiketlerden oluşturun; her iş etiketler doldurulmuş olarak açılır ve başlık iş üzerinde yine değiştirilebilir.",
     "titleTemplate": "Başlık şablonu",
@@ -1249,7 +1251,9 @@
       "4": "Persembe",
       "5": "Cuma",
       "6": "Cumartesi"
-    }
+    },
+    "openingHoursMoved": "Çalışma saatleri, resmi tatiller, kapalı günler ve izinlerle birlikte haftanın her günü için ayrı ayarlanır.",
+    "openingHoursLink": "Çalışma saatleri ve tatilleri aç"
   },
   "maintenance": {
     "title": "Öngörülen Bakım",
@@ -2938,5 +2942,15 @@
       "cross": "Çarpı",
       "hex": "Altıgen"
     }
+  },
+  "openingHours": {
+    "search": {
+      "hours": "Çalışma saatleri",
+      "switch": "Kapalı günler ve izinler",
+      "publicHolidays": "Resmi tatiller",
+      "calendars": "Tatil takvimleri",
+      "closedDays": "Kapalı günler",
+      "timeOff": "İzinler"
+    }
   }
 }

+ 174 - 0
messages/tr/workshopCalendar.json

@@ -0,0 +1,174 @@
+{
+  "page": {
+    "description": "Atölyenin ne zaman açık olduğu, hangi günler kapalı olduğu ve kimin müsait olmadığı. İş panosu, planlama, takvim, çevrimiçi randevu ve müşteri portalı buna göre çalışır."
+  },
+  "switch": {
+    "title": "Kapalı günler ve izinler",
+    "label": "Kapalı günleri, resmi tatilleri ve izinleri kullan",
+    "hint": "Bu kapalıyken, bu sayfada çalışma saatleri dışında hiçbir şey iş panosunu, planlamayı, takvimi veya çevrimiçi randevuyu etkilemez. Girdiğiniz her şey saklanır.",
+    "saved": "Kaydedildi",
+    "failed": "Kaydedilemedi"
+  },
+  "hours": {
+    "title": "Çalışma saatleri",
+    "description": "Atölyenin haftanın her günü ne zaman açık olduğu. Çevrimiçi randevu ve sonraki boş zaman yalnızca bu saatleri sunar, iş panosu ise geri kalanını gölgeler.",
+    "open": "Açık",
+    "closed": "Kapalı",
+    "opens": "Açılış",
+    "closes": "Kapanış",
+    "copyMonday": "Pazartesiyi diğer hafta içi günlere kopyala",
+    "save": "Çalışma saatlerini kaydet",
+    "saved": "Çalışma saatleri kaydedildi",
+    "invalid": "{day}: kapanış saati açılış saatinden sonra olmalıdır",
+    "allClosed": "Her gün kapalı, bu yüzden çevrimiçi randevu alınamaz."
+  },
+  "holidays": {
+    "title": "Resmi tatiller",
+    "description": "Resmi tatillerde kapalı olmak için ülkenizi ve tatillerin farklı olduğu yerlerde eyaletinizi veya bölgenizi seçin. Tarihler Nager.Date'ten gelir ve her gün güncellenir.",
+    "country": "Ülke",
+    "noCountry": "Resmi tatil yok",
+    "searchCountry": "Ülke ara",
+    "regions": "Eyaletler ve bölgeler",
+    "regionsHint": "Yalnızca bazı bölgelerde geçerli olan bir tatil, sizin bölgelerinizden biri işaretliyse sayılır. Ulusal tatiller her zaman sayılır.",
+    "countriesFailed": "Ülke listesi yüklenemedi ({error}). Daha sonra tekrar deneyin.",
+    "list": "{first} ve {second} yıllarındaki tatiller",
+    "closed": "Kapalı",
+    "open": "Açık",
+    "regional": "Yalnızca: {regions}",
+    "notPublic": "Resmi tatil değil, bu yüzden siz kapatmadıkça açık",
+    "changed": "Varsayılandan değiştirildi",
+    "reset": "Varsayılanı kullan",
+    "refresh": "Şimdi güncelle",
+    "updated": "{when} güncellendi",
+    "neverUpdated": "Henüz güncellenmedi",
+    "fetchError": "Tatiller güncellenemedi: {error}. Listede zaten olan günler geçerli olmaya devam eder.",
+    "saved": "Resmi tatiller güncellendi",
+    "none": "Nager.Date'te bu ülke için resmi tatil yok.",
+    "pickCountry": "Resmi tatillerini listelemek için bir ülke seçin.",
+    "source": "Tatil tarihleri Nager.Date'ten alınır"
+  },
+  "feeds": {
+    "title": "Tatil takvimleri",
+    "description": "Herhangi bir tatil takvimini .ics bağlantısı veya dosyası olarak ekleyin: resmi bir akış, Google'ın tatil takvimleri ya da kendi takviminiz. Bağlantı her gün yeniden okunur.",
+    "addLink": "Bağlantı ekle",
+    "linkPlaceholder": ".ics takvimine https:// veya webcal:// bağlantısı",
+    "uploadFile": ".ics dosyası yükle",
+    "added": "Takvim eklendi",
+    "addFailed": "Takvim eklenemedi",
+    "tooLarge": "Dosya çok büyük. Tatil takvimleri 900 KB'tan çok daha küçüktür.",
+    "remove": "Kaldır",
+    "removeTitle": "{name} kaldırılsın mı?",
+    "removeDescription": "Günleri artık atölyeyi kapatmaz. Daha sonra yeniden ekleyebilirsiniz.",
+    "refresh": "Yeniden oku",
+    "refreshed": "Takvim yeniden okundu",
+    "fromLink": "Her gün {host} adresinden okunur",
+    "fromFile": "{file} dosyasından",
+    "updated": "{when} okundu",
+    "error": "Son okuma başarısız: {error}",
+    "days": "{first} ve {second} yıllarında {count, plural, one {# gün} other {# gün}}",
+    "closeAll": "Tümünde kapalı",
+    "openAll": "Tümünde açık",
+    "choose": "Her gün, siz kapatmadıkça atölyeyi kapatır. Bir Google takviminin anma günü olarak işaretlediği günler, örneğin Sevgililer Günü, başta kapalı gelir.",
+    "show": "Günleri göster",
+    "hide": "Günleri gizle",
+    "empty": "Eklenmiş takvim yok."
+  },
+  "closures": {
+    "title": "Kapalı günler",
+    "description": "Atölyenin kendi nedenleriyle kapalı olduğu günler: bir kapanış haftası, personel günü, tatilden önce erken kapanış.",
+    "add": "Kapalı gün ekle",
+    "edit": "Kapalı günü düzenle",
+    "name": "Ad",
+    "namePlaceholder": "Yaz kapanışı",
+    "nameRequired": "Bir ad verin",
+    "yearly": "Her yıl",
+    "yearlyHint": "Her yıl aynı tarihlerde kapalı.",
+    "empty": "Eklenmiş kapalı gün yok.",
+    "past": "Geçmiş kapanışları göster",
+    "hidePast": "Geçmiş kapanışları gizle",
+    "saved": "Kapalı gün kaydedildi",
+    "deleted": "Kapalı gün kaldırıldı",
+    "deleteTitle": "{name} kaldırılsın mı?",
+    "deleteDescription": "Atölye bu günlerde yeniden her zamanki gibi açık olur."
+  },
+  "stretch": {
+    "from": "İlk gün",
+    "to": "Son gün",
+    "allDay": "Tüm gün",
+    "startTime": "Başlangıç",
+    "endTime": "Bitiş",
+    "startTimeHint": "İlk günde. Tüm gün için boş bırakın.",
+    "endTimeHint": "Son günde. Günün geri kalanı için boş bırakın.",
+    "endBeforeStart": "Son gün ilk günden önce",
+    "timesCross": "Bitiş saati başlangıç saatinden önce",
+    "tooLong": "Bir kayıt en fazla bir yılı kapsayabilir",
+    "pickDate": "Tarih seçin"
+  },
+  "timeOff": {
+    "title": "İzinler",
+    "description": "Bir teknisyeni izinli olarak işaretleyin: yıllık izin, hastalık izni, kurslar. Teknisyen yokken randevular için önerilmez, iş panosu ve planlama da bunu gösterir.",
+    "add": "İzin ekle",
+    "edit": "İzni düzenle",
+    "technician": "Teknisyen",
+    "pickTechnician": "Bir teknisyen seçin",
+    "reason": "Neden",
+    "note": "Not",
+    "notePlaceholder": "İsteğe bağlı, örneğin kursun adı",
+    "empty": "Önümüzdeki haftalarda izinli kimse yok.",
+    "saved": "İzin kaydedildi",
+    "deleted": "İzin kaldırıldı",
+    "deleteTitle": "Bu izin kaldırılsın mı?",
+    "deleteDescription": "{name} bu günlerde yeniden müsait sayılır.",
+    "noTechnicians": "Önce iş panosuna teknisyen ekleyin.",
+    "manage": "İzinler",
+    "upcoming": "Yaklaşan ve son izinler",
+    "disabled": "İzinler bu atölye için kapalı. Ayarlar, Çalışma saatleri ve tatiller altında açın.",
+    "button": "İzin",
+    "setForTechnician": "{name} için izin ekle"
+  },
+  "reasons": {
+    "holiday": "Yıllık izin",
+    "sick": "Hastalık izni",
+    "training": "Eğitim",
+    "other": "Dışarıda"
+  },
+  "range": {
+    "allDay": "{dates}",
+    "from": "{dates}, {time} itibarıyla",
+    "until": "{dates}, {time} saatine kadar",
+    "between": "{dates}, {start} ile {end} arası",
+    "fromUntil": "{dates}, ilk gün {start} saatinden son gün {end} saatine kadar"
+  },
+  "board": {
+    "closedDay": "Kapalı",
+    "closedFor": "Kapalı: {name}",
+    "shortDay": "Kısa gün: {name}",
+    "closedWeekday": "Atölye bu gün kapalı",
+    "away": "{reason}",
+    "awayAllDay": "{name} müsait değil: {reason}",
+    "awayPart": "{name} {when} müsait değil: {reason}",
+    "awayTooltip": "Müsait değil: {reason}",
+    "dropClosed": "Atölye o sırada kapalı ({name}). İş yine de planlandı.",
+    "dropClosedWeekday": "Atölye o sırada normalde kapalı. İş yine de planlandı.",
+    "dropAway": "{name} o sırada müsait değil ({reason}). İş yine de planlandı."
+  },
+  "schedule": {
+    "closed": "Atölye o sırada kapalı: {names}",
+    "closedWeekday": "Atölye bu gün normalde kapalı.",
+    "away": "Müsait değil: {reason}",
+    "awayPart": "{when} müsait değil",
+    "awayConflict": "Müsait değil ({reason})",
+    "awayHeading": "Teknisyen o sırada müsait değil",
+    "bookedAnyway": "İş üzerinde anlaşıldıysa bu saati yine de koruyabilirsiniz."
+  },
+  "calendar": {
+    "closure": "Kapalı",
+    "absence": "İzin",
+    "absenceTitle": "{name}: {reason}"
+  },
+  "portal": {
+    "closedDay": "Atölye o gün kapalı ({name}). Lütfen başka bir gün seçin.",
+    "closedWeekday": "Atölye o gün kapalı. Lütfen başka bir gün seçin.",
+    "upcomingClosed": "Kapalı günler: {days}"
+  }
+}

+ 50 - 0
prisma/migrations/20261109152957_workshop_closures/migration.sql

@@ -0,0 +1,50 @@
+-- CreateTable
+CREATE TABLE "workshop_closures" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "startDate" DATE NOT NULL,
+    "endDate" DATE NOT NULL,
+    "startTime" TEXT,
+    "endTime" TEXT,
+    "repeatsYearly" BOOLEAN NOT NULL DEFAULT false,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "workshop_closures_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "technician_absences" (
+    "id" TEXT NOT NULL,
+    "startDate" DATE NOT NULL,
+    "endDate" DATE NOT NULL,
+    "startTime" TEXT,
+    "endTime" TEXT,
+    "reason" TEXT NOT NULL DEFAULT 'holiday',
+    "note" TEXT,
+    "technicianId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "technician_absences_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "workshop_closures_organizationId_startDate_idx" ON "workshop_closures"("organizationId", "startDate");
+
+-- CreateIndex
+CREATE INDEX "technician_absences_organizationId_startDate_idx" ON "technician_absences"("organizationId", "startDate");
+
+-- CreateIndex
+CREATE INDEX "technician_absences_technicianId_idx" ON "technician_absences"("technicianId");
+
+-- AddForeignKey
+ALTER TABLE "workshop_closures" ADD CONSTRAINT "workshop_closures_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "technician_absences" ADD CONSTRAINT "technician_absences_technicianId_fkey" FOREIGN KEY ("technicianId") REFERENCES "technicians"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "technician_absences" ADD CONSTRAINT "technician_absences_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 60 - 0
prisma/schema/closures.prisma

@@ -0,0 +1,60 @@
+/// A day, or run of days, the whole workshop is closed, entered by hand: a
+/// shutdown week, a staff day, the afternoon of Christmas Eve. Public holidays
+/// from the workshop's country and holiday calendars are not stored here; they
+/// are read from settings and joined with these when a calendar is built.
+model WorkshopClosure {
+  id   String @id @default(cuid())
+  name String
+
+  /// First and last closed day, both included, as dates on the workshop's
+  /// own calendar. Kept as dates, not instants, so a time zone change cannot
+  /// move a holiday.
+  startDate DateTime @db.Date
+  endDate   DateTime @db.Date
+  /// "HH:mm" the closure begins on its first day. Null closes the whole day.
+  startTime String?
+  /// "HH:mm" the closure ends on its last day. Null keeps it closed to the
+  /// end of that day.
+  endTime   String?
+  /// Closed on the same dates every year (a national day, a staff holiday).
+  repeatsYearly Boolean @default(false)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([organizationId, startDate])
+  @@map("workshop_closures")
+}
+
+/// Time a technician is away: holiday, sick leave, a course. The work board,
+/// the scheduler and online booking read it so nobody is booked while away.
+model TechnicianAbsence {
+  id String @id @default(cuid())
+
+  /// First and last day away, both included, on the workshop's calendar.
+  startDate DateTime @db.Date
+  endDate   DateTime @db.Date
+  /// "HH:mm" the absence begins on its first day. Null is the whole day.
+  startTime String?
+  /// "HH:mm" it ends on its last day. Null is to the end of that day.
+  endTime   String?
+  /// holiday, sick, training or other.
+  reason    String  @default("holiday")
+  note      String?
+
+  technicianId String
+  technician   Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@index([organizationId, startDate])
+  @@index([technicianId])
+  @@map("technician_absences")
+}

+ 2 - 0
prisma/schema/organization.prisma

@@ -93,6 +93,8 @@ model Organization {
   documentDesignSnapshots     DocumentDesignSnapshot[]
   documentAssetSnapshots      DocumentAssetSnapshot[]
   backupRuns                  BackupRun[]
+  workshopClosures            WorkshopClosure[]
+  technicianAbsences          TechnicianAbsence[]
   platformBackup              PlatformBackup?
 
   @@map("organizations")

+ 1 - 0
prisma/schema/technicians.prisma

@@ -20,6 +20,7 @@ model Technician {
   inspections    Inspection[]
   statusReports  StatusReport[]
   timeEntries    TimeEntry[]
+  absences       TechnicianAbsence[]
 
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt

+ 95 - 0
src/__tests__/features/inspection-reminders/booking-window.test.ts

@@ -0,0 +1,95 @@
+/**
+ * How far ahead online booking reads the workshop.
+ *
+ * A workshop can offer bookings eight weeks out. Jobs, closed days and leave
+ * that far ahead have to be read too, or the page offers a closed day in
+ * week eight and the confirmation lets the customer have it.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    workBay: { findMany: vi.fn() },
+    technician: { findMany: vi.fn() },
+    serviceRecord: { findMany: vi.fn() },
+    inspection: { findMany: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+  },
+}))
+vi.mock('@/features/workshop-calendar/Lib/loadCalendar.server', async (importOriginal) => ({
+  ...(await importOriginal<
+    typeof import('@/features/workshop-calendar/Lib/loadCalendar.server')
+  >()),
+  loadWorkshopCalendar: vi.fn(),
+}))
+
+import { db } from '@/lib/db'
+import { loadWorkshopCalendar } from '@/features/workshop-calendar/Lib/loadCalendar.server'
+import { availabilityFor, claimableResource } from '@/features/inspection-reminders/Lib/booking'
+import type { InspectionReminderSettings } from '@/features/inspection-reminders/Lib/settings'
+import { defaultWeek } from '@/features/workshop-calendar/Lib/calendar'
+import { zonedDate } from '@/lib/timezone'
+
+const OSLO = 'Europe/Oslo'
+const settings: InspectionReminderSettings = {
+  durationMinutes: 60,
+  leadDays: 1,
+  horizonWeeks: 8,
+  walkInReserve: 0,
+  linkValidDays: 7,
+  bookingMode: 'direct',
+  phone: '',
+  workshopName: 'Egeland Auto',
+  templateSms: null,
+  templateEmailSubject: null,
+  templateEmailBody: null,
+  workingHours: { start: '08:00', end: '16:00', includeWeekends: false, timeZone: OSLO },
+  timeZone: OSLO,
+  timeZoneDetected: false,
+}
+// Monday 9 November 2026; the staff trip is a Wednesday in week eight.
+const now = zonedDate(2026, 11, 9, 10, 0, OSLO)
+const TRIP = '2026-12-30'
+const calendar = {
+  timeZone: OSLO,
+  week: defaultWeek('08:00', '16:00'),
+  closures: [{ name: 'Staff trip', source: 'closure' as const, startDate: TRIP, endDate: TRIP }],
+  absences: [],
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(db.workBay.findMany).mockResolvedValue([{ id: 'bay-1' }] as never)
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([])
+  vi.mocked(db.inspection.findMany).mockResolvedValue([])
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([
+    { key: 'workshop.timezone', value: OSLO },
+  ] as never)
+  vi.mocked(loadWorkshopCalendar).mockResolvedValue({ enabled: true, calendar })
+})
+
+describe('online booking eight weeks out', () => {
+  it('reads closed days and jobs through the whole horizon', async () => {
+    const { days } = await availabilityFor('org1', settings, now)
+    const [, range] = vi.mocked(loadWorkshopCalendar).mock.calls[0]
+    expect(range.to >= days.at(-1)?.date!).toBe(true)
+    const jobsUntil = vi.mocked(db.serviceRecord.findMany).mock.calls[0][0] as {
+      where: { AND: { startDateTime: { lt: Date } }[] }
+    }
+    expect(jobsUntil.where.AND[0].startDateTime.lt >= zonedDate(2026, 12, 31, 0, 0, OSLO)).toBe(
+      true
+    )
+    expect(days.find((d) => d.date === TRIP)).toMatchObject({
+      status: 'closed',
+      closedFor: 'Staff trip',
+    })
+  })
+
+  it('refuses to confirm a time on a closed day in week eight', async () => {
+    const start = zonedDate(2026, 12, 30, 9, 0, OSLO)
+    expect(await claimableResource('org1', settings, start, now)).toBeNull()
+    const [, range] = vi.mocked(loadWorkshopCalendar).mock.calls[0]
+    expect(range.from <= TRIP && range.to >= TRIP).toBe(true)
+  })
+})

+ 115 - 0
src/__tests__/features/portal/closed-days.test.ts

@@ -0,0 +1,115 @@
+/**
+ * A customer asking for service on a day the workshop is closed.
+ *
+ * The request is refused with the reason, before a vehicle or a request is
+ * created, and the form can list the closed days ahead so they are not picked.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    vehicle: { findFirst: vi.fn(), create: vi.fn() },
+    customer: { findUnique: vi.fn() },
+    serviceRequest: { create: vi.fn() },
+  },
+}))
+vi.mock('@/lib/customer-session', () => ({
+  getCustomerSession: vi.fn(async () => ({ customerId: 'c1', organizationId: 'org1' })),
+}))
+vi.mock('@/lib/workshop-timezone', () => ({
+  workshopTimeZone: vi.fn(async () => 'Australia/Sydney'),
+}))
+vi.mock('@/features/workshop-calendar/Lib/loadCalendar.server', () => ({
+  loadWorkshopCalendar: vi.fn(),
+}))
+// The server's translations, from the real English messages.
+vi.mock('next-intl/server', async () => {
+  const messages = (await import('../../../../messages/en/workshopCalendar.json')).default
+  return {
+    getTranslations:
+      async (namespace: string) => (key: string, values?: Record<string, string>) => {
+        const path = [...namespace.split('.').slice(1), ...key.split('.')]
+        let text: unknown = messages
+        for (const part of path) text = (text as Record<string, unknown>)[part]
+        return String(text).replace(/\{(\w+)\}/g, (_, name: string) => values?.[name] ?? '')
+      },
+  }
+})
+
+import { db } from '@/lib/db'
+import { loadWorkshopCalendar } from '@/features/workshop-calendar/Lib/loadCalendar.server'
+import { defaultWeek } from '@/features/workshop-calendar/Lib/calendar'
+import { createServiceRequest } from '@/features/portal/Actions/portalActions'
+
+const calendar = {
+  timeZone: 'Australia/Sydney',
+  week: defaultWeek('08:00', '17:00'),
+  closures: [
+    {
+      name: 'Australia Day',
+      source: 'holiday' as const,
+      startDate: '2027-01-26',
+      endDate: '2027-01-26',
+    },
+  ],
+  absences: [],
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(loadWorkshopCalendar).mockResolvedValue({ enabled: true, calendar })
+})
+
+describe('asking for service on a closed day', () => {
+  it('is refused with the holiday named, before anything is created', async () => {
+    const res = await createServiceRequest({
+      vehicleId: 'v1',
+      description: 'Brakes squeal',
+      preferredDate: '2027-01-26',
+    })
+    expect(res).toEqual({
+      success: false,
+      error: 'The workshop is closed on that day (Australia Day). Please pick another day.',
+    })
+    expect(db.vehicle.findFirst).not.toHaveBeenCalled()
+    expect(db.serviceRequest.create).not.toHaveBeenCalled()
+  })
+
+  it('is refused when the closed day comes with a time of day', async () => {
+    const res = await createServiceRequest({
+      vehicleId: 'v1',
+      description: 'Brakes squeal',
+      preferredDate: '2027-01-26T12:00',
+    })
+    expect(res).toEqual({
+      success: false,
+      error: 'The workshop is closed on that day (Australia Day). Please pick another day.',
+    })
+    expect(db.serviceRequest.create).not.toHaveBeenCalled()
+  })
+
+  it('is refused on a weekday the workshop never opens', async () => {
+    const res = await createServiceRequest({
+      vehicleId: 'v1',
+      description: 'Service',
+      preferredDate: '2027-01-24', // a Sunday
+    })
+    expect(res).toEqual({
+      success: false,
+      error: 'The workshop is closed on that day. Please pick another day.',
+    })
+  })
+
+  it('goes through on an open day', async () => {
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(null)
+    const res = await createServiceRequest({
+      vehicleId: 'v1',
+      description: 'Service',
+      preferredDate: '2027-01-25',
+    })
+    // Past the closed-day check, the usual vehicle check answers.
+    expect(res).toEqual({ success: false, error: 'Vehicle not found' })
+    expect(db.vehicle.findFirst).toHaveBeenCalled()
+  })
+})

+ 117 - 0
src/__tests__/features/portal/opening-hours.test.tsx

@@ -0,0 +1,117 @@
+/**
+ * Opening hours on the customer portal: the week, and the days soon the
+ * workshop is shut or closes early, so a customer can plan a visit.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, within } from '@testing-library/react'
+
+vi.mock('@/lib/db', () => ({
+  db: { appSetting: { findFirst: vi.fn() } },
+}))
+vi.mock('@/lib/customer-session', () => ({
+  getCustomerSession: vi.fn(async () => ({ customerId: 'c1', organizationId: 'org1' })),
+}))
+vi.mock('@/lib/workshop-timezone', () => ({
+  workshopTimeZone: vi.fn(async () => 'Australia/Sydney'),
+}))
+vi.mock('@/features/workshop-calendar/Lib/loadCalendar.server', () => ({
+  loadWorkshopCalendar: vi.fn(),
+}))
+
+import { db } from '@/lib/db'
+import { loadWorkshopCalendar } from '@/features/workshop-calendar/Lib/loadCalendar.server'
+import { defaultWeek } from '@/features/workshop-calendar/Lib/calendar'
+import {
+  getPortalOpeningHours,
+  type PortalOpeningHours as Hours,
+} from '@/features/portal/Actions/portalActions'
+import { PortalOpeningHours } from '@/features/portal/Components/PortalOpeningHours'
+
+const holiday = (name: string, date: string, startTime?: string) => ({
+  name,
+  source: 'holiday' as const,
+  startDate: date,
+  endDate: date,
+  ...(startTime ? { startTime } : {}),
+})
+
+describe('what the portal is told', () => {
+  beforeEach(() => {
+    vi.useFakeTimers({ toFake: ['Date'] })
+    // Monday 21 December 2026, mid-morning in Sydney.
+    vi.setSystemTime(new Date('2026-12-20T23:00:00Z'))
+    vi.mocked(db.appSetting.findFirst).mockResolvedValue({ value: '24h' } as never)
+    vi.mocked(loadWorkshopCalendar).mockResolvedValue({
+      enabled: true,
+      calendar: {
+        timeZone: 'Australia/Sydney',
+        week: defaultWeek('08:00', '17:00'),
+        closures: [
+          holiday('Christmas Eve', '2026-12-24', '12:00'),
+          holiday('Christmas Day', '2026-12-25'),
+          // A Saturday and a Sunday: shut anyway, so nothing to tell anyone.
+          holiday('Boxing Day', '2026-12-26'),
+          holiday('Sunday market', '2026-12-27'),
+          holiday('Boxing Day (observed)', '2026-12-28'),
+        ],
+        absences: [],
+      },
+    })
+  })
+
+  it('sends the week, the workshop clock and the days it shuts or closes early', async () => {
+    const res = await getPortalOpeningHours()
+    expect(res.success).toBe(true)
+    const hours = res.data as Hours
+    expect(hours.today).toBe('2026-12-21')
+    expect(hours.timeFormat).toBe('24h')
+    expect(hours.week[1]).toEqual({ open: true, start: '08:00', end: '17:00' })
+    expect(hours.closedSoon).toEqual([
+      { date: '2026-12-24', name: 'Christmas Eve', open: [{ start: '08:00', end: '12:00' }] },
+      { date: '2026-12-25', name: 'Christmas Day', open: [] },
+      { date: '2026-12-28', name: 'Boxing Day (observed)', open: [] },
+    ])
+  })
+})
+
+describe('the opening hours card', () => {
+  const hours: Hours = {
+    week: defaultWeek('08:00', '17:00'),
+    today: '2026-12-22',
+    timeFormat: '12h',
+    closedSoon: [
+      { date: '2026-12-24', name: 'Christmas Eve', open: [{ start: '08:00', end: '12:00' }] },
+      { date: '2026-12-25', name: 'Christmas Day', open: [] },
+    ],
+  }
+
+  it('lists the week from Monday, marks today and says which days are closed', () => {
+    render(<PortalOpeningHours hours={hours} />)
+    const days = within(screen.getByTestId('portal-opening-hours')).getAllByRole('listitem')
+    expect(days.map((d) => d.textContent)).toEqual([
+      'Monday8 AM – 5 PM',
+      'Tuesday (Today)8 AM – 5 PM',
+      'Wednesday8 AM – 5 PM',
+      'Thursday8 AM – 5 PM',
+      'Friday8 AM – 5 PM',
+      'SaturdayClosed',
+      'SundayClosed',
+    ])
+    expect(days[1]).toHaveAttribute('aria-current', 'date')
+  })
+
+  it('names the days soon it is shut, and the hours of a short day', () => {
+    render(<PortalOpeningHours hours={hours} />)
+    const soon = within(screen.getByTestId('portal-closed-soon')).getAllByRole('listitem')
+    expect(soon.map((d) => d.textContent)).toEqual([
+      'Thu, Dec 24 · Christmas EveOpen 8 AM – 12 PM',
+      'Fri, Dec 25 · Christmas DayClosed',
+    ])
+  })
+
+  it('leaves the closed-soon list out when nothing is coming up', () => {
+    render(<PortalOpeningHours hours={{ ...hours, closedSoon: [] }} />)
+    expect(screen.queryByTestId('portal-closed-soon')).not.toBeInTheDocument()
+  })
+})

+ 5 - 0
src/__tests__/features/workorders/create-workorder.test.ts

@@ -48,6 +48,9 @@ vi.mock('@/lib/db', () => ({
     serviceRecord: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
     inspection: { findMany: vi.fn() },
     technician: { findFirst: vi.fn() },
+    // The workshop's closed days and time off, read with what is booked.
+    workshopClosure: { findMany: vi.fn() },
+    technicianAbsence: { findMany: vi.fn() },
     $transaction: vi.fn(),
   },
 }))
@@ -92,6 +95,8 @@ beforeEach(() => {
   // to read and every other test keeps asserting what it always did.
   vi.mocked(db.serviceRecord.findMany).mockResolvedValue([])
   vi.mocked(db.inspection.findMany).mockResolvedValue([])
+  vi.mocked(db.workshopClosure.findMany).mockResolvedValue([])
+  vi.mocked(db.technicianAbsence.findMany).mockResolvedValue([])
 })
 
 // ---------------------------------------------------------------------------

+ 407 - 0
src/__tests__/features/workshop-calendar/actions.test.ts

@@ -0,0 +1,407 @@
+/**
+ * Saving the workshop calendar: time off, closed days, opening hours, public
+ * holidays and holiday calendars.
+ *
+ * The parts that would hurt if they broke quietly: time off for another
+ * workshop's technician, a holiday fetch that fails and wipes the days
+ * already known, a choice that comes back after it was switched off.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn() }))
+vi.mock('@/features/workshop-calendar/Lib/icsFetch.server', () => ({
+  fetchIcs: vi.fn(),
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    auditLog: { create: vi.fn() },
+    organizationMember: { findFirst: vi.fn() },
+    appSetting: { findMany: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() },
+    $transaction: vi.fn(),
+    $executeRaw: vi.fn(),
+    technician: { findFirst: vi.fn() },
+    technicianAbsence: {
+      create: vi.fn(),
+      updateMany: vi.fn(),
+      deleteMany: vi.fn(),
+      findMany: vi.fn(),
+    },
+    workshopClosure: {
+      create: vi.fn(),
+      updateMany: vi.fn(),
+      deleteMany: vi.fn(),
+      findMany: vi.fn(),
+    },
+  },
+}))
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { fetchIcs } from '@/features/workshop-calendar/Lib/icsFetch.server'
+import { refreshLinkedFeeds } from '@/features/workshop-calendar/Lib/feedSync.server'
+import { createAbsence } from '@/features/workshop-calendar/Actions/absenceActions'
+import { createClosure } from '@/features/workshop-calendar/Actions/closureActions'
+import { getWorkshopCalendar } from '@/features/workshop-calendar/Actions/calendarActions'
+import { canEditTimeOff } from '@/features/workshop-calendar/Lib/access.server'
+import {
+  addHolidayFeed,
+  refreshHolidayFeed,
+  removeHolidayFeed,
+  saveHolidayCountry,
+  setHolidayFeedChoice,
+  saveOpeningHours,
+  setHolidayChoice,
+} from '@/features/workshop-calendar/Actions/holidayActions'
+import { syncPublicHolidays } from '@/features/workshop-calendar/Lib/holidaySync.server'
+import { defaultWeek } from '@/features/workshop-calendar/Lib/calendar'
+
+const ORG = 'org-a'
+let settings: Map<string, string>
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-a', email: 'a@x.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  // Settings live in a map the mocked table reads from and writes to.
+  settings = new Map()
+  vi.mocked(db.appSetting.findMany).mockImplementation((async (args: {
+    where: { key: { in: string[] } }
+  }) =>
+    args.where.key.in
+      .filter((k) => settings.has(k))
+      .map((k) => ({ key: k, value: settings.get(k) }))) as never)
+  vi.mocked(db.appSetting.findUnique).mockImplementation((async (args: {
+    where: { organizationId_key: { key: string } }
+  }) => {
+    const key = args.where.organizationId_key.key
+    return settings.has(key) ? { value: settings.get(key) } : null
+  }) as never)
+  // The lock is Postgres's; here a transaction just runs.
+  vi.mocked(db.$transaction).mockImplementation((async (fn: (tx: unknown) => unknown) =>
+    fn(db)) as never)
+  vi.mocked(db.appSetting.upsert).mockImplementation((async (args: {
+    where: { organizationId_key: { key: string } }
+    update: { value: string }
+  }) => {
+    settings.set(args.where.organizationId_key.key, args.update.value)
+    return {}
+  }) as never)
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+describe('time off', () => {
+  it('refuses a technician from another workshop', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue(null)
+    const res = await createAbsence({
+      technicianId: 'tech-b',
+      startDate: '2026-11-16',
+      endDate: '2026-11-20',
+      reason: 'holiday',
+    })
+    expect(res.success).toBe(false)
+    expect(db.technicianAbsence.create).not.toHaveBeenCalled()
+    expect(vi.mocked(db.technician.findFirst).mock.calls[0][0]?.where).toEqual({
+      id: 'tech-b',
+      organizationId: ORG,
+    })
+  })
+
+  it('saves whole days without times, and part of a day with them', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue({ id: 'tech-a' } as never)
+    vi.mocked(db.technicianAbsence.create).mockResolvedValue({
+      id: 'a1',
+      technicianId: 'tech-a',
+      startDate: new Date('2026-11-16T00:00:00Z'),
+      endDate: new Date('2026-11-20T00:00:00Z'),
+      startTime: null,
+      endTime: null,
+      reason: 'holiday',
+      note: null,
+      technician: { name: 'Kari', color: '#000' },
+    } as never)
+
+    const res = await createAbsence({
+      technicianId: 'tech-a',
+      startDate: '2026-11-16',
+      endDate: '2026-11-20',
+      startTime: '',
+      endTime: null,
+      reason: 'holiday',
+      note: '  ',
+    })
+    expect(res.success).toBe(true)
+    const data = vi.mocked(db.technicianAbsence.create).mock.calls[0][0].data
+    expect(data).toMatchObject({
+      organizationId: ORG,
+      startDate: new Date('2026-11-16T00:00:00Z'),
+      endDate: new Date('2026-11-20T00:00:00Z'),
+      startTime: null,
+      endTime: null,
+      note: null,
+    })
+    expect(res.data).toMatchObject({ startDate: '2026-11-16', technicianName: 'Kari' })
+  })
+
+  it('refuses a stretch that ends before it starts or crosses its own times', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue({ id: 'tech-a' } as never)
+    const backwards = await createAbsence({
+      technicianId: 'tech-a',
+      startDate: '2026-11-20',
+      endDate: '2026-11-16',
+    })
+    const crossed = await createAbsence({
+      technicianId: 'tech-a',
+      startDate: '2026-11-16',
+      endDate: '2026-11-16',
+      startTime: '15:00',
+      endTime: '13:00',
+    })
+    expect(backwards.success).toBe(false)
+    expect(crossed.success).toBe(false)
+    expect(db.technicianAbsence.create).not.toHaveBeenCalled()
+  })
+})
+
+describe('who may see and set leave', () => {
+  const member = (customRole: unknown) =>
+    vi.mocked(getCachedMembership).mockResolvedValue({
+      organizationId: ORG,
+      role: 'member',
+      roleId: customRole ? 'role-1' : null,
+      customRole,
+    } as never)
+  const board = (action: string) => ({ action, subject: 'work_board' })
+
+  it('keeps the board calendar, with its leave notes, from a role without the board', async () => {
+    member({ isAdmin: false, permissions: [{ action: 'read', subject: 'customers' }] })
+    const res = await getWorkshopCalendar({ from: '2026-11-16', to: '2026-11-22' })
+    expect(res).toMatchObject({ success: false, forbidden: true })
+    expect(db.technicianAbsence.findMany).not.toHaveBeenCalled()
+  })
+
+  it('offers the leave buttons exactly where the actions accept them', async () => {
+    member({ isAdmin: true, permissions: [] })
+    expect(await canEditTimeOff('user-a', 'member')).toBe(true)
+
+    member({ isAdmin: false, permissions: [board('update')] })
+    expect(await canEditTimeOff('user-a', 'member')).toBe(true)
+
+    member({ isAdmin: false, permissions: [board('read')] })
+    expect(await canEditTimeOff('user-a', 'member')).toBe(false)
+
+    // A member with no role has no permissions, in withAuth and here.
+    member(null)
+    expect(await canEditTimeOff('user-a', 'member')).toBe(false)
+  })
+})
+
+describe('closed days', () => {
+  it('saves a closure for this workshop only', async () => {
+    vi.mocked(db.workshopClosure.create).mockResolvedValue({
+      id: 'c1',
+      name: 'Summer shutdown',
+      startDate: new Date('2027-07-05T00:00:00Z'),
+      endDate: new Date('2027-07-23T00:00:00Z'),
+      startTime: null,
+      endTime: null,
+      repeatsYearly: true,
+    } as never)
+    const res = await createClosure({
+      name: ' Summer shutdown ',
+      startDate: '2027-07-05',
+      endDate: '2027-07-23',
+      repeatsYearly: true,
+    })
+    expect(res.success).toBe(true)
+    expect(vi.mocked(db.workshopClosure.create).mock.calls[0][0].data).toMatchObject({
+      organizationId: ORG,
+      name: 'Summer shutdown',
+      repeatsYearly: true,
+    })
+  })
+})
+
+describe('opening hours', () => {
+  it('saves the week and keeps the work day in step for what still reads it', async () => {
+    const week = defaultWeek('07:30', '16:00')
+    week[6] = { open: true, start: '07:00', end: '12:00' }
+    const res = await saveOpeningHours(week)
+    expect(res.success).toBe(true)
+    expect(JSON.parse(settings.get('workshop.openingHours') as string)[6]).toEqual(week[6])
+    expect(settings.get('workboard.workDayStart')).toBe('07:00')
+    expect(settings.get('workboard.workDayEnd')).toBe('16:00')
+  })
+
+  it('refuses a day that closes before it opens', async () => {
+    const week = defaultWeek()
+    week[1] = { open: true, start: '16:00', end: '08:00' }
+    expect((await saveOpeningHours(week)).success).toBe(false)
+    expect(settings.has('workshop.openingHours')).toBe(false)
+  })
+})
+
+describe('public holidays', () => {
+  const holiday = (date: string, name: string) => ({
+    date,
+    localName: name,
+    name,
+    countryCode: 'AU',
+    global: true,
+    counties: null,
+    types: ['Public'],
+  })
+
+  it('fetches a new country, starts it with its own defaults, and keeps regions of that country', async () => {
+    settings.set('holidays.country', 'NO')
+    settings.set('holidays.choices', JSON.stringify({ 'Labour Day': false }))
+    const fetchMock = vi.fn(async (url: string) => {
+      const year = Number(url.match(/PublicHolidays\/(\d{4})/)?.[1])
+      return new Response(JSON.stringify([holiday(`${year}-12-25`, 'Christmas Day')]), {
+        status: 200,
+      })
+    })
+    vi.stubGlobal('fetch', fetchMock)
+
+    const res = await saveHolidayCountry({ country: 'au', regions: ['AU-QLD', 'DE-BY'] })
+    expect(res.success).toBe(true)
+    expect(settings.get('holidays.country')).toBe('AU')
+    expect(JSON.parse(settings.get('holidays.regions') as string)).toEqual(['AU-QLD'])
+    expect(settings.get('holidays.choices')).toBe('{}')
+    const cache = JSON.parse(settings.get('holidays.cache') as string)
+    expect(cache.country).toBe('AU')
+    expect(Object.keys(cache.years)).toHaveLength(3)
+    expect(
+      fetchMock.mock.calls.every(([url]) =>
+        String(url).startsWith('https://date.nager.at/api/v3/PublicHolidays/')
+      )
+    ).toBe(true)
+  })
+
+  it('keeps the holidays it had when a fetch fails, and records why', async () => {
+    settings.set('holidays.country', 'NZ')
+    settings.set(
+      'holidays.cache',
+      JSON.stringify({
+        country: 'NZ',
+        fetchedAt: '2026-11-01T00:00:00.000Z',
+        years: { '2026': [holiday('2026-12-25', 'Christmas Day')] },
+      })
+    )
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response('down', { status: 503 }))
+    )
+
+    const cache = await syncPublicHolidays(ORG, { now: new Date('2026-11-09T00:00:00Z') })
+    expect(cache?.error).toContain('503')
+    expect(cache?.years['2026']).toHaveLength(1)
+    expect(cache?.fetchedAt).toBe('2026-11-01T00:00:00.000Z')
+  })
+
+  it('forgets a choice set back to the default', async () => {
+    settings.set(
+      'holidays.choices',
+      JSON.stringify({ 'Christmas Day': false, 'Boxing Day': false })
+    )
+    await setHolidayChoice({ key: 'Christmas Day', closed: null })
+    expect(JSON.parse(settings.get('holidays.choices') as string)).toEqual({ 'Boxing Day': false })
+  })
+})
+
+describe('holiday calendars', () => {
+  const ics = [
+    'BEGIN:VCALENDAR',
+    'X-WR-CALNAME:Our holidays',
+    'BEGIN:VEVENT',
+    'DTSTART;VALUE=DATE:20261225',
+    'SUMMARY:Christmas Day',
+    'END:VEVENT',
+    'BEGIN:VEVENT',
+    'DTSTART;VALUE=DATE:20270101',
+    'SUMMARY:New Year',
+    'END:VEVENT',
+    'END:VCALENDAR',
+  ].join('\r\n')
+
+  it('adds a calendar from a file with its days, named after the calendar', async () => {
+    const res = await addHolidayFeed({ fileName: 'ours.ics', text: ics })
+    expect(res.success).toBe(true)
+    const [feed] = JSON.parse(settings.get('holidays.feeds') as string)
+    expect(feed).toMatchObject({ name: 'Our holidays', url: null, fileName: 'ours.ics' })
+    expect(feed.events.map((e: { name: string }) => e.name)).toEqual(['Christmas Day', 'New Year'])
+  })
+
+  it('refuses a file that is not a calendar', async () => {
+    const res = await addHolidayFeed({ fileName: 'notes.ics', text: 'hello' })
+    expect(res.success).toBe(false)
+    expect(settings.has('holidays.feeds')).toBe(false)
+  })
+
+  it('reads a webcal link as https and stores its days', async () => {
+    vi.mocked(fetchIcs).mockResolvedValue(ics)
+    const res = await addHolidayFeed({ url: 'webcal://example.com/holidays.ics' })
+    expect(res.success).toBe(true)
+    expect(fetchIcs).toHaveBeenCalledWith('https://example.com/holidays.ics')
+    const [feed] = JSON.parse(settings.get('holidays.feeds') as string)
+    expect(feed.url).toBe('https://example.com/holidays.ics')
+  })
+
+  const stored = () =>
+    JSON.parse(settings.get('holidays.feeds') ?? '[]') as {
+      id: string
+      events: { name: string }[]
+      choices: Record<string, boolean>
+    }[]
+
+  async function addLinked() {
+    vi.mocked(fetchIcs).mockResolvedValue(ics)
+    await addHolidayFeed({ url: 'https://example.com/holidays.ics' })
+    return stored()[0].id
+  }
+
+  it('clears the days of a calendar that has lost its last one', async () => {
+    const id = await addLinked()
+    vi.mocked(fetchIcs).mockResolvedValue('BEGIN:VCALENDAR\r\nEND:VCALENDAR')
+    const res = await refreshHolidayFeed(id)
+    expect(res.success).toBe(true)
+    expect(stored()[0].events).toEqual([])
+  })
+
+  it('keeps a switch made while the calendar was being read', async () => {
+    const id = await addLinked()
+    vi.mocked(fetchIcs).mockImplementation(async () => {
+      await setHolidayFeedChoice({ feedId: id, name: 'Christmas Day', closed: false })
+      return ics
+    })
+    await refreshLinkedFeeds(ORG)
+    expect(stored()[0].choices).toEqual({ 'Christmas Day': false })
+  })
+
+  it('does not bring back a calendar removed while it was being read', async () => {
+    const id = await addLinked()
+    vi.mocked(fetchIcs).mockImplementation(async () => {
+      await removeHolidayFeed(id)
+      return ics
+    })
+    const res = await refreshHolidayFeed(id)
+    expect(res).toMatchObject({ success: false, error: 'Calendar not found' })
+    expect(stored()).toEqual([])
+  })
+})

+ 295 - 0
src/__tests__/features/workshop-calendar/calendar.test.ts

@@ -0,0 +1,295 @@
+/**
+ * When the workshop is open and who is away: the rules every screen reads.
+ *
+ * A wrong answer here offers a customer Christmas Day, books a technician who
+ * is on leave, or shades the wrong hours on the board, so the edges are
+ * tested: a closure over New Year, part of a day, 29 February, the night the
+ * clocks change.
+ */
+
+import { describe, it, expect } from 'vitest'
+import {
+  type WorkshopCalendar,
+  absencesDuring,
+  addDays,
+  awayAllDay,
+  closuresDuring,
+  dayState,
+  defaultWeek,
+  isOpenFor,
+  openIntervalsOn,
+  parseWeekHours,
+  repeatYearly,
+  weekSpan,
+} from '@/features/workshop-calendar/Lib/calendar'
+import { closedDay, awayDay } from '@/features/workshop-calendar/Lib/boardDays'
+import { formatDays } from '@/features/workshop-calendar/Lib/format'
+
+const ZONE = 'Australia/Brisbane' // UTC+10, no daylight saving
+
+function cal(over: Partial<WorkshopCalendar> = {}): WorkshopCalendar {
+  return {
+    timeZone: ZONE,
+    week: defaultWeek('08:00', '17:00'),
+    closures: [],
+    absences: [],
+    ...over,
+  }
+}
+
+/** "HH:mm" of an instant on the workshop's clock. */
+const clock = (d: Date, zone = ZONE) =>
+  new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit' }).format(d)
+
+describe('opening hours', () => {
+  it('opens weekdays only by default, like the single work day did', () => {
+    const week = defaultWeek('07:00', '15:00')
+    expect(week.map((d) => d.open)).toEqual([false, true, true, true, true, true, false])
+  })
+
+  it('reads a stored week, and keeps the fallback for anything unusable', () => {
+    const fallback = defaultWeek('07:00', '15:00')
+    const stored = JSON.stringify([
+      { open: true, start: '09:00', end: '13:00' }, // Sunday open
+      { open: true, start: '07:30', end: '16:00' },
+      { open: true, start: '25:00', end: '16:00' }, // broken time: fallback times, still open
+      { open: true, start: '16:00', end: '08:00' }, // back to front: closed
+      null,
+      { open: false, start: '07:00', end: '15:00' },
+      { open: true, start: '08:00', end: '12:00' }, // Saturday morning
+    ])
+    const week = parseWeekHours(stored, fallback)
+    expect(week[0]).toEqual({ open: true, start: '09:00', end: '13:00' })
+    expect(week[1]).toEqual({ open: true, start: '07:30', end: '16:00' })
+    expect(week[2]).toEqual({ open: true, start: '07:00', end: '16:00' })
+    expect(week[3].open).toBe(false)
+    expect(week[4]).toEqual(fallback[4])
+    expect(week[5].open).toBe(false)
+    expect(week[6]).toEqual({ open: true, start: '08:00', end: '12:00' })
+    expect(parseWeekHours('not json', fallback)).toBe(fallback)
+    expect(parseWeekHours(JSON.stringify([1, 2]), fallback)).toBe(fallback)
+  })
+
+  it('spans the earliest opening to the latest closing for drawing the board', () => {
+    const week = defaultWeek('08:00', '17:00')
+    week[6] = { open: true, start: '07:00', end: '12:00' }
+    expect(weekSpan(week)).toEqual({ start: '07:00', end: '17:00' })
+  })
+
+  it('opens on a Saturday when the week says so', () => {
+    const week = defaultWeek('08:00', '17:00')
+    week[6] = { open: true, start: '08:00', end: '12:00' }
+    const intervals = openIntervalsOn(cal({ week }), '2026-11-14') // a Saturday
+    expect(intervals.map((i) => [clock(i.start), clock(i.end)])).toEqual([['08:00', '12:00']])
+    expect(openIntervalsOn(cal(), '2026-11-14')).toEqual([])
+  })
+})
+
+describe('closures', () => {
+  const christmas = {
+    name: 'Christmas Day',
+    source: 'holiday' as const,
+    startDate: '2026-12-25',
+    endDate: '2026-12-25',
+  }
+
+  it('closes the whole day for a holiday, and names it', () => {
+    const state = dayState(cal({ closures: [christmas] }), '2026-12-25')
+    expect(state.open).toBe(false)
+    expect(state.closedWeekday).toBe(false)
+    expect(state.closures.map((c) => c.name)).toEqual(['Christmas Day'])
+  })
+
+  it('names a holiday once when the public holidays and a calendar both list it', () => {
+    const fromFeed = { ...christmas, name: 'christmas day', source: 'feed' as const }
+    const both = cal({ closures: [christmas, fromFeed] })
+    expect(dayState(both, '2026-12-25').closures.map((c) => c.name)).toEqual(['Christmas Day'])
+    const day = { start: new Date('2026-12-25T00:00:00Z'), end: new Date('2026-12-25T23:00:00Z') }
+    expect(closuresDuring(both, day.start, day.end).map((c) => c.name)).toEqual(['Christmas Day'])
+  })
+
+  it('shortens a day closed from a time, and says it is shortened', () => {
+    const eve = {
+      ...christmas,
+      name: 'Christmas Eve',
+      startDate: '2026-12-24',
+      endDate: '2026-12-24',
+      startTime: '12:00',
+    }
+    const state = dayState(cal({ closures: [eve] }), '2026-12-24')
+    expect(state.open).toBe(true)
+    expect(state.shortened).toBe(true)
+    expect(state.intervals.map((i) => [clock(i.start), clock(i.end)])).toEqual([['08:00', '12:00']])
+  })
+
+  it('takes a closure from the middle of a day, leaving morning and afternoon', () => {
+    const lunch = {
+      ...christmas,
+      name: 'Staff meeting',
+      startDate: '2026-11-10',
+      endDate: '2026-11-10',
+      startTime: '11:00',
+      endTime: '13:00',
+    }
+    const intervals = openIntervalsOn(cal({ closures: [lunch] }), '2026-11-10')
+    expect(intervals.map((i) => [clock(i.start), clock(i.end)])).toEqual([
+      ['08:00', '11:00'],
+      ['13:00', '17:00'],
+    ])
+  })
+
+  it('closes every day of a stretch, with its times on the first and last day only', () => {
+    const shutdown = {
+      ...christmas,
+      name: 'Shutdown',
+      startDate: '2026-12-23',
+      endDate: '2026-12-29',
+      startTime: '14:00',
+      endTime: '10:00',
+    }
+    const c = cal({ closures: [shutdown] })
+    expect(openIntervalsOn(c, '2026-12-23').map((i) => clock(i.end))).toEqual(['14:00'])
+    expect(openIntervalsOn(c, '2026-12-28')).toEqual([])
+    expect(openIntervalsOn(c, '2026-12-29').map((i) => clock(i.start))).toEqual(['10:00'])
+  })
+
+  it('knows whether a booked slot is open, and which closures it falls in', () => {
+    const c = cal({ closures: [christmas] })
+    const at = (key: string, hh: string) => new Date(`${key}T${hh}:00+10:00`)
+    expect(isOpenFor(c, at('2026-12-24', '09:00'), at('2026-12-24', '10:00'))).toBe(true)
+    expect(isOpenFor(c, at('2026-12-24', '16:30'), at('2026-12-24', '17:30'))).toBe(false)
+    expect(isOpenFor(c, at('2026-12-25', '09:00'), at('2026-12-25', '10:00'))).toBe(false)
+    expect(
+      closuresDuring(c, at('2026-12-25', '09:00'), at('2026-12-25', '10:00')).map((x) => x.name)
+    ).toEqual(['Christmas Day'])
+  })
+})
+
+describe('repeating every year', () => {
+  it('moves a closure to each year and keeps a stretch over New Year whole', () => {
+    const period = {
+      name: 'Summer shutdown',
+      source: 'closure' as const,
+      startDate: '2024-12-24',
+      endDate: '2025-01-02',
+    }
+    const repeats = repeatYearly(period, 2026, 2027)
+    expect(repeats.map((p) => [p.startDate, p.endDate])).toEqual([
+      ['2026-12-24', '2027-01-02'],
+      ['2027-12-24', '2028-01-02'],
+    ])
+  })
+
+  it('puts 29 February on the 28th in a year without it', () => {
+    const leap = {
+      name: 'Leap day',
+      source: 'closure' as const,
+      startDate: '2024-02-29',
+      endDate: '2024-02-29',
+    }
+    expect(repeatYearly(leap, 2026, 2028).map((p) => p.startDate)).toEqual([
+      '2026-02-28',
+      '2027-02-28',
+      '2028-02-29',
+    ])
+  })
+})
+
+describe('a day the clocks change', () => {
+  it('keeps the hours on the wall clock in Oslo when summer time starts', () => {
+    // 29 March 2026: clocks go from 02:00 to 03:00 in Europe/Oslo.
+    const c = cal({ timeZone: 'Europe/Oslo', week: defaultWeek('08:00', '16:00', true) })
+    const [open] = openIntervalsOn(c, '2026-03-29')
+    expect(clock(open.start, 'Europe/Oslo')).toBe('08:00')
+    expect(clock(open.end, 'Europe/Oslo')).toBe('16:00')
+    expect(open.end.getTime() - open.start.getTime()).toBe(8 * 3600_000)
+    // The board lays the day out by its clock, so the shut hours sit at
+    // 00:00-08:00 and 16:00-24:00 even on a 23-hour day.
+    expect(closedDay(c, '2026-03-29').spans).toEqual([
+      { startMins: 0, endMins: 8 * 60 },
+      { startMins: 16 * 60, endMins: 24 * 60 },
+    ])
+  })
+
+  it('draws leave at its own clock time on both nights the clocks change', () => {
+    const c = cal({
+      timeZone: 'Europe/Oslo',
+      week: defaultWeek('08:00', '16:00', true),
+      absences: [
+        {
+          id: 'spring',
+          technicianId: 'tech-1',
+          reason: 'holiday',
+          startDate: '2026-03-29',
+          endDate: '2026-03-29',
+          startTime: '13:00',
+        },
+        {
+          id: 'autumn',
+          technicianId: 'tech-1',
+          reason: 'holiday',
+          startDate: '2026-10-25',
+          endDate: '2026-10-25',
+          startTime: '13:00',
+        },
+      ],
+    })
+    expect(awayDay(c, 'tech-1', '2026-03-29')?.spans).toEqual([
+      { startMins: 13 * 60, endMins: 24 * 60 },
+    ])
+    expect(awayDay(c, 'tech-1', '2026-10-25')?.spans).toEqual([
+      { startMins: 13 * 60, endMins: 24 * 60 },
+    ])
+  })
+})
+
+describe('time off', () => {
+  const leave = {
+    id: 'a1',
+    technicianId: 'tech-1',
+    startDate: '2026-11-16',
+    endDate: '2026-11-20',
+    reason: 'holiday',
+  }
+  const doctor = {
+    id: 'a2',
+    technicianId: 'tech-2',
+    startDate: '2026-11-17',
+    endDate: '2026-11-17',
+    startTime: '13:00',
+    endTime: '15:00',
+    reason: 'other',
+  }
+
+  it('finds who is away during a slot, and only them', () => {
+    const c = cal({ absences: [leave, doctor] })
+    const at = (key: string, hh: string) => new Date(`${key}T${hh}:00+10:00`)
+    expect(
+      absencesDuring(c, 'tech-1', at('2026-11-18', '09:00'), at('2026-11-18', '10:00'))
+    ).toHaveLength(1)
+    expect(
+      absencesDuring(c, 'tech-2', at('2026-11-17', '09:00'), at('2026-11-17', '10:00'))
+    ).toHaveLength(0)
+    expect(
+      absencesDuring(c, 'tech-2', at('2026-11-17', '14:00'), at('2026-11-17', '16:00'))
+    ).toHaveLength(1)
+  })
+
+  it('tells a whole day away from part of one', () => {
+    const c = cal({ absences: [leave, doctor] })
+    expect(awayAllDay(c, leave, '2026-11-18')).toBe(true)
+    expect(awayAllDay(c, doctor, '2026-11-17')).toBe(false)
+    const part = awayDay(c, 'tech-2', '2026-11-17')
+    expect(part?.allDay).toBe(false)
+    expect(part?.spans).toEqual([{ startMins: 13 * 60, endMins: 15 * 60 }])
+    expect(awayDay(c, 'tech-2', addDays('2026-11-17', 1))).toBeNull()
+  })
+})
+
+describe('dates for people to read', () => {
+  it('writes a range with plain spaces, so the server and the browser agree', () => {
+    const range = formatDays('2026-11-16', '2026-11-17', 'en', { weekday: true })
+    expect(range).toBe('Mon, Nov 16 – Tue, Nov 17, 2026')
+    expect(range).not.toMatch(/[\u2009\u202f]/)
+  })
+})

+ 218 - 0
src/__tests__/features/workshop-calendar/components.test.tsx

@@ -0,0 +1,218 @@
+/**
+ * The screens for leave, opening hours and public holidays, used the way a
+ * service advisor would: what they pick is what gets saved.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }))
+vi.mock('@/components/confirm-dialog', () => ({ useConfirm: () => vi.fn(async () => true) }))
+vi.mock('@/components/date-settings-context', () => ({
+  useDateSettings: () => ({ dateFormat: 'yyyy-MM-dd', timeFormat: '24h', timezone: 'UTC' }),
+}))
+vi.mock('@/features/workshop-calendar/Actions/absenceActions', () => ({
+  createAbsence: vi.fn(),
+  updateAbsence: vi.fn(),
+  deleteAbsence: vi.fn(),
+  listAbsences: vi.fn(),
+}))
+vi.mock('@/features/workshop-calendar/Actions/holidayActions', () => ({
+  saveOpeningHours: vi.fn(),
+  setHolidayChoice: vi.fn(),
+  saveHolidayCountry: vi.fn(),
+  refreshPublicHolidays: vi.fn(),
+  listHolidayCountries: vi.fn(),
+}))
+
+import { toast } from 'sonner'
+import { createAbsence } from '@/features/workshop-calendar/Actions/absenceActions'
+import {
+  listHolidayCountries,
+  saveOpeningHours,
+  setHolidayChoice,
+} from '@/features/workshop-calendar/Actions/holidayActions'
+import { TimeOffDialog } from '@/features/workshop-calendar/Components/TimeOffDialog'
+import { OpeningHoursCard } from '@/features/workshop-calendar/Components/OpeningHoursCard'
+import { PublicHolidaysCard } from '@/features/workshop-calendar/Components/PublicHolidaysCard'
+import { defaultWeek } from '@/features/workshop-calendar/Lib/calendar'
+import type { HolidayRow } from '@/features/workshop-calendar/Lib/holidays'
+
+beforeEach(() => {
+  vi.clearAllMocks()
+})
+
+describe('setting a technician on leave', () => {
+  const technicians = [
+    { id: 'kari', name: 'Kari', color: '#f00' },
+    { id: 'ola', name: 'Ola', color: '#00f' },
+  ]
+
+  it('saves the technician picked from the lane, whole days, as leave by default', async () => {
+    vi.mocked(createAbsence).mockResolvedValue({ success: true, data: {} } as never)
+    const onSaved = vi.fn()
+    render(
+      <TimeOffDialog
+        open
+        onOpenChange={vi.fn()}
+        technicians={technicians}
+        defaultTechnicianId="kari"
+        defaultDate="2026-11-16"
+        onSaved={onSaved}
+      />
+    )
+    expect(
+      screen.getByRole('heading', { name: 'Set on leave or out of office' })
+    ).toBeInTheDocument()
+    await userEvent.type(screen.getByLabelText('Note'), 'Two weeks in Spain')
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+    expect(createAbsence).toHaveBeenCalledWith({
+      technicianId: 'kari',
+      reason: 'holiday',
+      note: 'Two weeks in Spain',
+      startDate: '2026-11-16',
+      endDate: '2026-11-16',
+      startTime: null,
+      endTime: null,
+    })
+    expect(onSaved).toHaveBeenCalled()
+  })
+
+  it('asks for a technician before saving when none was picked', async () => {
+    render(
+      <TimeOffDialog
+        open
+        onOpenChange={vi.fn()}
+        technicians={technicians}
+        defaultDate="2026-11-16"
+        onSaved={vi.fn()}
+      />
+    )
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }))
+    expect(screen.getByRole('alert')).toHaveTextContent('Pick a technician')
+    expect(createAbsence).not.toHaveBeenCalled()
+  })
+
+  it('sends times only for part of a day, and refuses times that cross', async () => {
+    vi.mocked(createAbsence).mockResolvedValue({ success: true, data: {} } as never)
+    render(
+      <TimeOffDialog
+        open
+        onOpenChange={vi.fn()}
+        technicians={technicians}
+        defaultTechnicianId="ola"
+        defaultDate="2026-11-17"
+        onSaved={vi.fn()}
+      />
+    )
+    // Whole days is the usual case, so the switch starts on and reads "All day".
+    const allDay = screen.getByRole('switch', { name: 'All day' })
+    expect(allDay).toBeChecked()
+    expect(screen.queryByLabelText('From')).not.toBeInTheDocument()
+    await userEvent.click(allDay)
+    expect(allDay).not.toBeChecked()
+    const from = screen.getByLabelText('From')
+    const until = screen.getByLabelText('Until')
+    await userEvent.type(from, '15:00')
+    await userEvent.type(until, '13:00')
+    expect(screen.getByRole('alert')).toHaveTextContent('The end time is before the start time')
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }))
+    expect(createAbsence).not.toHaveBeenCalled()
+
+    await userEvent.clear(until)
+    await userEvent.type(until, '17:00')
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }))
+    expect(createAbsence).toHaveBeenCalledWith(
+      expect.objectContaining({ technicianId: 'ola', startTime: '15:00', endTime: '17:00' })
+    )
+  })
+})
+
+describe('opening hours', () => {
+  it('opens Saturday mornings and saves the week', async () => {
+    vi.mocked(saveOpeningHours).mockResolvedValue({ success: true, data: {} } as never)
+    render(<OpeningHoursCard week={defaultWeek('08:00', '16:00')} canEdit />)
+    const saturday = screen.getByTestId('opening-hours-6')
+    await userEvent.click(within(saturday).getByRole('switch'))
+    const closes = within(saturday).getByLabelText('Saturday: Closes')
+    await userEvent.clear(closes)
+    await userEvent.type(closes, '12:00')
+    await userEvent.click(screen.getByRole('button', { name: 'Save opening hours' }))
+
+    const week = vi.mocked(saveOpeningHours).mock.calls[0][0] as ReturnType<typeof defaultWeek>
+    expect(week[6]).toEqual({ open: true, start: '08:00', end: '12:00' })
+    expect(week[0].open).toBe(false)
+  })
+
+  it('says which day is wrong instead of saving hours that close before they open', async () => {
+    render(<OpeningHoursCard week={defaultWeek('08:00', '16:00')} canEdit />)
+    const monday = screen.getByTestId('opening-hours-1')
+    const closes = within(monday).getByLabelText('Monday: Closes')
+    await userEvent.clear(closes)
+    await userEvent.type(closes, '07:00')
+    await userEvent.click(screen.getByRole('button', { name: 'Save opening hours' }))
+    expect(saveOpeningHours).not.toHaveBeenCalled()
+    expect(toast.error).toHaveBeenCalledWith(
+      'Monday: the closing time must be after the opening time'
+    )
+  })
+})
+
+describe('public holidays', () => {
+  const rows: HolidayRow[] = [
+    {
+      key: 'Christmas Day',
+      name: 'Christmas Day',
+      localName: 'Christmas Day',
+      dates: ['2026-12-25'],
+      types: ['Public'],
+      regional: false,
+      counties: [],
+      closed: true,
+      closedByDefault: true,
+    },
+    {
+      key: 'Melbourne Cup',
+      name: 'Melbourne Cup',
+      localName: 'Melbourne Cup',
+      dates: ['2026-11-03'],
+      types: ['Public'],
+      regional: true,
+      counties: ['AU-VIC'],
+      closed: true,
+      closedByDefault: true,
+    },
+  ]
+
+  it('stays open on a holiday when it is switched off, and forgets that when switched back', async () => {
+    vi.mocked(listHolidayCountries).mockResolvedValue({
+      success: true,
+      data: { countries: ['AU', 'NZ'], error: null },
+    } as never)
+    vi.mocked(setHolidayChoice).mockResolvedValue({ success: true, data: {} } as never)
+    render(
+      <PublicHolidaysCard
+        country="AU"
+        regions={['AU-VIC']}
+        availableRegions={['AU-QLD', 'AU-VIC']}
+        holidays={rows}
+        fetchedAt={null}
+        fetchError={null}
+        year={2026}
+        canEdit
+      />
+    )
+    const cup = screen.getByRole('switch', { name: 'Melbourne Cup: Closed' })
+    await userEvent.click(cup)
+    expect(setHolidayChoice).toHaveBeenLastCalledWith({ key: 'Melbourne Cup', closed: false })
+    expect(screen.getByRole('switch', { name: 'Melbourne Cup: Open' })).toBeInTheDocument()
+
+    await userEvent.click(screen.getByRole('switch', { name: 'Melbourne Cup: Open' }))
+    expect(setHolidayChoice).toHaveBeenLastCalledWith({ key: 'Melbourne Cup', closed: null })
+    expect(screen.getByText('Only in VIC')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'VIC' })).toHaveAttribute('aria-pressed', 'true')
+  })
+})

+ 264 - 0
src/__tests__/features/workshop-calendar/holidays.test.ts

@@ -0,0 +1,264 @@
+/**
+ * Public holidays and holiday calendars, read into closed days.
+ *
+ * Holiday sources list more than a workshop closes for (observances,
+ * regional days elsewhere, bank days), so what matters is which days end up
+ * closed by default, and that a workshop's choice sticks.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { parseIcs } from '@/features/workshop-calendar/Lib/ics'
+import {
+  type HolidayCache,
+  type HolidayFeed,
+  type NagerHoliday,
+  feedClosures,
+  feedRows,
+  holidayRows,
+  publicHolidayClosures,
+  regionsOf,
+} from '@/features/workshop-calendar/Lib/holidays'
+
+const h = (over: Partial<NagerHoliday> & { date: string; name: string }): NagerHoliday => ({
+  localName: over.name,
+  countryCode: 'AU',
+  global: true,
+  counties: null,
+  types: ['Public'],
+  ...over,
+})
+
+const AU: HolidayCache = {
+  country: 'AU',
+  fetchedAt: '2026-11-09T00:00:00.000Z',
+  years: {
+    '2026': [
+      h({ date: '2026-01-26', name: 'Australia Day' }),
+      h({ date: '2026-03-02', name: 'Labour Day', global: false, counties: ['AU-WA'] }),
+      h({ date: '2026-05-04', name: 'Labour Day', global: false, counties: ['AU-QLD'] }),
+      h({ date: '2026-11-03', name: 'Melbourne Cup', global: false, counties: ['AU-VIC'] }),
+      h({ date: '2026-12-25', name: 'Christmas Day' }),
+      h({ date: '2026-12-24', name: 'Christmas Eve', types: ['Bank'] }),
+    ],
+    '2027': [h({ date: '2027-12-25', name: 'Christmas Day' })],
+  },
+}
+
+describe('public holidays', () => {
+  it('closes for nationwide public holidays and those of the chosen regions', () => {
+    const closed = publicHolidayClosures(AU, ['AU-QLD'], {})
+    expect(closed.map((c) => `${c.startDate} ${c.name}`)).toEqual([
+      '2026-01-26 Australia Day',
+      '2026-05-04 Labour Day',
+      '2026-12-25 Christmas Day',
+      '2027-12-25 Christmas Day',
+    ])
+  })
+
+  it('counts only nationwide holidays when no region is chosen', () => {
+    const names = publicHolidayClosures(AU, [], {}).map((c) => c.name)
+    expect(names).not.toContain('Labour Day')
+    expect(names).not.toContain('Melbourne Cup')
+  })
+
+  it('leaves bank and other non-public days open unless the workshop closes them', () => {
+    expect(publicHolidayClosures(AU, [], {}).map((c) => c.name)).not.toContain('Christmas Eve')
+    expect(publicHolidayClosures(AU, [], { 'Christmas Eve': true }).map((c) => c.name)).toContain(
+      'Christmas Eve'
+    )
+  })
+
+  it('stays open on a public holiday the workshop switched off, every year', () => {
+    const closed = publicHolidayClosures(AU, [], { 'Christmas Day': false })
+    expect(closed.map((c) => c.name)).not.toContain('Christmas Day')
+  })
+
+  it('lists each holiday once with all its dates, and the regions to choose from', () => {
+    const rows = holidayRows(AU, ['AU-QLD'], { 'Australia Day': false }, [2026, 2027])
+    const christmas = rows.find((r) => r.key === 'Christmas Day')
+    expect(christmas?.dates).toEqual(['2026-12-25', '2027-12-25'])
+    expect(rows.find((r) => r.key === 'Australia Day')?.closed).toBe(false)
+    expect(rows.find((r) => r.key === 'Labour Day')).toMatchObject({ regional: true, closed: true })
+    expect(rows.find((r) => r.key === 'Christmas Eve')).toMatchObject({
+      closed: false,
+      closedByDefault: false,
+    })
+    expect(regionsOf(AU)).toEqual(['AU-QLD', 'AU-VIC', 'AU-WA'])
+  })
+})
+
+describe('holiday calendars', () => {
+  const feed: HolidayFeed = {
+    id: 'f1',
+    name: 'Holidays in Australia',
+    url: null,
+    fileName: 'au.ics',
+    events: [
+      { uid: '1', name: 'Christmas Day', startDate: '2026-12-25', endDate: '2026-12-25' },
+      { uid: '2', name: 'Valentine’s Day', startDate: '2026-02-14', endDate: '2026-02-14' },
+      { uid: '3', name: 'Christmas Day', startDate: '2027-12-25', endDate: '2027-12-25' },
+    ],
+    choices: { 'Valentine’s Day': false },
+    fetchedAt: null,
+    error: null,
+  }
+
+  it('closes on every day of a calendar except those switched off', () => {
+    expect(feedClosures([feed]).map((c) => `${c.startDate} ${c.name}`)).toEqual([
+      '2026-12-25 Christmas Day',
+      '2027-12-25 Christmas Day',
+    ])
+  })
+
+  it('lists a calendar by name with its dates in the years shown', () => {
+    expect(feedRows(feed, '2026-01-01', '2026-12-31')).toEqual([
+      { name: 'Valentine’s Day', dates: ['2026-02-14'], closed: false },
+      { name: 'Christmas Day', dates: ['2026-12-25'], closed: true },
+    ])
+  })
+})
+
+describe('reading an .ics calendar', () => {
+  const ics = [
+    'BEGIN:VCALENDAR',
+    'X-WR-CALNAME:Queensland public holidays',
+    'BEGIN:VEVENT',
+    'UID:a',
+    'DTSTART;VALUE=DATE:20261225',
+    'DTEND;VALUE=DATE:20261226',
+    'SUMMARY:Christmas Day',
+    'END:VEVENT',
+    'BEGIN:VEVENT',
+    'UID:b',
+    'DTSTART;VALUE=DATE:20261224',
+    'DTEND;VALUE=DATE:20261228',
+    // A folded line: the continuation starts with a space.
+    'SUMMARY:Christmas shutdown\\, wor',
+    ' kshop closed',
+    'BEGIN:VALARM',
+    'SUMMARY:Not the event',
+    'END:VALARM',
+    'END:VEVENT',
+    'BEGIN:VEVENT',
+    'UID:c',
+    'DTSTART:20260814T090000Z',
+    'DTEND:20260814T170000Z',
+    'SUMMARY:Ekka show day',
+    'END:VEVENT',
+    'BEGIN:VEVENT',
+    'UID:d',
+    'DTSTART;VALUE=DATE:20200101',
+    'RRULE:FREQ=YEARLY;COUNT=10',
+    'SUMMARY:New Year',
+    'END:VEVENT',
+    'BEGIN:VEVENT',
+    'UID:e',
+    'DTSTART;VALUE=DATE:20260601',
+    'SUMMARY:Cancelled day',
+    'STATUS:CANCELLED',
+    'END:VEVENT',
+    'BEGIN:VEVENT',
+    'UID:f',
+    'SUMMARY:No date',
+    'END:VEVENT',
+    'END:VCALENDAR',
+  ].join('\r\n')
+
+  const parsed = parseIcs(ics, { fromYear: 2026, toYear: 2027 })
+  const byName = (name: string) => parsed.events.filter((e) => e.name === name)
+
+  it('takes the calendar name and skips what it cannot use', () => {
+    expect(parsed.name).toBe('Queensland public holidays')
+    expect(parsed.skipped).toBe(1)
+    expect(byName('Cancelled day')).toEqual([])
+  })
+
+  it('reads an all-day end as the day after the last one', () => {
+    expect(byName('Christmas Day')[0]).toMatchObject({
+      startDate: '2026-12-25',
+      endDate: '2026-12-25',
+    })
+    expect(byName('Christmas shutdown, workshop closed')[0]).toMatchObject({
+      startDate: '2026-12-24',
+      endDate: '2026-12-27',
+    })
+  })
+
+  it('counts a timed event for the day it is on', () => {
+    expect(byName('Ekka show day')[0]).toMatchObject({
+      startDate: '2026-08-14',
+      endDate: '2026-08-14',
+    })
+  })
+
+  it('repeats a yearly event in the years asked for, within its count', () => {
+    expect(byName('New Year').map((e) => e.startDate)).toEqual(['2026-01-01', '2027-01-01'])
+    const short = parseIcs(ics.replace('COUNT=10', 'COUNT=3'), { fromYear: 2026, toYear: 2027 })
+    expect(short.events.filter((e) => e.name === 'New Year')).toEqual([])
+  })
+})
+
+describe("Google's holiday calendars", () => {
+  // As Google writes them: a holiday has one line of description, an
+  // observance a second line on how to hide observances, in the calendar's
+  // own language.
+  const google = [
+    'BEGIN:VCALENDAR',
+    'BEGIN:VEVENT',
+    'UID:20261225_a@google.com',
+    'DTSTART;VALUE=DATE:20261225',
+    'DTEND;VALUE=DATE:20261226',
+    'SUMMARY:Første juledag',
+    'DESCRIPTION:Offentlig høytidsdag',
+    'END:VEVENT',
+    'BEGIN:VEVENT',
+    'UID:20261129_b@google.com',
+    'DTSTART;VALUE=DATE:20261129',
+    'DTEND;VALUE=DATE:20261130',
+    'SUMMARY:Første søndag i advent',
+    'DESCRIPTION:Helligdag\\nDu kan skjule helligdager ved å gå til Google Kalender',
+    'END:VEVENT',
+    'BEGIN:VEVENT',
+    'UID:20260214_c@google.com',
+    'DTSTART;VALUE=DATE:20260214',
+    'DTEND;VALUE=DATE:20260215',
+    "SUMMARY:Valentine's Day",
+    'DESCRIPTION:Observance\\nTo hide observances\\, go to Google Calendar Settings',
+    'END:VEVENT',
+    'END:VCALENDAR',
+  ].join('\r\n')
+  const parsed = parseIcs(google, { fromYear: 2026, toYear: 2026 })
+  const feed: HolidayFeed = {
+    id: 'g',
+    name: 'Holidays',
+    url: null,
+    fileName: null,
+    events: parsed.events,
+    choices: {},
+    fetchedAt: null,
+    error: null,
+  }
+
+  it('closes for the holidays and stays open on observances', () => {
+    expect(feedClosures([feed]).map((c) => c.name)).toEqual(['Første juledag'])
+    expect(feedRows(feed, '2026-01-01', '2026-12-31').map((r) => `${r.name}: ${r.closed}`)).toEqual(
+      ["Valentine's Day: false", 'Første søndag i advent: false', 'Første juledag: true']
+    )
+  })
+
+  it('closes on an observance once the workshop says so', () => {
+    const chosen = { ...feed, choices: { 'Første søndag i advent': true } }
+    expect(feedClosures([chosen]).map((c) => c.name)).toEqual([
+      'Første søndag i advent',
+      'Første juledag',
+    ])
+  })
+
+  it('reads a second line from any other calendar as just a description', () => {
+    const other = parseIcs(google.replaceAll('@google.com', '@example.gov'), {
+      fromYear: 2026,
+      toYear: 2026,
+    })
+    expect(other.events.every((e) => !e.observance)).toBe(true)
+  })
+})

+ 100 - 0
src/__tests__/features/workshop-calendar/ics-fetch.test.ts

@@ -0,0 +1,100 @@
+// @vitest-environment node
+/**
+ * Fetching a holiday calendar from a link the workshop pasted.
+ *
+ * The server fetches it, so the link must never reach a private address,
+ * not even when its name passes the check and then answers with a private
+ * address for the connection itself.
+ */
+
+import { createServer, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/features/webhooks/Lib/ssrf', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/features/webhooks/Lib/ssrf')>()
+  return { ...actual, checkWebhookUrl: vi.fn(actual.checkWebhookUrl) }
+})
+
+import { checkWebhookUrl } from '@/features/webhooks/Lib/ssrf'
+import { fetchIcs, guardedLookup } from '@/features/workshop-calendar/Lib/icsFetch.server'
+
+const CALENDAR =
+  'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Christmas Day\r\nEND:VEVENT\r\nEND:VCALENDAR'
+
+describe('the lookup a connection uses', () => {
+  const resolveTo =
+    (...addresses: string[]) =>
+    (
+      _host: string,
+      _opts: unknown,
+      cb: (err: null, a: { address: string; family: number }[]) => void
+    ) =>
+      cb(
+        null,
+        addresses.map((address) => ({ address, family: address.includes(':') ? 6 : 4 }))
+      )
+
+  const ask = (lookup: ReturnType<typeof guardedLookup>, all: boolean) =>
+    new Promise<{ err: Error | null; address: unknown }>((resolve) =>
+      lookup('calendar.example', { all }, (err, address) => resolve({ err, address }))
+    )
+
+  it('hands a public address to the connection', async () => {
+    const lookup = guardedLookup(resolveTo('93.184.216.34') as never)
+    expect(await ask(lookup, false)).toEqual({ err: null, address: '93.184.216.34' })
+    expect((await ask(lookup, true)).address).toEqual([{ address: '93.184.216.34', family: 4 }])
+  })
+
+  it('refuses when any address the name gives is private', async () => {
+    for (const address of ['10.0.0.5', '127.0.0.1', '169.254.169.254', '::1']) {
+      const lookup = guardedLookup(resolveTo('93.184.216.34', address) as never)
+      expect((await ask(lookup, true)).err?.message).toBe('resolves_private')
+    }
+  })
+})
+
+describe('fetching a calendar', () => {
+  let server: Server
+  let base = ''
+  const routes: Record<string, (res: import('node:http').ServerResponse) => void> = {
+    '/holidays.ics': (res) => res.end(CALENDAR),
+    '/moved': (res) => res.writeHead(302, { location: '/holidays.ics' }).end(),
+    '/page': (res) => res.end('<html>Not a calendar</html>'),
+    '/huge.ics': (res) => res.end(`BEGIN:VCALENDAR\r\n${'X'.repeat(1_000_000)}`),
+  }
+
+  beforeAll(async () => {
+    server = createServer((req, res) =>
+      (routes[req.url ?? ''] ?? ((r) => r.writeHead(404).end()))(res)
+    )
+    await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
+    base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
+  })
+  afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())))
+  afterEach(() => {
+    vi.unstubAllEnvs()
+  })
+
+  it('reads a calendar, following a redirect', async () => {
+    vi.stubEnv('WEBHOOKS_ALLOW_PRIVATE_TARGETS', 'true')
+    expect(await fetchIcs(`${base}/moved`)).toBe(CALENDAR)
+  })
+
+  it('says why it cannot use a page that is not a calendar, or one too large', async () => {
+    vi.stubEnv('WEBHOOKS_ALLOW_PRIVATE_TARGETS', 'true')
+    await expect(fetchIcs(`${base}/page`)).rejects.toThrow('The link is not a calendar (.ics) file')
+    await expect(fetchIcs(`${base}/huge.ics`)).rejects.toThrow('The calendar is too large to read')
+    await expect(fetchIcs(`${base}/missing.ics`)).rejects.toThrow('The calendar link answered 404')
+  })
+
+  it('refuses a private address the check never saw', async () => {
+    // The name passes the check (as if it answered with a public address
+    // then), and resolves to this machine when the connection is made.
+    vi.mocked(checkWebhookUrl).mockResolvedValueOnce({ ok: true })
+    const port = (server.address() as AddressInfo).port
+    await expect(fetchIcs(`http://localhost:${port}/holidays.ics`)).rejects.toThrow(
+      'That link cannot be used: resolves_private'
+    )
+  })
+})

+ 242 - 0
src/__tests__/features/workshop-calendar/scheduling.test.ts

@@ -0,0 +1,242 @@
+/**
+ * How the workshop calendar changes scheduling: the next free slot the desk
+ * is offered, a technician's load on the board, and the days a customer may
+ * book online.
+ *
+ * Staff are never refused (those paths only warn); online booking is, so a
+ * customer is never offered a holiday, a closed weekday or a technician who
+ * is away.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { zonedDate, zonedParts } from '@/lib/timezone'
+import {
+  absenceBookings,
+  findConflicts,
+  nextAvailableSlot,
+  technicianMinutesInDay,
+  withinWorkingHours,
+} from '@/features/workboard/Lib/availability'
+import { bookableDays } from '@/features/inspection-reminders/Lib/booking'
+import type { InspectionReminderSettings } from '@/features/inspection-reminders/Lib/settings'
+import { type WorkshopCalendar, defaultWeek } from '@/features/workshop-calendar/Lib/calendar'
+
+const OSLO = 'Europe/Oslo'
+const oslo = (y: number, m: number, d: number, h: number, min = 0) =>
+  zonedDate(y, m, d, h, min, OSLO)
+const hhmm = (d: Date) => {
+  const p = zonedParts(d, OSLO)
+  return `${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`
+}
+const dayOfMonth = (d: Date) => zonedParts(d, OSLO).day
+
+function calendar(over: Partial<WorkshopCalendar> = {}): WorkshopCalendar {
+  return {
+    timeZone: OSLO,
+    week: defaultWeek('08:00', '16:00'),
+    closures: [],
+    absences: [],
+    ...over,
+  }
+}
+
+const hoursWith = (cal: WorkshopCalendar) => ({
+  start: '08:00',
+  end: '16:00',
+  timeZone: OSLO,
+  calendar: cal,
+})
+
+// Thursday 24 December 2026 and the days around it.
+const christmas = calendar({
+  closures: [
+    {
+      name: 'Julaften',
+      source: 'holiday',
+      startDate: '2026-12-24',
+      endDate: '2026-12-24',
+      startTime: '12:00',
+    },
+    { name: 'Første juledag', source: 'holiday', startDate: '2026-12-25', endDate: '2026-12-25' },
+  ],
+})
+
+describe('next free slot', () => {
+  it('skips a public holiday and the hours closed on the day before', () => {
+    // From 11:30 on Christmas Eve, a two hour job no longer fits before 12:00;
+    // the 25th is a holiday and the 26th and 27th a weekend.
+    const slot = nextAvailableSlot({
+      from: oslo(2026, 12, 24, 11, 30),
+      durationMinutes: 120,
+      bookings: [],
+      hours: hoursWith(christmas),
+    })
+    expect(slot && dayOfMonth(slot.start)).toBe(28)
+    expect(slot && hhmm(slot.start)).toBe('08:00')
+  })
+
+  it('offers a Saturday when the workshop opens on Saturdays', () => {
+    const week = defaultWeek('08:00', '16:00')
+    week[6] = { open: true, start: '09:00', end: '13:00' }
+    const slot = nextAvailableSlot({
+      from: oslo(2026, 11, 13, 17, 0), // Friday after closing
+      durationMinutes: 60,
+      bookings: [],
+      hours: hoursWith(calendar({ week })),
+    })
+    expect(slot && dayOfMonth(slot.start)).toBe(14)
+    expect(slot && hhmm(slot.start)).toBe('09:00')
+  })
+
+  it('does not book a technician while they are away, and says why in a clash', () => {
+    const cal = calendar({
+      absences: [
+        {
+          id: 'a1',
+          technicianId: 'kari',
+          startDate: '2026-11-16',
+          endDate: '2026-11-17',
+          reason: 'holiday',
+        },
+      ],
+    })
+    const bookings = absenceBookings(cal)
+    const slot = nextAvailableSlot({
+      from: oslo(2026, 11, 16, 7, 0),
+      durationMinutes: 60,
+      bookings,
+      hours: hoursWith(cal),
+      technicianId: 'kari',
+    })
+    expect(slot && dayOfMonth(slot.start)).toBe(18)
+
+    // Someone else is free that Monday, and Kari's leave is named as the clash.
+    expect(
+      nextAvailableSlot({
+        from: oslo(2026, 11, 16, 7, 0),
+        durationMinutes: 60,
+        bookings,
+        hours: hoursWith(cal),
+        technicianId: 'ola',
+      })?.start
+    ).toEqual(oslo(2026, 11, 16, 8, 0))
+    const clash = findConflicts(
+      { start: oslo(2026, 11, 16, 9), end: oslo(2026, 11, 16, 10), technicianId: 'kari' },
+      bookings
+    )
+    expect(clash).toMatchObject([{ kind: 'absence', label: 'holiday' }])
+  })
+
+  it('counts time away as away, not as booked work, on the board', () => {
+    const cal = calendar({
+      absences: [
+        {
+          id: 'a1',
+          technicianId: 'kari',
+          startDate: '2026-11-16',
+          endDate: '2026-11-16',
+          reason: 'sick',
+        },
+      ],
+    })
+    const load = technicianMinutesInDay(
+      absenceBookings(cal),
+      oslo(2026, 11, 16, 0),
+      oslo(2026, 11, 17, 0)
+    )
+    expect(load).toEqual({})
+  })
+
+  it('treats a slot on a holiday as outside working hours', () => {
+    const slot = { start: oslo(2026, 12, 25, 9), end: oslo(2026, 12, 25, 10) }
+    expect(withinWorkingHours(slot, hoursWith(christmas))).toBe(false)
+    expect(
+      withinWorkingHours(
+        { start: oslo(2026, 12, 24, 9), end: oslo(2026, 12, 24, 10) },
+        hoursWith(christmas)
+      )
+    ).toBe(true)
+  })
+})
+
+describe('online booking', () => {
+  const settings: InspectionReminderSettings = {
+    durationMinutes: 60,
+    leadDays: 1,
+    horizonWeeks: 1,
+    walkInReserve: 0,
+    linkValidDays: 7,
+    bookingMode: 'direct',
+    phone: '',
+    workshopName: 'Egeland Auto',
+    templateSms: null,
+    templateEmailSubject: null,
+    templateEmailBody: null,
+    workingHours: { start: '08:00', end: '16:00', includeWeekends: false, timeZone: OSLO },
+    timeZone: OSLO,
+    timeZoneDetected: false,
+  }
+  const now = oslo(2026, 12, 22, 10)
+
+  it('closes a public holiday with its name, and shortens the day before it', () => {
+    const days = bookableDays({
+      now,
+      settings,
+      resources: [{ workBayId: 'bay-1' }],
+      bookings: [],
+      calendar: christmas,
+    })
+    const eve = days.find((d) => d.date === '2026-12-24')
+    const day = days.find((d) => d.date === '2026-12-25')
+    expect(day).toMatchObject({ status: 'closed', starts: [], closedFor: 'Første juledag' })
+    // 08:00 to 12:00 with an hour's slot on the half-hour grid: 08:00 to 11:00.
+    expect(eve?.starts.map((s) => hhmm(new Date(s)))).toEqual([
+      '08:00',
+      '08:30',
+      '09:00',
+      '09:30',
+      '10:00',
+      '10:30',
+      '11:00',
+    ])
+  })
+
+  it('offers no time when the only technician is on leave', () => {
+    const cal = calendar({
+      absences: [
+        {
+          id: 'a1',
+          technicianId: 'kari',
+          startDate: '2026-12-23',
+          endDate: '2026-12-23',
+          reason: 'holiday',
+        },
+      ],
+    })
+    const days = bookableDays({
+      now,
+      settings,
+      resources: [{ technicianId: 'kari' }],
+      bookings: absenceBookings(cal),
+      calendar: cal,
+    })
+    expect(days.find((d) => d.date === '2026-12-23')?.status).toBe('full')
+    expect(days.find((d) => d.date === '2026-12-28')?.status).toBe('open')
+  })
+
+  it('lets a customer book a Saturday when the workshop opens then', () => {
+    const week = defaultWeek('08:00', '16:00')
+    week[6] = { open: true, start: '09:00', end: '12:00' }
+    const days = bookableDays({
+      now,
+      settings,
+      resources: [{ workBayId: 'bay-1' }],
+      bookings: [],
+      calendar: calendar({ week }),
+    })
+    const saturday = days.find((d) => d.date === '2026-12-26')
+    expect(saturday?.status).toBe('open')
+    expect(hhmm(new Date(saturday?.starts[0] as string))).toBe('09:00')
+    expect(days.find((d) => d.date === '2026-12-27')?.status).toBe('closed')
+  })
+})

+ 1 - 0
src/__tests__/lib/backup/build-data.test.ts

@@ -104,6 +104,7 @@ describe('organisation scope', () => {
       'tireSets',
       'workOrderStatuses',
       'conditionMarkTypes',
+      'workshopClosures',
       'laborPresets',
       'webhooks',
       'reportSchedules',

+ 55 - 0
src/__tests__/lib/cron/workshop-holidays.test.ts

@@ -0,0 +1,55 @@
+/**
+ * The daily holiday refresh: every workshop with a country or a linked
+ * calendar is read again, one failing does not stop the rest, and a workshop
+ * being restored is left alone.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: { appSetting: { findMany: vi.fn() } } }))
+vi.mock('@/lib/backup/freeze', () => ({
+  isRestoring: vi.fn((id: string) => id === 'org-restoring'),
+}))
+vi.mock('@/features/workshop-calendar/Lib/holidaySync.server', () => ({
+  syncPublicHolidays: vi.fn(),
+}))
+vi.mock('@/features/workshop-calendar/Lib/feedSync.server', () => ({
+  refreshLinkedFeeds: vi.fn(),
+}))
+
+import { db } from '@/lib/db'
+import { syncPublicHolidays } from '@/features/workshop-calendar/Lib/holidaySync.server'
+import { refreshLinkedFeeds } from '@/features/workshop-calendar/Lib/feedSync.server'
+import { syncWorkshopHolidaysOnce } from '@/lib/cron/workshop-holidays'
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  // The failing workshop is logged; keep the test output quiet.
+  vi.spyOn(console, 'error').mockImplementation(() => undefined)
+})
+
+describe('daily holiday refresh', () => {
+  it('refreshes each workshop once and carries on past one that fails', async () => {
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { organizationId: 'org-au' },
+      { organizationId: 'org-au' },
+      { organizationId: 'org-broken' },
+      { organizationId: 'org-restoring' },
+      { organizationId: 'org-nz' },
+    ] as never)
+    vi.mocked(syncPublicHolidays).mockImplementation(async (id) => {
+      if (id === 'org-broken') throw new Error('boom')
+      return null
+    })
+
+    const synced = await syncWorkshopHolidaysOnce(new Date('2026-11-09T04:15:00Z'))
+
+    expect(synced).toBe(2)
+    expect(vi.mocked(syncPublicHolidays).mock.calls.map(([id]) => id)).toEqual([
+      'org-au',
+      'org-broken',
+      'org-nz',
+    ])
+    expect(vi.mocked(refreshLinkedFeeds).mock.calls.map(([id]) => id)).toEqual(['org-au', 'org-nz'])
+  })
+})

+ 1 - 0
src/__tests__/setup.ts

@@ -102,5 +102,6 @@ vi.mock('next-intl', () => ({
     },
   }),
   useMessages: () => ({}),
+  useNow: () => new Date(),
   NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
 }))

+ 20 - 0
src/app/(authenticated)/calendar/page.tsx

@@ -8,6 +8,9 @@ import { getAvailableChannels } from '@/features/scheduled-messages/Lib/availabl
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { zonedDayKey } from '@/lib/timezone'
 import CalendarClient from '@/features/calendar/Components/CalendarClient'
+import { getTechnicians } from '@/features/workboard/Actions/technicianActions'
+import { canEditTimeOff } from '@/features/workshop-calendar/Lib/access.server'
+import { calendarEnabled } from '@/features/workshop-calendar/Lib/loadCalendar.server'
 import {
   getMonthGridDays,
   isCalendarView,
@@ -60,6 +63,22 @@ export default async function CalendarPage({
 
   const eventsResult = await getCalendarEvents(rangeKeys)
 
+  // Who can be set on leave, offered only to people who may edit the work
+  // board and only while the workshop tracks time off at all.
+  const timeOffAllowed =
+    ctx !== null &&
+    (await calendarEnabled(ctx.organizationId)) &&
+    (await canEditTimeOff(ctx.userId, ctx.role))
+  const technicians = timeOffAllowed ? await getTechnicians() : null
+  const timeOff =
+    technicians?.success && technicians.data
+      ? {
+          technicians: technicians.data
+            .filter((tech) => tech.isActive)
+            .map((tech) => ({ id: tech.id, name: tech.name, color: tech.color })),
+        }
+      : null
+
   const events = eventsResult.success && eventsResult.data ? eventsResult.data : []
 
   return (
@@ -74,6 +93,7 @@ export default async function CalendarPage({
           todayStr={todayStr}
           currencyCode={currencyCode}
           messageChannels={messageChannels}
+          timeOff={timeOff}
         />
       </div>
     </>

+ 137 - 0
src/app/(authenticated)/settings/opening-hours/opening-hours-settings.tsx

@@ -0,0 +1,137 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { CalendarCheck, UserX } from 'lucide-react'
+import { AppCard } from '@/components/app-card'
+import { DocsLink } from '@/components/docs-link'
+import { Switch } from '@/components/ui/switch'
+import { OpeningHoursCard } from '@/features/workshop-calendar/Components/OpeningHoursCard'
+import { PublicHolidaysCard } from '@/features/workshop-calendar/Components/PublicHolidaysCard'
+import {
+  HolidayFeedsCard,
+  type FeedSummary,
+} from '@/features/workshop-calendar/Components/HolidayFeedsCard'
+import { ClosedDaysCard } from '@/features/workshop-calendar/Components/ClosedDaysCard'
+import { TimeOffList } from '@/features/workshop-calendar/Components/TimeOffList'
+import type { TechnicianOption } from '@/features/workshop-calendar/Components/TimeOffDialog'
+import { setClosuresEnabled } from '@/features/workshop-calendar/Actions/holidayActions'
+import type { ClosureView } from '@/features/workshop-calendar/Actions/closureActions'
+import type { AbsenceView } from '@/features/workshop-calendar/Actions/absenceActions'
+import type { HolidayRow } from '@/features/workshop-calendar/Lib/holidays'
+import type { WeekHours } from '@/features/workshop-calendar/Lib/calendar'
+import { ReadOnlyBanner } from '../read-only-guard'
+import { useSettingsPermission } from '../settings-permission-context'
+
+export interface HolidaySettingsData {
+  enabled: boolean
+  week: WeekHours
+  year: number
+  country: string
+  regions: string[]
+  availableRegions: string[]
+  fetchedAt: string | null
+  fetchError: string | null
+  holidays: HolidayRow[]
+  feeds: FeedSummary[]
+}
+
+/**
+ * When the workshop is open: hours per weekday, then (behind one switch)
+ * public holidays, holiday calendars, its own closed days and time off.
+ */
+export function OpeningHoursSettings({
+  settings,
+  closures,
+  absences,
+  technicians,
+  today,
+  timeOffEditable,
+}: {
+  settings: HolidaySettingsData
+  closures: ClosureView[]
+  absences: AbsenceView[] | null
+  technicians: TechnicianOption[]
+  today: string
+  timeOffEditable: boolean
+}) {
+  const t = useTranslations('workshopCalendar')
+  const router = useRouter()
+  const { canEdit } = useSettingsPermission()
+  const [enabled, setEnabled] = useState(settings.enabled)
+
+  const toggle = async (next: boolean) => {
+    setEnabled(next)
+    const res = await setClosuresEnabled(next)
+    if (!res.success) {
+      setEnabled(!next)
+      toast.error(res.error ?? t('switch.failed'))
+      return
+    }
+    toast.success(t('switch.saved'))
+    router.refresh()
+  }
+
+  return (
+    <div className="space-y-6">
+      <ReadOnlyBanner />
+      <div>
+        <p className="text-sm text-muted-foreground">{t('page.description')}</p>
+        <DocsLink href="/docs/configuration/opening-hours" variant="hint" className="mt-1" />
+      </div>
+
+      <OpeningHoursCard week={settings.week} canEdit={canEdit} />
+
+      <AppCard id="closures-switch" icon={CalendarCheck} title={t('switch.title')}>
+        <label className="flex items-start gap-3">
+          <Switch
+            checked={enabled}
+            disabled={!canEdit}
+            onCheckedChange={toggle}
+            className="mt-0.5"
+            aria-label={t('switch.label')}
+          />
+          <span>
+            <span className="text-sm font-medium">{t('switch.label')}</span>
+            <span className="block text-xs text-muted-foreground">{t('switch.hint')}</span>
+          </span>
+        </label>
+      </AppCard>
+
+      {enabled && (
+        <>
+          <PublicHolidaysCard
+            country={settings.country}
+            regions={settings.regions}
+            availableRegions={settings.availableRegions}
+            holidays={settings.holidays}
+            fetchedAt={settings.fetchedAt}
+            fetchError={settings.fetchError}
+            year={settings.year}
+            canEdit={canEdit}
+          />
+          <HolidayFeedsCard feeds={settings.feeds} year={settings.year} canEdit={canEdit} />
+          <ClosedDaysCard closures={closures} today={today} canEdit={canEdit} />
+          {absences && (
+            <AppCard
+              id="time-off"
+              icon={UserX}
+              title={t('timeOff.title')}
+              description={t('timeOff.description')}
+            >
+              <TimeOffList
+                absences={absences}
+                technicians={technicians}
+                canEdit={timeOffEditable}
+                today={today}
+                onChanged={() => router.refresh()}
+              />
+            </AppCard>
+          )}
+        </>
+      )}
+    </div>
+  )
+}

+ 42 - 0
src/app/(authenticated)/settings/opening-hours/page.tsx

@@ -0,0 +1,42 @@
+import { getAuthContext } from '@/lib/get-auth-context'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { zonedDayKey } from '@/lib/timezone'
+import { getTechnicians } from '@/features/workboard/Actions/technicianActions'
+import { getHolidaySettings } from '@/features/workshop-calendar/Actions/holidayActions'
+import { listClosures } from '@/features/workshop-calendar/Actions/closureActions'
+import { listAbsences } from '@/features/workshop-calendar/Actions/absenceActions'
+import { canEditTimeOff } from '@/features/workshop-calendar/Lib/access.server'
+import { OpeningHoursSettings } from './opening-hours-settings'
+
+export default async function OpeningHoursSettingsPage() {
+  const auth = await getAuthContext()
+  const [settings, closures, absences, technicians, timeZone, timeOffEditable] = await Promise.all([
+    getHolidaySettings(),
+    listClosures(),
+    listAbsences(),
+    getTechnicians(),
+    auth ? workshopTimeZone(auth.organizationId) : Promise.resolve('UTC'),
+    auth ? canEditTimeOff(auth.userId, auth.role) : Promise.resolve(false),
+  ])
+  if (!settings.success || !settings.data) {
+    return <p className="text-sm text-muted-foreground">{settings.error}</p>
+  }
+
+  return (
+    <OpeningHoursSettings
+      settings={settings.data}
+      closures={closures.data ?? []}
+      // Null when this person may not see the work board's time off.
+      absences={absences.success ? (absences.data ?? []) : null}
+      technicians={
+        technicians.success && technicians.data
+          ? technicians.data
+              .filter((t) => t.isActive)
+              .map((t) => ({ id: t.id, name: t.name, color: t.color }))
+          : []
+      }
+      today={zonedDayKey(new Date(), timeZone)}
+      timeOffEditable={timeOffEditable}
+    />
+  )
+}

+ 9 - 28
src/app/(authenticated)/settings/workshop/workshop-settings.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import { useRef, useState } from 'react'
+import Link from 'next/link'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
@@ -112,9 +113,6 @@ export function WorkshopSettings({
   const [defaultUnit, setDefaultUnit] = useState(
     settings[SETTING_KEYS.INVENTORY_DEFAULT_UNIT] || ''
   )
-  const [workDayStart, setWorkDayStart] = useState(
-    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_START] || '07:00'
-  )
   // Unset means the default; a template saved empty means the plain name,
   // so the two are kept apart here rather than folded into one falsy value.
   const [titleTemplate, setTitleTemplate] = useState(
@@ -140,9 +138,6 @@ export function WorkshopSettings({
   }
   const titlePreview = resolveWorkOrderTitle(titleTemplate, SAMPLE_WORK_ORDER_TITLE_VALUES)
   const unknownTitleTags = unknownTokensIn(titleTemplate)
-  const [workDayEnd, setWorkDayEnd] = useState(
-    settings[SETTING_KEYS.WORKBOARD_WORK_DAY_END] || '15:00'
-  )
   const [defaultMarkupPercent, setDefaultMarkupPercent] = useState(
     settings[SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT] || '0'
   )
@@ -209,8 +204,6 @@ export function WorkshopSettings({
       [SETTING_KEYS.MEASURE_SYSTEM]: measureSystem,
       [SETTING_KEYS.ENGINE_SIZE_UNIT]: engineSizeUnit,
       [SETTING_KEYS.INVENTORY_DEFAULT_UNIT]: defaultUnit.trim(),
-      [SETTING_KEYS.WORKBOARD_WORK_DAY_START]: workDayStart,
-      [SETTING_KEYS.WORKBOARD_WORK_DAY_END]: workDayEnd,
       [SETTING_KEYS.WORK_ORDER_TITLE_TEMPLATE]: titleTemplate.trim(),
       [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
       [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory ? 'true' : 'false',
@@ -372,26 +365,14 @@ export function WorkshopSettings({
             </div>
           </div>
 
-          <div className="grid gap-4 sm:grid-cols-2">
-            <div className="space-y-2">
-              <Label htmlFor="workDayStart">{t('workshop.workDayStart')}</Label>
-              <Input
-                id="workDayStart"
-                type="time"
-                value={workDayStart}
-                onChange={(e) => setWorkDayStart(e.target.value)}
-              />
-            </div>
-            <div className="space-y-2">
-              <Label htmlFor="workDayEnd">{t('workshop.workDayEnd')}</Label>
-              <Input
-                id="workDayEnd"
-                type="time"
-                value={workDayEnd}
-                onChange={(e) => setWorkDayEnd(e.target.value)}
-              />
-            </div>
-          </div>
+          {/* Hours are set per weekday on their own page now, beside the
+              holidays and time off that shorten them. */}
+          <p className="text-sm text-muted-foreground">
+            {t('workshop.openingHoursMoved')}{' '}
+            <Link href="/settings/opening-hours" className="underline underline-offset-4">
+              {t('workshop.openingHoursLink')}
+            </Link>
+          </p>
 
           <Separator />
 

+ 17 - 4
src/app/(public)/b/[token]/booking-client.tsx

@@ -27,14 +27,22 @@ export function BookingClient({ token, initial }: { token: string; initial: Book
   const [note, setNote] = useState('')
   const [busy, setBusy] = useState(false)
 
+  // Times on the workshop's clock: next-intl would format in the server's
+  // zone, which put a 07:00 opening in Oslo at "6:00 AM".
   const when = (iso: string) =>
-    format.dateTime(new Date(iso), { dateStyle: 'full', timeStyle: 'short' })
-  const time = (iso: string) => format.dateTime(new Date(iso), { timeStyle: 'short' })
+    format.dateTime(new Date(iso), {
+      dateStyle: 'full',
+      timeStyle: 'short',
+      timeZone: page.timeZone,
+    })
+  const time = (iso: string) =>
+    format.dateTime(new Date(iso), { timeStyle: 'short', timeZone: page.timeZone })
   const dayLabel = (key: string) =>
-    format.dateTime(new Date(`${key}T12:00:00`), {
+    format.dateTime(new Date(`${key}T12:00:00Z`), {
       weekday: 'short',
       day: 'numeric',
       month: 'short',
+      timeZone: 'UTC',
     })
 
   const openDays = useMemo(() => page.days.filter((d) => d.status !== 'closed'), [page.days])
@@ -95,7 +103,12 @@ export function BookingClient({ token, initial }: { token: string; initial: Book
         )}
       </p>
       <p className="text-muted-foreground">
-        {t('due', { date: format.dateTime(new Date(page.dueAt), { dateStyle: 'long' }) })}
+        {t('due', {
+          date: format.dateTime(new Date(page.dueAt), {
+            dateStyle: 'long',
+            timeZone: page.timeZone,
+          }),
+        })}
       </p>
     </div>
   )

+ 5 - 2
src/app/(public)/portal/[orgId]/dashboard/page.tsx

@@ -1,5 +1,6 @@
 import { PortalShell } from '@/features/portal/Components/PortalShell'
-import { getPortalDashboard } from '@/features/portal/Actions/portalActions'
+import { getPortalDashboard, getPortalOpeningHours } from '@/features/portal/Actions/portalActions'
+import { PortalOpeningHours } from '@/features/portal/Components/PortalOpeningHours'
 import { AppCard } from '@/components/app-card'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
@@ -33,7 +34,7 @@ export default async function PortalDashboardPage({
   const tStatus = await getTranslations('portal.status')
   const tQuoteStatus = await getTranslations('quotes.statusLabels')
   const tInvoices = await getTranslations('portal.invoices')
-  const result = await getPortalDashboard()
+  const [result, hours] = await Promise.all([getPortalDashboard(), getPortalOpeningHours()])
 
   if (!result.success || !result.data) {
     return (
@@ -286,6 +287,8 @@ export default async function PortalDashboardPage({
             </ul>
           </AppCard>
         )}
+
+        {hours.success && hours.data && <PortalOpeningHours hours={hours.data} wide />}
       </div>
     </PortalShell>
   )

+ 19 - 2
src/app/(public)/portal/[orgId]/request-service/page.tsx

@@ -1,9 +1,12 @@
 import { PortalShell } from '@/features/portal/Components/PortalShell'
 import { ServiceRequestForm } from '@/features/portal/Components/ServiceRequestForm'
 import {
+  getPortalClosedDays,
+  getPortalOpeningHours,
   getPortalVehicles,
   getPortalServiceRequests,
 } from '@/features/portal/Actions/portalActions'
+import { PortalOpeningHours } from '@/features/portal/Components/PortalOpeningHours'
 import { Badge } from '@/components/ui/badge'
 import { AppCard } from '@/components/app-card'
 import { getTranslations } from 'next-intl/server'
@@ -22,9 +25,11 @@ export default async function PortalRequestServicePage({
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.requestService')
   const tStatus = await getTranslations('portal.status')
-  const [vehiclesResult, requestsResult] = await Promise.all([
+  const [vehiclesResult, requestsResult, closedResult, hoursResult] = await Promise.all([
     getPortalVehicles(),
     getPortalServiceRequests(),
+    getPortalClosedDays(),
+    getPortalOpeningHours(),
   ])
 
   if (!vehiclesResult.success || !vehiclesResult.data) {
@@ -66,7 +71,19 @@ export default async function PortalRequestServicePage({
 
         {/* The form handles the no-vehicles case itself: it opens in
             "describe your vehicle" mode, so new customers can still ask. */}
-        <ServiceRequestForm orgId={orgId} vehicles={vehicles} />
+        {/* The hours sit beside the form, where the customer picks a day. */}
+        <div className="grid items-start gap-6 lg:grid-cols-3">
+          <div className="lg:col-span-2">
+            <ServiceRequestForm
+              orgId={orgId}
+              vehicles={vehicles}
+              closedDays={closedResult.success ? (closedResult.data ?? []) : []}
+            />
+          </div>
+          {hoursResult.success && hoursResult.data && (
+            <PortalOpeningHours hours={hoursResult.data} />
+          )}
+        </div>
 
         {requests.length > 0 && (
           <AppCard title={t('yourRequests')}>

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

@@ -132,6 +132,7 @@ const docsMap: Record<string, string> = {
   '/tire-hotel/storage': '/docs/features/tire-hotel',
   '/tire-hotel/forecast': '/docs/features/tire-hotel',
   '/settings/tire-hotel': '/docs/features/tire-hotel',
+  '/settings/opening-hours': '/docs/configuration/opening-hours',
 }
 
 const breadcrumbMap: Record<string, BreadcrumbSegment[]> = {

+ 1 - 0
src/cronTasks.ts

@@ -12,4 +12,5 @@ export { checkDueReminders } from './lib/cron/reminder-alerts'
 export { processScheduledMessages } from './lib/cron/scheduled-messages'
 export { processIntegrationJobs, cleanupIntegrationLogs } from './lib/cron/integration-jobs'
 export { processBackupRuns } from './lib/cron/backup-runs'
+export { syncWorkshopHolidays } from './lib/cron/workshop-holidays'
 export { startHeartbeat } from './lib/cron/heartbeat'

+ 94 - 1
src/features/calendar/Actions/calendarActions.ts

@@ -8,8 +8,19 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getManifest } from '@/integrations/registry'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { addZonedDays, zonedDate, zonedDayKey, zonedParts } from '@/lib/timezone'
+import { loadWorkshopCalendar } from '@/features/workshop-calendar/Lib/loadCalendar.server'
+import { type ClosedPeriod, eachDay } from '@/features/workshop-calendar/Lib/calendar'
 
-export type CalendarEventType = 'service' | 'reminder' | 'quote' | 'message' | 'external'
+export type CalendarEventType =
+  | 'service'
+  | 'reminder'
+  | 'quote'
+  | 'message'
+  | 'external'
+  /** A day the workshop is closed: a public holiday, a holiday calendar, its own closure. */
+  | 'closure'
+  /** A technician on leave or out of office. */
+  | 'absence'
 
 export type CalendarEvent = {
   id: string
@@ -191,6 +202,86 @@ export async function getCalendarEvents(params: { start: string; end: string })
       const vehicleLabel = (v: { year: number; make: string; model: string } | null) =>
         v ? `${v.year} ${v.make} ${v.model}` : ''
 
+      // Closed days and time off, one all-day (or part-day) entry per day.
+      const { calendar } = await loadWorkshopCalendar(organizationId, {
+        from: params.start,
+        to: params.end,
+      })
+      const tc = await getTranslations('workshopCalendar')
+      const technicianNames = calendar.absences.length
+        ? new Map(
+            (
+              await db.technician.findMany({
+                where: {
+                  organizationId,
+                  id: { in: [...new Set(calendar.absences.map((a) => a.technicianId))] },
+                },
+                select: { id: true, name: true },
+              })
+            ).map((tech) => [tech.id, tech.name])
+          )
+        : new Map<string, string>()
+      const dayEntries = (
+        stretch: Pick<ClosedPeriod, 'startDate' | 'endDate' | 'startTime' | 'endTime'>,
+        make: (day: string, time: string | null, endTime: string | null) => CalendarEvent
+      ) => {
+        const first = stretch.startDate < params.start ? params.start : stretch.startDate
+        const last = stretch.endDate > params.end ? params.end : stretch.endDate
+        if (first > last) return []
+        return eachDay(first, last).map((day) => {
+          const time = day === stretch.startDate ? (stretch.startTime ?? null) : null
+          const until = day === stretch.endDate ? (stretch.endTime ?? null) : null
+          // Part of a day is a timed block; a whole day stays an all-day item.
+          return time || until
+            ? make(day, time ?? '00:00', until ?? '23:59')
+            : make(day, null, null)
+        })
+      }
+      const blank = {
+        vehicleId: null,
+        vehicleLabel: '',
+        customerName: null,
+        invoiceNumber: null,
+        amount: null,
+      }
+      // A holiday listed by two sources is one entry a day.
+      const seenClosure = new Set<string>()
+      const closureEvents: CalendarEvent[] = calendar.closures.flatMap((c, i) =>
+        dayEntries(c, (day, time, endTime) => ({
+          ...blank,
+          id: `closure:${i}:${day}`,
+          title: c.name,
+          date: day,
+          time,
+          endTime,
+          type: 'closure',
+          status: 'closed',
+          allDay: time === null,
+          source: c.source,
+        })).filter((event) => {
+          const id = `${event.date}|${event.title.toLocaleLowerCase()}|${event.time ?? ''}`
+          if (seenClosure.has(id)) return false
+          seenClosure.add(id)
+          return true
+        })
+      )
+      const absenceEvents: CalendarEvent[] = calendar.absences.flatMap((a) =>
+        dayEntries(a, (day, time, endTime) => ({
+          ...blank,
+          id: `absence:${a.id}:${day}`,
+          title: tc('calendar.absenceTitle', {
+            name: technicianNames.get(a.technicianId) ?? '',
+            reason: tc.has(`reasons.${a.reason}`) ? tc(`reasons.${a.reason}`) : a.reason,
+          }),
+          date: day,
+          time,
+          endTime,
+          type: 'absence',
+          status: 'away',
+          allDay: time === null,
+        }))
+      )
+
       const ti = await getTranslations('integrations')
       const externalEvents: CalendarEvent[] = []
       for (const e of external) {
@@ -231,6 +322,8 @@ export async function getCalendarEvents(params: { start: string; end: string })
       }
 
       const events: CalendarEvent[] = [
+        ...closureEvents,
+        ...absenceEvents,
         ...externalEvents,
         ...services.map((s) => {
           const startsAt = s.startDateTime

+ 69 - 3
src/features/calendar/Components/CalendarClient.tsx

@@ -3,6 +3,7 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { usePathname } from 'next/navigation'
 import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
 import { useDayFormatter } from './useDayFormatter'
 import { CalendarDays, CalendarOff, Plus } from 'lucide-react'
 import { useDateSettings } from '@/components/date-settings-context'
@@ -15,6 +16,11 @@ import { ReminderFormDialog } from '@/features/vehicles/Components/ReminderFormD
 import { NewQuoteDialog } from '@/features/quotes/Components/NewQuoteDialog'
 import { ScheduleMessageDialog } from '@/features/scheduled-messages/Components/ScheduleMessageDialog'
 import type { MessageChannel } from '@/features/scheduled-messages/Schema/scheduledMessageSchema'
+import {
+  TimeOffDialog,
+  type TechnicianOption,
+} from '@/features/workshop-calendar/Components/TimeOffDialog'
+import { listAbsences, type AbsenceView } from '@/features/workshop-calendar/Actions/absenceActions'
 import { getCalendarEvents } from '../Actions/calendarActions'
 import type { CalendarEvent, CalendarEventType } from '../Actions/calendarActions'
 import {
@@ -53,9 +59,22 @@ interface CalendarClientProps {
   currencyCode: string
   /** Channels the workshop can actually send on, resolved on the server */
   messageChannels: MessageChannel[]
+  /**
+   * The technicians this person may set on leave; null when they may not, or
+   * the workshop does not track time off.
+   */
+  timeOff?: { technicians: TechnicianOption[] } | null
 }
 
-const ALL_TYPES: CalendarEventType[] = ['service', 'reminder', 'quote', 'message', 'external']
+const ALL_TYPES: CalendarEventType[] = [
+  'service',
+  'reminder',
+  'quote',
+  'message',
+  'external',
+  'closure',
+  'absence',
+]
 
 /** Minutes a work order started from a time slot is booked for. */
 const SLOT_WORK_ORDER_MINUTES = 60
@@ -83,6 +102,7 @@ export default function CalendarClient({
   todayStr,
   currencyCode,
   messageChannels,
+  timeOff = null,
 }: CalendarClientProps) {
   const t = useTranslations('calendar')
   const tWorkOrders = useTranslations('workOrders.list')
@@ -109,6 +129,8 @@ export default function CalendarClient({
     quote: true,
     message: true,
     external: true,
+    closure: true,
+    absence: true,
   })
 
   // Dialogs the calendar can open
@@ -121,6 +143,11 @@ export default function CalendarClient({
   const [menuDateStr, setMenuDateStr] = useState<string | undefined>(undefined)
   const [menuTime, setMenuTime] = useState<string | undefined>(undefined)
   const [daySheetOpen, setDaySheetOpen] = useState(false)
+  /** The leave being added (absence null) or edited, and the day it starts on. */
+  const [timeOffEdit, setTimeOffEdit] = useState<{
+    date: string
+    absence: AbsenceView | null
+  } | null>(null)
 
   const dateStr = toLocalDateStr(date)
   const range = useMemo(() => visibleRange(view, date, weekStartDay), [view, date, weekStartDay])
@@ -177,6 +204,8 @@ export default function CalendarClient({
       quote: 0,
       message: 0,
       external: 0,
+      closure: 0,
+      absence: 0,
     }
     const startKey = toLocalDateStr(range.start)
     const endKey = toLocalDateStr(range.end)
@@ -326,6 +355,9 @@ export default function CalendarClient({
         case 'message':
           setShowMessageDialog(true)
           break
+        case 'timeOff':
+          setTimeOffEdit({ date: dateStr, absence: null })
+          break
       }
     },
     [dateStr, todayStr, openWorkOrderPicker]
@@ -367,8 +399,25 @@ export default function CalendarClient({
         setMenuTime(time)
         setShowMessageDialog(true)
       },
+      onTimeOff: timeOff ? (day) => setTimeOffEdit({ date: day, absence: null }) : undefined,
     }),
-    [openWorkOrderPicker]
+    [openWorkOrderPicker, timeOff]
+  )
+
+  /** A leave entry is one row a day; its id carries the stretch it belongs to. */
+  const editAbsence = useCallback(
+    async (event: CalendarEvent) => {
+      const id = event.id.split(':')[1]
+      const res = await listAbsences({ from: event.date })
+      const absence = res.success ? res.data?.find((a) => a.id === id) : undefined
+      if (!absence) {
+        toast.error(res.error ?? t('timeOffMissing'))
+        refresh()
+        return
+      }
+      setTimeOffEdit({ date: absence.startDate, absence })
+    },
+    [refresh, t]
   )
 
   // Noon parse, so the seeded day survives any timezone the workshop sits in.
@@ -461,7 +510,11 @@ export default function CalendarClient({
   })()
 
   return (
-    <EventPeekProvider currencyCode={currencyCode} onRefresh={refresh}>
+    <EventPeekProvider
+      currencyCode={currencyCode}
+      onRefresh={refresh}
+      onEditAbsence={timeOff ? editAbsence : undefined}
+    >
       {/*
         A definite height, not flex-1: nothing above this in the layout has
         one (the shell is min-h-svh), so a flexed calendar would grow to the
@@ -483,6 +536,7 @@ export default function CalendarClient({
           onToggleSidebar={() => updatePreferences({ sidebarOpen: !preferences.sidebarOpen })}
           onPreferenceChange={updatePreferences}
           onCreate={handleCreate}
+          canSetTimeOff={timeOff !== null}
         />
         <div className="flex min-h-0 flex-1">
           {preferences.sidebarOpen && (
@@ -589,6 +643,18 @@ export default function CalendarClient({
         title={t('selectVehicle')}
         redirectQuery={workOrderQuery}
       />
+      {timeOff && (
+        <TimeOffDialog
+          open={timeOffEdit !== null}
+          onOpenChange={(open) => {
+            if (!open) setTimeOffEdit(null)
+          }}
+          absence={timeOffEdit?.absence ?? null}
+          technicians={timeOff.technicians}
+          defaultDate={timeOffEdit?.date ?? dateStr}
+          onSaved={refresh}
+        />
+      )}
     </EventPeekProvider>
   )
 }

+ 15 - 2
src/features/calendar/Components/CalendarSidebar.tsx

@@ -9,7 +9,18 @@ import type { CalendarEventType } from '../Actions/calendarActions'
 
 export type TypeFilters = Record<CalendarEventType, boolean>
 
-const FILTER_ORDER: CalendarEventType[] = ['service', 'reminder', 'quote', 'message', 'external']
+const FILTER_ORDER: CalendarEventType[] = [
+  'service',
+  'reminder',
+  'quote',
+  'message',
+  'closure',
+  'absence',
+  'external',
+]
+
+/** Shown only when there is something of the kind in view. */
+const ONLY_WHEN_PRESENT: CalendarEventType[] = ['external', 'closure', 'absence']
 
 const FILTER_LABEL: Record<CalendarEventType, string> = {
   service: 'services',
@@ -17,6 +28,8 @@ const FILTER_LABEL: Record<CalendarEventType, string> = {
   quote: 'quotes',
   message: 'messages',
   external: 'external',
+  closure: 'closures',
+  absence: 'absences',
 }
 
 interface CalendarSidebarProps {
@@ -74,7 +87,7 @@ export function CalendarSidebar({
         </div>
         <ul className="space-y-0.5">
           {FILTER_ORDER.map((type) => {
-            if (type === 'external' && counts.external === 0) return null
+            if (ONLY_WHEN_PRESENT.includes(type) && counts[type] === 0) return null
             const palette = typePalette(type)
             return (
               <li key={type}>

+ 16 - 2
src/features/calendar/Components/CalendarToolbar.tsx

@@ -11,6 +11,7 @@ import {
   PanelLeft,
   Plus,
   Send,
+  UserX,
   Wrench,
 } from 'lucide-react'
 import { Button } from '@/components/ui/button'
@@ -29,7 +30,7 @@ import { cn } from '@/lib/utils'
 import type { CalendarView } from '../Lib/calendar-range'
 import type { CalendarPreferences } from './useCalendarPreferences'
 
-export type CreateKind = 'workOrder' | 'reminder' | 'quote' | 'message'
+export type CreateKind = 'workOrder' | 'reminder' | 'quote' | 'message' | 'timeOff'
 
 /** The keys the view switcher shows and the keyboard listens for. */
 export const VIEW_SHORTCUTS: Record<CalendarView, string> = {
@@ -56,6 +57,8 @@ interface CalendarToolbarProps {
   onToggleSidebar: () => void
   onPreferenceChange: (patch: Partial<CalendarPreferences>) => void
   onCreate: (kind: CreateKind) => void
+  /** Whether this person may set technicians on leave, and the workshop tracks it. */
+  canSetTimeOff?: boolean
 }
 
 /**
@@ -76,8 +79,10 @@ export function CalendarToolbar({
   onToggleSidebar,
   onPreferenceChange,
   onCreate,
+  canSetTimeOff = false,
 }: CalendarToolbarProps) {
   const t = useTranslations('calendar')
+  const tTimeOff = useTranslations('workshopCalendar.timeOff')
 
   return (
     <div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/70 px-2 py-2 sm:px-3">
@@ -175,7 +180,7 @@ export function CalendarToolbar({
               <span className="hidden sm:inline">{t('toolbar.create')}</span>
             </Button>
           </DropdownMenuTrigger>
-          <DropdownMenuContent align="end" className="w-52">
+          <DropdownMenuContent align="end" className="w-60">
             <DropdownMenuItem onClick={() => onCreate('workOrder')}>
               <Wrench className="mr-2 h-4 w-4" />
               {t('contextMenu.newWorkOrder')}
@@ -192,6 +197,15 @@ export function CalendarToolbar({
               <Send className="mr-2 h-4 w-4" />
               {t('contextMenu.scheduleMessage')}
             </DropdownMenuItem>
+            {canSetTimeOff && (
+              <>
+                <DropdownMenuSeparator />
+                <DropdownMenuItem onClick={() => onCreate('timeOff')}>
+                  <UserX className="mr-2 h-4 w-4" />
+                  {tTimeOff('add')}
+                </DropdownMenuItem>
+              </>
+            )}
           </DropdownMenuContent>
         </DropdownMenu>
       </div>

+ 13 - 1
src/features/calendar/Components/DayContextMenu.tsx

@@ -2,7 +2,7 @@
 
 import type { ReactNode } from 'react'
 import { useTranslations } from 'next-intl'
-import { Bell, ExternalLink, FileText, Send, Wrench } from 'lucide-react'
+import { Bell, ExternalLink, FileText, Send, UserX, Wrench } from 'lucide-react'
 import {
   ContextMenu,
   ContextMenuContent,
@@ -29,6 +29,8 @@ export interface DayActions {
   onNewReminder: (dateStr: string, time?: string) => void
   onNewQuote: (dateStr: string, time?: string) => void
   onScheduleMessage: (dateStr: string, time?: string) => void
+  /** Set a technician on leave from this day. Absent when this person may not. */
+  onTimeOff?: (dateStr: string) => void
 }
 
 /** How many of the day's events the right-click menu lists before it stops */
@@ -62,6 +64,7 @@ export function DayContextMenu({
   children: ReactNode
 }) {
   const t = useTranslations('calendar')
+  const tTimeOff = useTranslations('workshopCalendar.timeOff')
   const { formatDate } = useFormatDate()
   const { timeFormat } = useDateSettings()
   const { openPeek } = useEventPeek()
@@ -94,6 +97,15 @@ export function DayContextMenu({
           <Send className="mr-2 h-4 w-4" />
           {t('contextMenu.scheduleMessage')}
         </ContextMenuItem>
+        {actions.onTimeOff && (
+          <>
+            <ContextMenuSeparator />
+            <ContextMenuItem onClick={() => actions.onTimeOff?.(dateStr)}>
+              <UserX className="mr-2 h-4 w-4" />
+              {tTimeOff('add')}
+            </ContextMenuItem>
+          </>
+        )}
         {events.length > 0 && (
           <>
             <ContextMenuSeparator />

+ 17 - 5
src/features/calendar/Components/EventChip.tsx

@@ -2,11 +2,12 @@
 
 import type { CSSProperties, MouseEvent, ReactNode } from 'react'
 import { Check } from 'lucide-react'
+import { useTranslations } from 'next-intl'
 import { useDateSettings } from '@/components/date-settings-context'
 import { formatClock } from '@/features/workboard/utils/clock'
 import { cn } from '@/lib/utils'
 import { timeToMinutes } from '../Lib/calendar-range'
-import { eventPalette, isEventDone } from './calendar-utils'
+import { eventPalette, isEventDone, openEndedSide } from './calendar-utils'
 import { EventContextMenu } from './EventContextMenu'
 import { useEventPeek } from './EventPeek'
 import type { CalendarEvent } from '../Actions/calendarActions'
@@ -42,6 +43,18 @@ export function EventChip({
   const done = isEventDone(event)
   const start = event.time ? formatClock(timeToMinutes(event.time), timeFormat) : null
   const end = event.endTime ? formatClock(timeToMinutes(event.endTime), timeFormat) : null
+  const t = useTranslations('calendar.range')
+  const side = openEndedSide(event)
+  const range =
+    side === 'from'
+      ? t('from', { time: start ?? '' })
+      : side === 'until'
+        ? t('until', { time: end ?? '' })
+        : start
+          ? end
+            ? `${start} – ${end}`
+            : start
+          : null
 
   const onClick = (e: MouseEvent<HTMLButtonElement>) => {
     e.stopPropagation()
@@ -105,7 +118,7 @@ export function EventChip({
         type="button"
         onClick={onClick}
         onContextMenu={onContextMenu}
-        title={end ? `${start} – ${end} ${event.title}` : label}
+        title={range ? `${range} ${event.title}` : event.title}
         style={style}
         className={cn(
           'absolute flex flex-col overflow-hidden rounded-md border px-1.5 py-1 text-left text-xs leading-tight shadow-sm transition-[filter,box-shadow] hover:z-20 hover:shadow-md',
@@ -121,8 +134,7 @@ export function EventChip({
         </span>
         {!compact && (
           <span className="truncate text-[11px] opacity-90">
-            {start}
-            {end ? ` – ${end}` : ''}
+            {range}
             {event.vehicleLabel ? ` · ${event.vehicleLabel}` : ''}
           </span>
         )}
@@ -141,7 +153,7 @@ export function EventChip({
         )}
       >
         <span className="w-[6.5rem] shrink-0 pt-0.5 text-xs tabular-nums text-muted-foreground">
-          {start ? (end ? `${start} – ${end}` : start) : null}
+          {range}
         </span>
         <span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', palette.dot)} />
         <span className="min-w-0 flex-1">

+ 60 - 10
src/features/calendar/Components/EventPeek.tsx

@@ -22,10 +22,13 @@ import {
   Hash,
   Mail,
   MessageSquare,
+  Pencil,
   Send,
   User,
   Wrench,
   X,
+  CalendarOff,
+  UserX,
 } from 'lucide-react'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
@@ -34,7 +37,7 @@ import { useDateSettings } from '@/components/date-settings-context'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { formatClock } from '@/features/workboard/utils/clock'
 import { timeToMinutes } from '../Lib/calendar-range'
-import { eventPalette, getEventLink, isEventDone } from './calendar-utils'
+import { eventPalette, getEventLink, isEventDone, openEndedSide } from './calendar-utils'
 import { ExternalEventDialog } from './ExternalEventDialog'
 import type { CalendarEvent } from '../Actions/calendarActions'
 
@@ -56,6 +59,12 @@ interface PeekApi {
   refresh: () => void
 }
 
+/** What the card can do beyond linking out, handed down by the calendar. */
+interface PeekActions {
+  /** Open a technician's leave for editing. Absent when this person may not. */
+  onEditAbsence?: (event: CalendarEvent) => void
+}
+
 const PeekContext = createContext<PeekApi>({
   openPeek: () => undefined,
   closePeek: () => undefined,
@@ -69,10 +78,12 @@ export function useEventPeek() {
 export function EventPeekProvider({
   currencyCode,
   onRefresh,
+  onEditAbsence,
   children,
 }: {
   currencyCode: string
   onRefresh?: () => void
+  onEditAbsence?: PeekActions['onEditAbsence']
   children: ReactNode
 }) {
   const [peek, setPeek] = useState<PeekState | null>(null)
@@ -111,7 +122,21 @@ export function EventPeekProvider({
           className="w-[22rem] max-w-[calc(100vw-1.5rem)] p-0 shadow-xl"
           onOpenAutoFocus={(e) => e.preventDefault()}
         >
-          {peek && <PeekCard event={peek.event} currencyCode={currencyCode} onClose={closePeek} />}
+          {peek && (
+            <PeekCard
+              event={peek.event}
+              currencyCode={currencyCode}
+              onClose={closePeek}
+              onEditAbsence={
+                onEditAbsence
+                  ? (event) => {
+                      closePeek()
+                      onEditAbsence(event)
+                    }
+                  : undefined
+              }
+            />
+          )}
         </PopoverContent>
       </Popover>
       <ExternalEventDialog event={external} onClose={() => setExternal(null)} />
@@ -129,6 +154,10 @@ function typeIcon(event: CalendarEvent) {
       return FileText
     case 'message':
       return event.channel === 'email' ? Mail : event.channel === 'sms' ? MessageSquare : Send
+    case 'closure':
+      return CalendarOff
+    case 'absence':
+      return UserX
     default:
       return CalendarClock
   }
@@ -157,6 +186,8 @@ function statusKey(event: CalendarEvent): string {
     return event.status === 'sent' ? 'sent' : event.status === 'approved' ? 'approved' : 'draft'
   }
   if (event.type === 'external') return 'busy'
+  if (event.type === 'closure') return 'closed'
+  if (event.type === 'absence') return 'away'
   return event.status === 'completed' ? 'done' : event.status === 'overdue' ? 'overdue' : 'upcoming'
 }
 
@@ -164,23 +195,33 @@ function PeekCard({
   event,
   currencyCode,
   onClose,
+  onEditAbsence,
 }: {
   event: CalendarEvent
   currencyCode: string
   onClose: () => void
+  onEditAbsence?: PeekActions['onEditAbsence']
 }) {
   const t = useTranslations('calendar')
+  const tc = useTranslations('common')
   const format = useDayFormatter()
   const { timeFormat } = useDateSettings()
   const formatCurrency = useFormatCurrency()
   const palette = eventPalette(event)
   const Icon = typeIcon(event)
   const day = new Date(`${event.date}T12:00:00`)
-  const when = event.time
-    ? event.endTime
-      ? `${formatClock(timeToMinutes(event.time), timeFormat)} – ${formatClock(timeToMinutes(event.endTime), timeFormat)}`
-      : formatClock(timeToMinutes(event.time), timeFormat)
-    : t('events.external.allDay')
+  const side = openEndedSide(event)
+  const when = side
+    ? side === 'from'
+      ? t('range.from', { time: formatClock(timeToMinutes(event.time ?? '00:00'), timeFormat) })
+      : t('range.until', {
+          time: formatClock(timeToMinutes(event.endTime ?? '00:00'), timeFormat),
+        })
+    : event.time
+      ? event.endTime
+        ? `${formatClock(timeToMinutes(event.time), timeFormat)} – ${formatClock(timeToMinutes(event.endTime), timeFormat)}`
+        : formatClock(timeToMinutes(event.time), timeFormat)
+      : t('events.external.allDay')
   const done = isEventDone(event)
 
   return (
@@ -220,9 +261,12 @@ function PeekCard({
           <Badge variant="secondary" className={`text-[10px] ${palette.chip}`}>
             {t(`events.type.${event.type}`)}
           </Badge>
-          <Badge variant="outline" className="text-[10px]">
-            {t(`events.status.${statusKey(event)}`)}
-          </Badge>
+          {/* "Closed" and "Leave" already say it all; a second badge repeated them. */}
+          {event.type !== 'closure' && event.type !== 'absence' && (
+            <Badge variant="outline" className="text-[10px]">
+              {t(`events.status.${statusKey(event)}`)}
+            </Badge>
+          )}
         </div>
 
         <dl className="mt-3 space-y-1.5 text-sm">
@@ -256,6 +300,12 @@ function PeekCard({
         </dl>
 
         <div className="mt-4 flex items-center justify-end gap-2">
+          {event.type === 'absence' && onEditAbsence && (
+            <Button size="sm" variant="outline" onClick={() => onEditAbsence(event)}>
+              <Pencil className="mr-1.5 h-3.5 w-3.5" />
+              {tc('buttons.edit')}
+            </Button>
+          )}
           <Button asChild size="sm">
             <Link href={getEventLink(event)}>
               {t(`peek.open.${event.type}`)}

+ 23 - 0
src/features/calendar/Components/calendar-utils.ts

@@ -15,6 +15,8 @@ export function getEventLink(event: {
   vehicleId: string | null
 }): string {
   if (event.type === 'external') return '/settings/integrations'
+  if (event.type === 'closure') return '/settings/opening-hours'
+  if (event.type === 'absence') return '/work-board'
   if (event.type === 'quote') return `/quotes/${event.id}`
   if (event.type === 'message') return '/messages?tab=scheduled'
   if (event.type === 'reminder')
@@ -124,6 +126,8 @@ export const TYPE_HUE: Record<CalendarEvent['type'], Hue> = {
   quote: 'violet',
   message: 'teal',
   external: 'slate',
+  closure: 'red',
+  absence: 'orange',
 }
 
 export function typePalette(type: CalendarEvent['type']): EventPalette {
@@ -154,6 +158,10 @@ export function eventHue(event: Pick<CalendarEvent, 'type' | 'status'>): Hue {
       return event.status === 'approved' ? 'emerald' : 'violet'
     case 'message':
       return event.status === 'failed' ? 'red' : 'teal'
+    case 'closure':
+      return 'red'
+    case 'absence':
+      return 'orange'
     default:
       return 'slate'
   }
@@ -172,6 +180,21 @@ export function isEventDone(event: Pick<CalendarEvent, 'type' | 'status'>): bool
   )
 }
 
+/**
+ * A closure or time off that takes the rest of a day is drawn up to 23:59,
+ * and one that ends partway through starts at midnight. Neither clock is
+ * something anyone set, so the label says "From 12:00" or "Until 14:00".
+ */
+export function openEndedSide(
+  event: Pick<CalendarEvent, 'type' | 'time' | 'endTime'>
+): 'from' | 'until' | null {
+  if (event.type !== 'closure' && event.type !== 'absence') return null
+  if (!event.time) return null
+  if (event.endTime === '23:59') return 'from'
+  if (event.time === '00:00' && event.endTime) return 'until'
+  return null
+}
+
 /** All-day items come first, then by clock time, then by title. */
 export function compareEvents(a: CalendarEvent, b: CalendarEvent): number {
   if (!a.time !== !b.time) return a.time ? 1 : -1

+ 70 - 22
src/features/inspection-reminders/Lib/booking.ts

@@ -9,6 +9,12 @@ import {
   zonedDayKey,
 } from '@/lib/timezone'
 import type { InspectionReminderSettings } from './settings'
+import {
+  type Interval,
+  type WorkshopCalendar,
+  dayState,
+  openIntervalsOn,
+} from '@/features/workshop-calendar/Lib/calendar'
 
 /**
  * What a customer may book from a reminder link: whole slots of the
@@ -31,6 +37,8 @@ export interface BookableDay {
   status: DayStatus
   /** ISO start times that are free. Empty when full or closed. */
   starts: string[]
+  /** Why a closed day is closed, when a holiday or closure says ("Christmas Day"). */
+  closedFor?: string | null
 }
 
 const STEP_MINUTES = 30
@@ -74,39 +82,75 @@ export function firstBookableDay(now: Date, leadDays: number, timeZone: string):
   return addZonedDays(startOfZonedDay(now, timeZone), Math.max(1, leadDays), timeZone)
 }
 
+/**
+ * When the shop takes bookings on `day`: its open stretches from the calendar
+ * (hours per weekday, less closures), or the one usual day on weekdays when
+ * there is no calendar.
+ */
+function bookingStretches(
+  day: Date,
+  settings: InspectionReminderSettings,
+  calendar: WorkshopCalendar | undefined
+): Interval[] {
+  const zone = settings.timeZone
+  if (calendar) return openIntervalsOn(calendar, dayKey(day, zone))
+  if (!settings.workingHours.includeWeekends && isZonedWeekend(day, zone)) return []
+  return [
+    {
+      start: atZonedTime(day, settings.workingHours.start, zone),
+      end: atZonedTime(day, settings.workingHours.end, zone),
+    },
+  ]
+}
+
+/** The grid of start times in a stretch: every STEP_MINUTES from its start. */
+function startsIn(stretch: Interval, durationMinutes: number): Date[] {
+  const out: Date[] = []
+  for (
+    let cursor = stretch.start;
+    cursor.getTime() + durationMinutes * 60_000 <= stretch.end.getTime();
+    cursor = new Date(cursor.getTime() + STEP_MINUTES * 60_000)
+  ) {
+    out.push(cursor)
+  }
+  return out
+}
+
 /**
  * Every day in the booking window with its free start times. A start is
  * free when more resources are free than the walk-in reserve keeps back.
+ * Closed days, holidays and closed hours come from the calendar when given.
  */
 export function bookableDays(input: {
   now: Date
   settings: InspectionReminderSettings
   resources: Resource[]
   bookings: Booking[]
+  calendar?: WorkshopCalendar
 }): BookableDay[] {
-  const { settings, resources, bookings } = input
+  const { settings, resources, bookings, calendar } = input
   const zone = settings.timeZone
   const days: BookableDay[] = []
   const first = firstBookableDay(input.now, settings.leadDays, zone)
   const totalDays = settings.horizonWeeks * 7
   for (let i = 0; i < totalDays; i++) {
     const day = addZonedDays(first, i, zone)
-    if (!settings.workingHours.includeWeekends && isZonedWeekend(day, zone)) {
-      days.push({ date: dayKey(day, zone), status: 'closed', starts: [] })
+    const stretches = bookingStretches(day, settings, calendar)
+    if (stretches.length === 0) {
+      const closedFor = calendar
+        ? (dayState(calendar, dayKey(day, zone)).closures[0]?.name ?? null)
+        : null
+      days.push({ date: dayKey(day, zone), status: 'closed', starts: [], closedFor })
       continue
     }
-    const open = atZonedTime(day, settings.workingHours.start, zone)
-    const close = atZonedTime(day, settings.workingHours.end, zone)
     const starts: string[] = []
     let possible = 0
-    for (
-      let cursor = open;
-      cursor.getTime() + settings.durationMinutes * 60_000 <= close.getTime();
-      cursor = new Date(cursor.getTime() + STEP_MINUTES * 60_000)
-    ) {
-      possible += 1
-      const free = freeResources(cursor, settings.durationMinutes, resources, bookings)
-      if (free.length > settings.walkInReserve) starts.push(cursor.toISOString())
+    for (const stretch of stretches) {
+      for (const cursor of startsIn(stretch, settings.durationMinutes)) {
+        possible += 1
+        const free = freeResources(cursor, settings.durationMinutes, resources, bookings)
+        if (free.length > settings.walkInReserve) starts.push(cursor.toISOString())
+      }
     }
     const status: DayStatus =
       starts.length === 0 ? 'full' : starts.length * 2 < possible ? 'limited' : 'open'
@@ -123,8 +167,11 @@ export async function availabilityFor(
 ): Promise<{ resources: Resource[]; days: BookableDay[]; firstStart: string | null }> {
   const resources = await bookingResources(organizationId)
   if (resources.length === 0) return { resources, days: [], firstStart: null }
-  const { bookings } = await loadBookingContext(organizationId, now)
-  const days = bookableDays({ now, settings, resources, bookings })
+  // The whole window the page offers: lead days, the horizon, and a day spare.
+  const { bookings, hours } = await loadBookingContext(organizationId, now, {
+    days: settings.leadDays + settings.horizonWeeks * 7 + 1,
+  })
+  const days = bookableDays({ now, settings, resources, bookings, calendar: hours.calendar })
   const firstStart = days.find((d) => d.starts.length > 0)?.starts[0] ?? null
   return { resources, days, firstStart }
 }
@@ -144,16 +191,17 @@ export async function claimableResource(
   if (start < first) return null
   const horizonEnd = addZonedDays(first, settings.horizonWeeks * 7, zone)
   if (start >= horizonEnd) return null
-  if (!settings.workingHours.includeWeekends && isZonedWeekend(start, zone)) return null
-  const open = atZonedTime(start, settings.workingHours.start, zone)
-  const close = atZonedTime(start, settings.workingHours.end, zone)
-  const end = new Date(start.getTime() + settings.durationMinutes * 60_000)
-  if (start < open || end > close) return null
-  if ((start.getTime() - open.getTime()) % (STEP_MINUTES * 60_000) !== 0) return null
 
   const resources = await bookingResources(organizationId)
   if (resources.length === 0) return null
-  const { bookings } = await loadBookingContext(organizationId, now)
+  // Read around the start itself, however far into the horizon it is.
+  const { bookings, hours } = await loadBookingContext(organizationId, start, { days: 2 })
+  // The same stretches and grid the page offered, read again: a closure added
+  // since the page opened takes the time away.
+  const offered = bookingStretches(start, settings, hours.calendar).some((stretch) =>
+    startsIn(stretch, settings.durationMinutes).some((s) => s.getTime() === start.getTime())
+  )
+  if (!offered) return null
   const free = freeResources(start, settings.durationMinutes, resources, bookings)
   return free.length > settings.walkInReserve ? free[0] : null
 }

+ 112 - 2
src/features/portal/Actions/portalActions.ts

@@ -1,6 +1,12 @@
 'use server'
 
 import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { getTranslations } from 'next-intl/server'
+import { zonedDayKey, zonedParts } from '@/lib/timezone'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import type { DayHours } from '@/features/workshop-calendar/Lib/calendar'
+import { loadWorkshopCalendar } from '@/features/workshop-calendar/Lib/loadCalendar.server'
+import { addDays, dayState } from '@/features/workshop-calendar/Lib/calendar'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import type { Prisma } from '@/generated/prisma/client'
 import { db } from '@/lib/db'
@@ -437,6 +443,26 @@ export async function createServiceRequest(input: {
   preferredDate?: string
 }) {
   return withPortalAuth(async ({ customerId, organizationId }) => {
+    // A preferred day the workshop is closed on (a holiday, a closure, a day
+    // it never opens) is refused before anything is created. The day checked
+    // is the one stored, whatever form the date came in ("2027-01-26" or
+    // "2027-01-26T12:00").
+    const timeZone = await workshopTimeZone(organizationId)
+    const preferredDate = toSafeWorkshopDate(input.preferredDate, timeZone) ?? null
+    if (preferredDate) {
+      const day = zonedDayKey(preferredDate, timeZone)
+      const { calendar } = await loadWorkshopCalendar(organizationId, { from: day, to: day })
+      const state = dayState(calendar, day)
+      if (!state.open) {
+        const tc = await getTranslations('workshopCalendar.portal')
+        throw new Error(
+          state.closures.length > 0
+            ? tc('closedDay', { name: state.closures.map((c) => c.name).join(', ') })
+            : tc('closedWeekday')
+        )
+      }
+    }
+
     let vehicle: { id: string; make: string; model: string; licensePlate: string | null } | null =
       null
 
@@ -501,8 +527,7 @@ export async function createServiceRequest(input: {
     const serviceRequest = await db.serviceRequest.create({
       data: {
         description: input.description,
-        preferredDate:
-          toSafeWorkshopDate(input.preferredDate, await workshopTimeZone(organizationId)) ?? null,
+        preferredDate,
         customerId,
         vehicleId: vehicle.id,
         organizationId,
@@ -686,3 +711,88 @@ export async function reportVehicleSold(vehicleId: string) {
     return { reportedAt: now.toISOString() }
   })
 }
+
+/**
+ * The days in the coming weeks the workshop is closed for a holiday or
+ * closure, so the request form can say so before a customer picks one.
+ */
+/** The workshop's week and what changes in it soon, for the customer to plan around. */
+export interface PortalOpeningHours {
+  /** Sunday first, as Date#getDay. */
+  week: DayHours[]
+  /** The workshop's today, "YYYY-MM-DD". */
+  today: string
+  timeFormat: '12h' | '24h'
+  /**
+   * Days soon that the workshop is shut or opens for less than usual, with
+   * the hours it is still open ("HH:mm"; none when closed all day).
+   */
+  closedSoon: { date: string; name: string; open: { start: string; end: string }[] }[]
+}
+
+/** How far ahead, and how many, of the closed days the portal names. */
+const CLOSED_SOON_DAYS = 60
+const CLOSED_SOON_MAX = 5
+
+/**
+ * Opening hours for the customer portal. The week always shows; closed days
+ * only while the workshop tracks them, and only on days it would otherwise
+ * open, since a holiday on a Sunday changes nothing for a closed-on-Sunday shop.
+ */
+export async function getPortalOpeningHours() {
+  return withPortalAuth(async ({ organizationId }): Promise<PortalOpeningHours> => {
+    const timeZone = await workshopTimeZone(organizationId)
+    const today = zonedDayKey(new Date(), timeZone)
+    const last = addDays(today, CLOSED_SOON_DAYS)
+    const [{ calendar }, format] = await Promise.all([
+      loadWorkshopCalendar(organizationId, { from: today, to: last }),
+      db.appSetting.findFirst({
+        where: { organizationId, key: SETTING_KEYS.TIME_FORMAT },
+        select: { value: true },
+      }),
+    ])
+    const clock = (at: Date) => {
+      const p = zonedParts(at, timeZone)
+      return `${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`
+    }
+    const closedSoon: PortalOpeningHours['closedSoon'] = []
+    for (
+      let day = today;
+      day <= last && closedSoon.length < CLOSED_SOON_MAX;
+      day = addDays(day, 1)
+    ) {
+      const state = dayState(calendar, day)
+      if (state.closures.length === 0 || state.closedWeekday) continue
+      if (state.open && !state.shortened) continue
+      closedSoon.push({
+        date: day,
+        name: state.closures.map((c) => c.name).join(', '),
+        open: state.intervals.map((i) => ({ start: clock(i.start), end: clock(i.end) })),
+      })
+    }
+    return {
+      week: calendar.week,
+      today,
+      timeFormat: format?.value === '24h' ? '24h' : '12h',
+      closedSoon,
+    }
+  })
+}
+
+export async function getPortalClosedDays() {
+  return withPortalAuth(async ({ organizationId }) => {
+    const timeZone = await workshopTimeZone(organizationId)
+    const today = zonedDayKey(new Date(), timeZone)
+    const last = addDays(today, 90)
+    const { calendar } = await loadWorkshopCalendar(organizationId, { from: today, to: last })
+    // One entry per closed day, naming each holiday once whatever lists it.
+    const days: { date: string; name: string }[] = []
+    for (let day = today; day <= last; day = addDays(day, 1)) {
+      const state = dayState(calendar, day)
+      if (!state.open && state.closures.length > 0) {
+        days.push({ date: day, name: state.closures.map((c) => c.name).join(', ') })
+      }
+    }
+    return days
+  })
+}

+ 93 - 0
src/features/portal/Components/PortalOpeningHours.tsx

@@ -0,0 +1,93 @@
+'use client'
+
+import { Clock } from 'lucide-react'
+import { useLocale, useTranslations } from 'next-intl'
+import { AppCard } from '@/components/app-card'
+import { cn } from '@/lib/utils'
+import { formatClock } from '@/features/workboard/utils/clock'
+import { formatDay, weekdayName } from '@/features/workshop-calendar/Lib/format'
+import { weekdayOf } from '@/features/workshop-calendar/Lib/calendar'
+import type { PortalOpeningHours as Hours } from '../Actions/portalActions'
+
+/** Monday first: how most of the world reads a week of opening hours. */
+const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0]
+
+const minutesOf = (hhmm: string) => {
+  const [h, m] = hhmm.split(':').map(Number)
+  return h * 60 + m
+}
+
+/**
+ * When the workshop is open, for its customers: each weekday's hours with
+ * today marked, then the days soon it is shut or closes early. `wide` lays
+ * the week out in a row on wider screens, for a card that spans the page.
+ */
+export function PortalOpeningHours({ hours, wide = false }: { hours: Hours; wide?: boolean }) {
+  const t = useTranslations('portal.openingHours')
+  const locale = useLocale()
+  const todayWeekday = weekdayOf(hours.today)
+  const span = (start: string, end: string) =>
+    `${formatClock(minutesOf(start), hours.timeFormat)} – ${formatClock(minutesOf(end), hours.timeFormat)}`
+
+  return (
+    <AppCard icon={Clock} title={t('title')}>
+      <ul
+        className={cn(
+          'space-y-1 text-sm',
+          // A list on a phone; a row of days once there is room for one.
+          wide && 'sm:grid sm:grid-cols-4 sm:gap-2 sm:space-y-0 lg:grid-cols-7'
+        )}
+        data-testid="portal-opening-hours"
+      >
+        {WEEK_ORDER.map((weekday) => {
+          const day = hours.week[weekday]
+          const isToday = weekday === todayWeekday
+          return (
+            <li
+              key={weekday}
+              className={cn(
+                'flex justify-between gap-3 rounded-md px-2 py-1',
+                wide && 'sm:flex-col sm:justify-start sm:gap-0.5 sm:border',
+                isToday && 'bg-muted font-medium'
+              )}
+              aria-current={isToday ? 'date' : undefined}
+            >
+              <span>
+                {weekdayName(weekday, locale)}
+                {isToday && <span className="sr-only"> ({t('today')})</span>}
+              </span>
+              <span className={cn('tabular-nums', !day?.open && 'text-muted-foreground')}>
+                {day?.open ? span(day.start, day.end) : t('closed')}
+              </span>
+            </li>
+          )
+        })}
+      </ul>
+
+      {hours.closedSoon.length > 0 && (
+        <div className="mt-4 space-y-1.5">
+          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+            {t('closedSoon')}
+          </p>
+          <ul className="space-y-1 text-sm" data-testid="portal-closed-soon">
+            {hours.closedSoon.map((day) => (
+              <li key={day.date} className="flex flex-wrap justify-between gap-x-3">
+                <span>
+                  {formatDay(day.date, locale, { year: false, weekday: true })}
+                  <span className="text-muted-foreground"> · {day.name}</span>
+                </span>
+                <span className="tabular-nums text-muted-foreground">
+                  {day.open.length === 0
+                    ? t('closed')
+                    : t('openOnly', {
+                        hours: day.open.map((o) => span(o.start, o.end)).join(', '),
+                      })}
+                </span>
+              </li>
+            ))}
+          </ul>
+        </div>
+      )}
+    </AppCard>
+  )
+}

+ 29 - 2
src/features/portal/Components/ServiceRequestForm.tsx

@@ -2,7 +2,8 @@
 
 import { useState, useTransition } from 'react'
 import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
+import { formatDay } from '@/features/workshop-calendar/Lib/format'
 import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
 import { Label } from '@/components/ui/label'
@@ -24,8 +25,19 @@ type Vehicle = {
 /** Sentinel for "my vehicle isn't listed" — never collides with a cuid. */
 const NEW_VEHICLE = '__new__'
 
-export function ServiceRequestForm({ orgId, vehicles }: { orgId: string; vehicles: Vehicle[] }) {
+export function ServiceRequestForm({
+  orgId,
+  vehicles,
+  closedDays = [],
+}: {
+  orgId: string
+  vehicles: Vehicle[]
+  /** Holidays and closures in the coming weeks, so they can be avoided. */
+  closedDays?: { date: string; name: string }[]
+}) {
   const t = useTranslations('portal.requestService')
+  const tc = useTranslations('workshopCalendar.portal')
+  const locale = useLocale()
   const router = useRouter()
   const [isPending, startTransition] = useTransition()
   // A customer with no registered vehicles starts straight in "new vehicle"
@@ -55,6 +67,11 @@ export function ServiceRequestForm({ orgId, vehicles }: { orgId: string; vehicle
       toast.error(t('describeIssue'))
       return
     }
+    const closed = closedDays.find((d) => d.date === preferredDate)
+    if (closed) {
+      toast.error(tc('closedDay', { name: closed.name }))
+      return
+    }
 
     startTransition(async () => {
       const result = await createServiceRequest({
@@ -193,6 +210,16 @@ export function ServiceRequestForm({ orgId, vehicles }: { orgId: string; vehicle
             onChange={(e) => setPreferredDate(e.target.value)}
             min={new Date().toISOString().split('T')[0]}
           />
+          {closedDays.length > 0 && (
+            <p className="text-xs text-muted-foreground" data-testid="portal-closed-days">
+              {tc('upcomingClosed', {
+                days: closedDays
+                  .slice(0, 8)
+                  .map((d) => `${formatDay(d.date, locale, { year: false })} (${d.name})`)
+                  .join(', '),
+              })}
+            </p>
+          )}
         </div>
 
         <div className="flex justify-end">

+ 1 - 0
src/features/settings/Lib/featureHints.ts

@@ -105,6 +105,7 @@ export const SETTINGS_SHIPPED_AT: Record<string, string> = {
   '/settings/templates': '2026-09-01',
   '/settings/email-templates': '2026-09-11',
   '/settings/warranty': '2026-09-18',
+  '/settings/opening-hours': '2026-11-09',
 }
 
 /** How long an entry counts as new. A month covers the slowest weekly visitor. */

+ 2 - 0
src/features/settings/Lib/searchIndex/index.ts

@@ -26,6 +26,7 @@ import { warrantyEntries } from './warranty'
 import { webhooksEntries } from './webhooks'
 import { workOrderStatusesEntries } from './workOrderStatuses'
 import { workshopEntries } from './workshop'
+import { openingHoursEntries } from './openingHours'
 
 export type { SettingsEntry } from './types'
 
@@ -51,6 +52,7 @@ export const SETTINGS_SEARCH_INDEX: readonly SettingsEntry[] = [
   ...emailTemplatesEntries,
   ...customerPortalEntries,
   ...workshopEntries,
+  ...openingHoursEntries,
   ...workOrderStatusesEntries,
   ...maintenanceEntries,
   ...inspectionRemindersEntries,

+ 62 - 0
src/features/settings/Lib/searchIndex/openingHours.ts

@@ -0,0 +1,62 @@
+import type { SettingsEntry } from './types'
+
+/** Opening hours and holidays: one card each, reached by the cards' ids. */
+export const openingHoursEntries: SettingsEntry[] = [
+  {
+    id: 'openingHours.hours',
+    page: 'openingHours',
+    title: 'openingHours.search.hours',
+    anchor: 'opening-hours',
+    keywords: [
+      'opening hours',
+      'opening time',
+      'closing time',
+      'business hours',
+      'work day start',
+      'work day end',
+      'weekend',
+      'saturday',
+    ],
+  },
+  {
+    id: 'openingHours.switch',
+    page: 'openingHours',
+    title: 'openingHours.search.switch',
+    anchor: 'closures-switch',
+    keywords: ['closed days', 'time off', 'turn off holidays', 'disable holidays'],
+  },
+  {
+    id: 'openingHours.publicHolidays',
+    page: 'openingHours',
+    title: 'openingHours.search.publicHolidays',
+    anchor: 'public-holidays',
+    keywords: [
+      'public holidays',
+      'bank holidays',
+      'national holidays',
+      'state holidays',
+      'country',
+    ],
+  },
+  {
+    id: 'openingHours.calendars',
+    page: 'openingHours',
+    title: 'openingHours.search.calendars',
+    anchor: 'holiday-calendars',
+    keywords: ['holiday calendar', 'ics', 'ical', 'webcal', 'google holidays', 'calendar feed'],
+  },
+  {
+    id: 'openingHours.closedDays',
+    page: 'openingHours',
+    title: 'openingHours.search.closedDays',
+    anchor: 'closed-days',
+    keywords: ['closed days', 'closure', 'shutdown', 'close early', 'christmas'],
+  },
+  {
+    id: 'openingHours.timeOff',
+    page: 'openingHours',
+    title: 'openingHours.search.timeOff',
+    anchor: 'time-off',
+    keywords: ['time off', 'leave', 'vacation', 'holiday', 'sick', 'absence', 'technician away'],
+  },
+]

+ 0 - 14
src/features/settings/Lib/searchIndex/workshop.ts

@@ -48,20 +48,6 @@ export const workshopEntries: SettingsEntry[] = [
       'shop rate',
     ],
   },
-  {
-    id: 'workshop.workDayStart',
-    page: 'workshop',
-    title: 'workshop.workDayStart',
-    anchor: 'workDayStart',
-    keywords: ['work day start', 'opening hours', 'opening time', 'start of day', 'business hours'],
-  },
-  {
-    id: 'workshop.workDayEnd',
-    page: 'workshop',
-    title: 'workshop.workDayEnd',
-    anchor: 'workDayEnd',
-    keywords: ['work day end', 'closing time', 'opening hours', 'end of day', 'business hours'],
-  },
   {
     id: 'workshop.titleTemplate',
     page: 'workshop',

+ 2 - 0
src/features/settings/Lib/settingsPageIcons.ts

@@ -3,6 +3,7 @@ import {
   BellRing,
   Building2,
   CalendarClock,
+  CalendarDays,
   ClipboardCheck,
   CreditCard,
   Database,
@@ -46,6 +47,7 @@ export const SETTINGS_PAGE_ICONS: Record<SettingsPageKey, LucideIcon> = {
   emailTemplates: Mail,
   customerPortal: Globe,
   workshop: Wrench,
+  openingHours: CalendarDays,
   workOrderStatuses: ListChecks,
   maintenance: Gauge,
   inspectionReminders: ClipboardCheck,

+ 2 - 0
src/features/settings/Lib/settingsPages.ts

@@ -24,6 +24,7 @@ export type SettingsPageKey =
   | 'emailTemplates'
   | 'customerPortal'
   | 'workshop'
+  | 'openingHours'
   | 'workOrderStatuses'
   | 'maintenance'
   | 'inspectionReminders'
@@ -98,6 +99,7 @@ export const SETTINGS_PAGES: SettingsPage[] = [
   },
 
   { key: 'workshop', href: '/settings/workshop', category: 'workshop' },
+  { key: 'openingHours', href: '/settings/opening-hours', category: 'workshop' },
   { key: 'workOrderStatuses', href: '/settings/work-order-statuses', category: 'workshop' },
   { key: 'maintenance', href: '/settings/maintenance', category: 'workshop' },
   { key: 'inspectionReminders', href: '/settings/inspection-reminders', category: 'workshop' },

+ 19 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -231,6 +231,25 @@ export const SETTING_KEYS = {
   WORKBOARD_WEEK_START_DAY: 'workboard.weekStartDay',
   WORKBOARD_WORK_DAY_START: 'workboard.workDayStart',
   WORKBOARD_WORK_DAY_END: 'workboard.workDayEnd',
+  /// JSON: seven days, Sunday first, each `{ open, start, end }`. Missing means
+  /// the work day above on weekdays, which is how every workshop started.
+  OPENING_HOURS: 'workshop.openingHours',
+  /// "false" turns closed days, public holidays and time off off everywhere.
+  /// Unset or anything else is on.
+  CLOSURES_ENABLED: 'workshop.closuresEnabled',
+  /// ISO 3166-1 alpha-2 code whose public holidays close the workshop; empty
+  /// means none.
+  HOLIDAY_COUNTRY: 'holidays.country',
+  /// JSON array of ISO 3166-2 codes ("AU-QLD") whose regional holidays count.
+  HOLIDAY_REGIONS: 'holidays.regions',
+  /// JSON object: holiday name to true (closed) or false (open), overriding
+  /// the default that public holidays close and other days do not.
+  HOLIDAY_CHOICES: 'holidays.choices',
+  /// JSON: the country's holidays by year as last fetched from Nager.Date.
+  /// Written by the sync, never by the settings form.
+  HOLIDAY_CACHE: 'holidays.cache',
+  /// JSON array of holiday calendars (.ics) with their events and choices.
+  HOLIDAY_FEEDS: 'holidays.feeds',
   INVOICE_LAYOUT_CONFIG: 'invoice.layoutConfig',
   QUOTE_LAYOUT_CONFIG: 'quote.layoutConfig',
   CERTIFICATE_LAYOUT_CONFIG: 'certificate.layoutConfig',

+ 166 - 36
src/features/vehicles/Components/service-edit/ScheduleTimesSection.tsx

@@ -1,7 +1,7 @@
 'use client'
 
 import { SectionFrame } from '@/components/section-frame'
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useRef } from 'react'
 import { useModernWorkOrder } from '@/components/work-order-layout-context'
 import { useRouter } from 'next/navigation'
 import { AddPersonDialog } from '@/features/team/Components/AddPersonDialog'
@@ -26,6 +26,7 @@ import {
 } from '@/components/ui/command'
 import {
   AlertTriangle,
+  CalendarOff,
   Check,
   ChevronsUpDown,
   Clock,
@@ -121,6 +122,7 @@ export function ScheduleTimesSection({
   onSaved,
 }: ScheduleTimesSectionProps) {
   const t = useTranslations('service.schedule')
+  const tCal = useTranslations('workshopCalendar')
   const router = useRouter()
   const modern = useModernWorkOrder()
   // Booked times belong to the workshop's clock, not to the clock of whoever
@@ -143,7 +145,6 @@ export function ScheduleTimesSection({
   // somebody to change something first.
   useEffect(() => {
     if (!initialStartDateTime || !initialEndDateTime) return
-    if (!initialTechnicianId && !initialWorkBayId) return
     void checkSlotAvailability({
       start: new Date(initialStartDateTime).toISOString(),
       end: new Date(initialEndDateTime).toISOString(),
@@ -151,7 +152,13 @@ export function ScheduleTimesSection({
       workBayId: initialWorkBayId || null,
       excludeId: serviceRecordId,
     }).then((res) => {
-      if (res.success && res.data) setConflicts(res.data.conflicts)
+      if (res.success && res.data) {
+        setConflicts(res.data.conflicts)
+        setClosed({
+          names: res.data.closures?.map((c) => c.name) ?? [],
+          weekday: !!res.data.closedWeekday,
+        })
+      }
     })
   }, [
     initialStartDateTime,
@@ -192,6 +199,8 @@ export function ScheduleTimesSection({
   const [conflicts, setConflicts] = useState<
     {
       id: string
+      /** `absence` is the technician's time off rather than other work. */
+      kind?: string
       label: string
       start: string
       end: string
@@ -199,21 +208,41 @@ export function ScheduleTimesSection({
       onBay: boolean
     }[]
   >([])
+  /** The workshop's own closures the booking falls in, whoever it is for. */
+  const [closed, setClosed] = useState<{ names: string[]; weekday: boolean }>({
+    names: [],
+    weekday: false,
+  })
   const [checking, setChecking] = useState(false)
   const [finding, setFinding] = useState(false)
 
   // How full each technician's day is, on the day this job is booked. Only
   // the overhauled page draws it, so only that page asks.
   const [dayLoad, setDayLoad] = useState<Record<string, number>>({})
+  // Who is away that day, so the list can say so beside their name.
+  const [dayAway, setDayAway] = useState<
+    Record<string, { reason: string; allDay: boolean; from: string | null; until: string | null }>
+  >({})
   const loadDay = startDateTime ? startDateTime.toISOString() : null
+  // Read through a ref: a save calls this from a closure made before the
+  // date changed, and asking about that old day afterwards drew the old day's
+  // load (and nobody away) under the new date.
+  const loadDayRef = useRef(loadDay)
+  loadDayRef.current = loadDay
   const refreshDayLoad = useCallback(async () => {
-    if (!modern || !loadDay) return
-    const res = await getTechnicianDayLoad({ day: loadDay })
-    if (res.success && res.data) setDayLoad(res.data.minutes)
-  }, [modern, loadDay])
+    const day = loadDayRef.current
+    if (!modern || !day) return
+    const res = await getTechnicianDayLoad({ day })
+    // A newer day was asked for while this one was on its way.
+    if (day !== loadDayRef.current) return
+    if (res.success && res.data) {
+      setDayLoad(res.data.minutes)
+      setDayAway(res.data.away ?? {})
+    }
+  }, [modern])
   useEffect(() => {
     void refreshDayLoad()
-  }, [refreshDayLoad])
+  }, [refreshDayLoad, loadDay])
 
   const bayId = selectedBayId === NO_BAY ? null : selectedBayId
 
@@ -225,8 +254,9 @@ export function ScheduleTimesSection({
    * what the day actually looks like. Saying so plainly is the useful part.
    */
   const checkSlot = async (start: Date, end: Date, techId: string, bay: string | null) => {
-    if ((!techId && !bay) || end <= start) {
+    if (end <= start) {
       setConflicts([])
+      setClosed({ names: [], weekday: false })
       return
     }
     setChecking(true)
@@ -239,6 +269,10 @@ export function ScheduleTimesSection({
     })
     setChecking(false)
     setConflicts(res.success && res.data ? res.data.conflicts : [])
+    setClosed({
+      names: res.success && res.data ? (res.data.closures?.map((c) => c.name) ?? []) : [],
+      weekday: res.success && res.data ? !!res.data.closedWeekday : false,
+    })
   }
 
   /** Move the booking to the first slot that fits, inside working hours. */
@@ -555,6 +589,12 @@ export function ScheduleTimesSection({
               const capacity = tech.dailyCapacity || DEFAULT_DAILY_CAPACITY
               const booked = dayLoad[tech.id] ?? 0
               const full = booked >= capacity
+              const away = dayAway[tech.id]
+              const awayReason = away
+                ? tCal.has(`reasons.${away.reason}`)
+                  ? tCal(`reasons.${away.reason}`)
+                  : away.reason
+                : null
               return (
                 <button
                   key={tech.id}
@@ -581,12 +621,23 @@ export function ScheduleTimesSection({
                   </span>
                   <span className="min-w-0">
                     <span className="block truncate text-sm font-semibold">{tech.name}</span>
-                    <span className="block truncate text-xs text-muted-foreground">
-                      {tech.skills ||
-                        (full
-                          ? t('loadFull')
-                          : t('loadFree', { hours: hoursOf(capacity - booked) }))}
-                    </span>
+                    {away ? (
+                      <span
+                        className="block truncate text-xs font-medium text-amber-700 dark:text-amber-400"
+                        data-testid="tech-away"
+                      >
+                        {away.allDay
+                          ? tCal('schedule.away', { reason: awayReason ?? '' })
+                          : `${tCal('schedule.away', { reason: awayReason ?? '' })} · ${[away.from, away.until].filter(Boolean).join('-')}`}
+                      </span>
+                    ) : (
+                      <span className="block truncate text-xs text-muted-foreground">
+                        {tech.skills ||
+                          (full
+                            ? t('loadFull')
+                            : t('loadFree', { hours: hoursOf(capacity - booked) }))}
+                      </span>
+                    )}
                   </span>
                   <span className="flex flex-col gap-1">
                     <span className="text-right text-xs tabular-nums text-muted-foreground">
@@ -769,13 +820,36 @@ export function ScheduleTimesSection({
           </div>
         </div>
 
+        {(closed.names.length > 0 || closed.weekday) && (
+          <div
+            className="rounded-md border border-amber-300 bg-amber-50 p-2.5 dark:border-amber-900/60 dark:bg-amber-950/40"
+            data-testid="schedule-closed"
+          >
+            <div className="flex items-start gap-2">
+              <CalendarOff className="mt-px h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-500" />
+              <div className="min-w-0 flex-1 space-y-1">
+                <p className="text-xs font-semibold text-amber-900 dark:text-amber-200">
+                  {closed.names.length > 0
+                    ? tCal('schedule.closed', { names: closed.names.join(', ') })
+                    : tCal('schedule.closedWeekday')}
+                </p>
+                <p className="text-[11px] text-amber-700 dark:text-amber-400">
+                  {tCal('schedule.bookedAnyway')}
+                </p>
+              </div>
+            </div>
+          </div>
+        )}
+
         {conflicts.length > 0 && (
           <div className="rounded-md border border-amber-300 bg-amber-50 p-2.5 dark:border-amber-900/60 dark:bg-amber-950/40">
             <div className="flex items-start gap-2">
               <AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-500" />
               <div className="min-w-0 flex-1 space-y-1">
                 <p className="text-xs font-semibold text-amber-900 dark:text-amber-200">
-                  {t('slotTaken', { count: conflicts.length })}
+                  {conflicts.every((c) => c.kind === 'absence')
+                    ? tCal('schedule.awayHeading')
+                    : t('slotTaken', { count: conflicts.length })}
                 </p>
                 <ul className="space-y-0.5">
                   {conflicts.slice(0, 3).map((c) => (
@@ -783,26 +857,42 @@ export function ScheduleTimesSection({
                       key={c.id}
                       className="text-[11px] leading-snug text-amber-800 dark:text-amber-300"
                     >
-                      {c.label || t('anotherJob')} ·{' '}
-                      {new Date(c.start).toLocaleString(undefined, {
-                        day: 'numeric',
-                        month: 'short',
-                        hour: '2-digit',
-                        minute: '2-digit',
-                        timeZone: timezone || undefined,
-                      })}
-                      {' – '}
-                      {new Date(c.end).toLocaleTimeString(undefined, {
-                        hour: '2-digit',
-                        minute: '2-digit',
-                        timeZone: timezone || undefined,
-                      })}
-                      {' · '}
-                      {c.onTechnician && c.onBay
-                        ? t('clashBoth')
-                        : c.onTechnician
-                          ? t('clashTechnician')
-                          : t('clashBay')}
+                      {c.kind === 'absence' ? (
+                        // Time off is days, not a slot: say which days, and
+                        // the clock only where it starts or ends in a day.
+                        <>
+                          {tCal('schedule.awayConflict', {
+                            reason: tCal.has(`reasons.${c.label}`)
+                              ? tCal(`reasons.${c.label}`)
+                              : c.label,
+                          })}
+                          {' · '}
+                          {awayWhen(c.start, c.end, timezone)}
+                        </>
+                      ) : (
+                        <>
+                          {c.label || t('anotherJob')} ·{' '}
+                          {new Date(c.start).toLocaleString(undefined, {
+                            day: 'numeric',
+                            month: 'short',
+                            hour: '2-digit',
+                            minute: '2-digit',
+                            timeZone: timezone || undefined,
+                          })}
+                          {' – '}
+                          {new Date(c.end).toLocaleTimeString(undefined, {
+                            hour: '2-digit',
+                            minute: '2-digit',
+                            timeZone: timezone || undefined,
+                          })}
+                          {' · '}
+                          {c.onTechnician && c.onBay
+                            ? t('clashBoth')
+                            : c.onTechnician
+                              ? t('clashTechnician')
+                              : t('clashBay')}
+                        </>
+                      )}
                     </li>
                   ))}
                 </ul>
@@ -865,3 +955,43 @@ export function ScheduleTimesSection({
     </SectionFrame>
   )
 }
+
+/** Hours and minutes past local midnight of an instant in the workshop's zone. */
+function clockIn(at: Date, timeZone: string | undefined): number {
+  const parts = new Intl.DateTimeFormat('en-GB', {
+    hour: '2-digit',
+    minute: '2-digit',
+    hourCycle: 'h23',
+    timeZone,
+  }).formatToParts(at)
+  const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? 0)
+  const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? 0)
+  return hour * 60 + minute
+}
+
+/**
+ * When a technician is away, as days: "16 Nov – 17 Nov", or with a clock
+ * only where the time off starts or ends inside a day. The end of a stretch
+ * is the midnight after its last day, which reads as that last day.
+ */
+function awayWhen(startIso: string, endIso: string, timeZone: string | null | undefined): string {
+  const zone = timeZone || undefined
+  const start = new Date(startIso)
+  let end = new Date(endIso)
+  const startsAtMidnight = clockIn(start, zone) === 0
+  const endsAtMidnight = clockIn(end, zone) === 0
+  if (endsAtMidnight) end = new Date(end.getTime() - 60_000)
+  const day = (at: Date) =>
+    at.toLocaleDateString(undefined, { day: 'numeric', month: 'short', timeZone: zone })
+  const dayAndClock = (at: Date) =>
+    at.toLocaleString(undefined, {
+      day: 'numeric',
+      month: 'short',
+      hour: '2-digit',
+      minute: '2-digit',
+      timeZone: zone,
+    })
+  const from = startsAtMidnight ? day(start) : dayAndClock(start)
+  const to = endsAtMidnight ? day(end) : dayAndClock(end)
+  return from === to ? from : `${from} – ${to}`
+}

Some files were not shown because too many files changed in this diff