diff --git a/bun.lockb b/bun.lockb index fb32df4..1ed5e7e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lighthouserc.cjs b/lighthouserc.cjs index 6abc5ee..1f06549 100644 --- a/lighthouserc.cjs +++ b/lighthouserc.cjs @@ -13,6 +13,7 @@ const config = { 'http://localhost:3000/en/news/1', 'http://localhost:3000/en/storage', 'http://localhost:3000/en/storage/shopping-cart', + 'http://localhost:3000/en/shift-schedule', ], startServerCommand: 'bun run start', }, diff --git a/messages/en.json b/messages/en.json index 62e259c..f9b2767 100644 --- a/messages/en.json +++ b/messages/en.json @@ -38,6 +38,7 @@ "events": "Events", "storage": "Storage", "about": "About", + "shiftSchedule": "Shift Schedule", "changeLocale": "Change language", "toggleTheme": "Toggle theme", "light": "Light", @@ -132,5 +133,28 @@ "returnByDescription": "Select how long you would like to borrow the item for.", "submit": "Submit" } + }, + "shiftSchedule": { + "title": "Shift Schedule", + "administratorMenu": { + "label": "Administrator Menu", + "open": "Open Administrator Menu", + "close": "Close Administrator Menu", + "clearShiftSchedule": "Clear shift schedule" + }, + "scheduleTable": { + "time": "Time", + "day": "{day, select, monday {Monday} tuesday {Tuesday} wednesday {Wednesday} thursday {Thursday} other {Friday}}", + "scheduleCell": { + "onShift": "{count, plural, =0 {Closed} =1 {1 person on shift} other {# people on shift}}", + "scheduleCellDialog": { + "empty": "No one on shift", + "registerSection": { + "recurring": "Recurring", + "register": "Register" + } + } + } + } } } diff --git a/messages/no.json b/messages/no.json index 0c5810a..8062933 100644 --- a/messages/no.json +++ b/messages/no.json @@ -38,6 +38,7 @@ "events": "Hendelser", "storage": "Lager", "about": "Om oss", + "shiftSchedule": "Vaktliste", "changeLocale": "Bytt språk", "toggleTheme": "Bytt tema", "light": "Lys", @@ -132,5 +133,28 @@ "returnByDescription": "Velg hvor lenge du ønsker å låne gjenstanden(e)", "submit": "Send" } + }, + "shiftSchedule": { + "title": "Vaktliste", + "administratorMenu": { + "label": "Administrator-meny", + "open": "Åpne Administrator-meny", + "close": "Lukk Administrator-meny", + "clearShiftSchedule": "Tøm vaktliste" + }, + "scheduleTable": { + "time": "Tid", + "day": "{day, select, monday {Mandag} tuesday {Tirsdag} wednesday {Onsdag} thursday {Torsdag} other {Fredag}}", + "scheduleCell": { + "onShift": "{count, plural, =0 {Stengt} =1 {1 person på vakt} other {# personer på vakt}}", + "scheduleCellDialog": { + "empty": "Ingen på vakt", + "registerSection": { + "recurring": "Gjentagende", + "register": "Registrer" + } + } + } + } } } diff --git a/package.json b/package.json index 4fdad2a..a496c1d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "@aws-sdk/client-s3": "^3.679.0", "@lucia-auth/adapter-drizzle": "^1.1.0", "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", diff --git a/src/app/[locale]/(default)/shift-schedule/layout.tsx b/src/app/[locale]/(default)/shift-schedule/layout.tsx new file mode 100644 index 0000000..8aa4078 --- /dev/null +++ b/src/app/[locale]/(default)/shift-schedule/layout.tsx @@ -0,0 +1,23 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; + +type ShiftScheduleLayoutProps = { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}; + +export default async function ShiftScheduleLayout({ + params, + children, +}: ShiftScheduleLayoutProps) { + const { locale } = await params; + + setRequestLocale(locale); + const t = await getTranslations('shiftSchedule'); + + return ( + <> +

{t('title')}

+ {children} + + ); +} diff --git a/src/app/[locale]/(default)/shift-schedule/loading.tsx b/src/app/[locale]/(default)/shift-schedule/loading.tsx new file mode 100644 index 0000000..7086b1d --- /dev/null +++ b/src/app/[locale]/(default)/shift-schedule/loading.tsx @@ -0,0 +1,126 @@ +import { Skeleton } from '@/components/ui/Skeleton'; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table'; +import { useFormatter, useTranslations } from 'next-intl'; + +export default function ShiftScheduleLayout() { + const t = useTranslations('shiftSchedule.scheduleTable'); + const format = useFormatter(); + + const days = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + ] as const; + const timeslots = ['first', 'second', 'third', 'fourth'] as const; + + function getDateTimeRange(timeslot: string) { + let firstDate: Date; + let secondDate: Date; + + switch (timeslot) { + case timeslots[0]: + firstDate = new Date(0, 0, 0, 10, 15, 0, 0); + secondDate = new Date(0, 0, 0, 12, 7, 0, 0); + break; + + case timeslots[1]: + firstDate = new Date(0, 0, 0, 12, 7, 0, 0); + secondDate = new Date(0, 0, 0, 14, 7, 0, 0); + break; + + case timeslots[2]: + firstDate = new Date(0, 0, 0, 14, 7, 0, 0); + secondDate = new Date(0, 0, 0, 16, 7, 0, 0); + break; + + case timeslots[3]: + firstDate = new Date(0, 0, 0, 16, 7, 0, 0); + secondDate = new Date(0, 0, 0, 18, 0, 0, 0); + break; + + default: + firstDate = new Date(); + secondDate = new Date(); + } + + return format.dateTimeRange(firstDate, secondDate, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } + + return ( + <> + {/* Table shown on small screens */} +
+ {days.map((day) => ( + + + + {t('time')} + + {t('day', { day: day })} + + + + + {timeslots.map((timeslot) => ( + + + {getDateTimeRange(timeslot)} + + + + + + ))} + +
+ ))} + + [skill icons legend] +
+
+ + {/* Table shown on all other screens */} + + + + {t('time')} + {days.map((day) => ( + + {t('day', { day: day })} + + ))} + + + + {timeslots.map((timeslot) => ( + + + {getDateTimeRange(timeslot)} + + {days.map((day) => ( + + + + ))} + + ))} + + [skill icons legend] +
+ + ); +} diff --git a/src/app/[locale]/(default)/shift-schedule/page.tsx b/src/app/[locale]/(default)/shift-schedule/page.tsx new file mode 100644 index 0000000..decde74 --- /dev/null +++ b/src/app/[locale]/(default)/shift-schedule/page.tsx @@ -0,0 +1,42 @@ +import { AdministratorMenu } from '@/components/shift-schedule/AdministratorMenu'; +import { ScheduleTable } from '@/components/shift-schedule/ScheduleTable'; +import { shiftScheduleMockData } from '@/mock-data/shiftSchedule'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'layout' }); + + return { + title: t('shiftSchedule'), + }; +} + +export default async function ShiftSchedulePage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + + setRequestLocale(locale); + const t = await getTranslations('shiftSchedule'); + + return ( + <> + + + + ); +} diff --git a/src/components/shift-schedule/AdministratorMenu.tsx b/src/components/shift-schedule/AdministratorMenu.tsx new file mode 100644 index 0000000..3548912 --- /dev/null +++ b/src/components/shift-schedule/AdministratorMenu.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/Collapsible'; +import { ChevronDownIcon, ChevronUpIcon, Trash2Icon } from 'lucide-react'; +import { useState } from 'react'; + +type AdministratorMenuProps = { + t: { + label: string; + open: string; + close: string; + clearShiftSchedule: string; + }; +}; + +function AdministratorMenu({ t }: AdministratorMenuProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + +
+ {t.label} + + + +
+ + + +
+ ); +} + +export { AdministratorMenu }; diff --git a/src/components/shift-schedule/RegisterShift.tsx b/src/components/shift-schedule/RegisterShift.tsx new file mode 100644 index 0000000..801dc2f --- /dev/null +++ b/src/components/shift-schedule/RegisterShift.tsx @@ -0,0 +1,23 @@ +import { Button } from '@/components/ui/Button'; +import { Checkbox } from '@/components/ui/Checkbox'; +import { Label } from '@/components/ui/Label'; +import { cx } from '@/lib/utils'; +import { useTranslations } from 'next-intl'; + +function RegisterShift({ className }: { className?: string }) { + const t = useTranslations( + 'shiftSchedule.scheduleTable.scheduleCell.scheduleCellDialog.registerSection', + ); + + return ( +
+
+ + +
+ +
+ ); +} + +export { RegisterShift }; diff --git a/src/components/shift-schedule/ScheduleCell.tsx b/src/components/shift-schedule/ScheduleCell.tsx new file mode 100644 index 0000000..8cd360e --- /dev/null +++ b/src/components/shift-schedule/ScheduleCell.tsx @@ -0,0 +1,58 @@ +import { ScheduleCellDialog } from '@/components/shift-schedule/ScheduleCellDialog'; +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/Dialog'; +import { TableCell } from '@/components/ui/Table'; +import { cx } from '@/lib/utils'; +import { UserIcon, UsersIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +type ScheduleCellProps = { + tDialog: { + day: string; + time: string; + }; + members: { + name: string; + }[]; +}; + +function ScheduleCell({ tDialog, members }: ScheduleCellProps) { + const t = useTranslations('shiftSchedule.scheduleTable.scheduleCell'); + + return ( + + + + + + + + + + + ); +} + +export { ScheduleCell }; diff --git a/src/components/shift-schedule/ScheduleCellDialog.tsx b/src/components/shift-schedule/ScheduleCellDialog.tsx new file mode 100644 index 0000000..248d3bf --- /dev/null +++ b/src/components/shift-schedule/ScheduleCellDialog.tsx @@ -0,0 +1,47 @@ +import { RegisterShift } from '@/components/shift-schedule/RegisterShift'; +import { DialogHeader, DialogTitle } from '@/components/ui/Dialog'; +import { useTranslations } from 'next-intl'; + +type ScheduleCellDialogProps = { + tDialog: { + day: string; + time: string; + }; + members: { + name: string; + }[]; +}; + +function ScheduleCellDialog({ tDialog, members }: ScheduleCellDialogProps) { + const t = useTranslations( + 'shiftSchedule.scheduleTable.scheduleCell.scheduleCellDialog', + ); + + return ( + <> + + + {tDialog.day} + {tDialog.time} + + +
+ {members.length === 0 ? ( +

{t('empty')}

+ ) : ( +
+ {members.map((member) => ( +
+

{member.name}

+
[skill icons]
+
+ ))} +
+ )} + +
+ + ); +} + +export { ScheduleCellDialog }; diff --git a/src/components/shift-schedule/ScheduleTable.tsx b/src/components/shift-schedule/ScheduleTable.tsx new file mode 100644 index 0000000..a438008 --- /dev/null +++ b/src/components/shift-schedule/ScheduleTable.tsx @@ -0,0 +1,160 @@ +import { ScheduleCell } from '@/components/shift-schedule/ScheduleCell'; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table'; +import { useFormatter, useTranslations } from 'next-intl'; + +type ScheduleEntryProps = { + members: { + name: string; + }[]; +}; + +type ScheduleDayProps = { + first: ScheduleEntryProps; + second: ScheduleEntryProps; + third: ScheduleEntryProps; + fourth: ScheduleEntryProps; +}; + +type ScheduleTableProps = { + week: { + monday: ScheduleDayProps; + tuesday: ScheduleDayProps; + wednesday: ScheduleDayProps; + thursday: ScheduleDayProps; + friday: ScheduleDayProps; + }; +}; + +function ScheduleTable({ week }: ScheduleTableProps) { + const t = useTranslations('shiftSchedule.scheduleTable'); + const format = useFormatter(); + + const days = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + ] as const; + const timeslots = ['first', 'second', 'third', 'fourth'] as const; + + function getDateTimeRange(timeslot: string) { + let firstDate: Date; + let secondDate: Date; + + switch (timeslot) { + case timeslots[0]: + firstDate = new Date(0, 0, 0, 10, 15, 0, 0); + secondDate = new Date(0, 0, 0, 12, 7, 0, 0); + break; + + case timeslots[1]: + firstDate = new Date(0, 0, 0, 12, 7, 0, 0); + secondDate = new Date(0, 0, 0, 14, 7, 0, 0); + break; + + case timeslots[2]: + firstDate = new Date(0, 0, 0, 14, 7, 0, 0); + secondDate = new Date(0, 0, 0, 16, 7, 0, 0); + break; + + case timeslots[3]: + firstDate = new Date(0, 0, 0, 16, 7, 0, 0); + secondDate = new Date(0, 0, 0, 18, 0, 0, 0); + break; + + default: + firstDate = new Date(); + secondDate = new Date(); + } + + return format.dateTimeRange(firstDate, secondDate, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } + + return ( + <> + {/* Table shown on small screens */} +
+ {days.map((day) => ( + + + + {t('time')} + + {t('day', { day: day })} + + + + + {timeslots.map((timeslot) => ( + + + {getDateTimeRange(timeslot)} + + + + ))} + +
+ ))} + + [skill icons legend] +
+
+ + {/* Table shown on all other screens */} + + + + {t('time')} + {days.map((day) => ( + + {t('day', { day: day })} + + ))} + + + + {timeslots.map((timeslot) => ( + + + {getDateTimeRange(timeslot)} + + {days.map((day) => ( + + ))} + + ))} + + [skill icons legend] +
+ + ); +} + +export { ScheduleTable }; diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..e4f5cd9 --- /dev/null +++ b/src/components/ui/Checkbox.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check } from 'lucide-react'; +import { forwardRef } from 'react'; + +import { cx } from '@/lib/utils'; + +const Checkbox = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/src/components/ui/Collapsible.tsx b/src/components/ui/Collapsible.tsx new file mode 100644 index 0000000..86ab87d --- /dev/null +++ b/src/components/ui/Collapsible.tsx @@ -0,0 +1,11 @@ +'use client'; + +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/lib/locale/index.ts b/src/lib/locale/index.ts index a4bb4b6..0166353 100644 --- a/src/lib/locale/index.ts +++ b/src/lib/locale/index.ts @@ -43,6 +43,10 @@ const routing = defineRouting({ en: '/storage/shopping-cart', no: '/lager/handlekurv', }, + '/shift-schedule': { + en: '/shift-schedule', + no: '/vaktliste', + }, }, }); diff --git a/src/mock-data/shiftSchedule.ts b/src/mock-data/shiftSchedule.ts new file mode 100644 index 0000000..d6090d6 --- /dev/null +++ b/src/mock-data/shiftSchedule.ts @@ -0,0 +1,91 @@ +const shiftScheduleMockData = { + monday: { + first: { + members: [{ name: 'En person' }], + }, + second: { + members: [{ name: 'En person' }], + }, + third: { + members: [ + { name: 'En person' }, + { name: 'En annen person' }, + { name: 'Person 3' }, + ], + }, + fourth: { + members: [{ name: 'En person' }], + }, + }, + tuesday: { + first: { + members: [{ name: 'En person' }], + }, + second: { + members: [{ name: 'En person' }], + }, + third: { + members: [ + { name: 'En person' }, + { name: 'En annen person' }, + { name: 'Person 3' }, + ], + }, + fourth: { + members: [{ name: 'En person' }], + }, + }, + wednesday: { + first: { + members: [{ name: 'En person' }, { name: 'En annen person' }], + }, + second: { + members: [], + }, + third: { + members: [{ name: 'En person' }, { name: 'En annen person' }], + }, + fourth: { + members: [{ name: 'En person' }, { name: 'En annen person' }], + }, + }, + thursday: { + first: { + members: [{ name: 'En person' }], + }, + second: { + members: [ + { name: 'En person' }, + { name: 'En annen person' }, + { name: 'Person 3' }, + ], + }, + third: { + members: [], + }, + fourth: { + members: [], + }, + }, + friday: { + first: { + members: [], + }, + second: { + members: [ + { + name: 'En person med veldig langt navn så jeg kan teste navn som bruker to linjer', + }, + { name: 'En annen person' }, + ], + }, + third: { + members: [{ name: 'En person' }], + }, + fourth: { + members: [{ name: 'En person' }], + }, + }, +}; + +export { shiftScheduleMockData };