diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/components/RosterGeneratorWorkflow.tsx b/frontend/src/components/RosterGeneratorWorkflow.tsx new file mode 100644 index 0000000..2bec6df --- /dev/null +++ b/frontend/src/components/RosterGeneratorWorkflow.tsx @@ -0,0 +1,508 @@ +import React, { useMemo, useState } from 'react'; +import BaseButton from './BaseButton'; +import CardBox from './CardBox'; + +const SITE_NAME = 'CULTURAL FROM PIERNEEF MUSEUM'; +const REFERENCE_UTC = Date.UTC(2025, 4, 1); +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_MONTHS = 24; + +const supervisors = [ + { name: 'Kenosi', code: 'F-079', phone: '869 7171' }, + { name: 'Mogoba', code: '', phone: '072 363 3949' }, + { name: 'Teffo J', code: '', phone: '078 734 7081' }, + { name: 'All', code: '', phone: '066 394 7465' }, +]; + +const guards = [ + { name: 'Mahlangu C.', cycle: ['D', 'D', 'N', 'N', 'R', 'R'], offset: 0 }, + { name: 'Tshabalala N.', cycle: ['D', 'D', 'R', 'R', 'D', 'D'], offset: 0 }, + { name: 'Malatjie P.', cycle: ['D', 'D', 'N', 'N', 'R', 'R'], offset: 4 }, +]; + +const shiftStyles = { + D: { + label: 'Day', + chip: 'bg-sky-100 text-sky-800 ring-sky-200', + table: 'bg-sky-50 text-sky-900', + hex: '#DBEAFE', + }, + N: { + label: 'Night', + chip: 'bg-violet-100 text-violet-800 ring-violet-200', + table: 'bg-violet-50 text-violet-900', + hex: '#EDE9FE', + }, + R: { + label: 'Rest', + chip: 'bg-slate-100 text-slate-600 ring-slate-200', + table: 'bg-slate-50 text-slate-500', + hex: '#F1F5F9', + }, +}; + +const dayAbbrevs = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; +const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +type ShiftCode = keyof typeof shiftStyles; + +type GuardRosterRow = { + guardName: string; + shifts: ShiftCode[]; + totals: Record; +}; + +type DayMeta = { + day: number; + weekday: string; + isWeekend: boolean; +}; + +type GeneratedRoster = { + id: string; + year: number; + month: number; + monthName: string; + days: DayMeta[]; + rows: GuardRosterRow[]; + generatedAt: string; + totalDayShifts: number; + totalNightShifts: number; + totalRestDays: number; +}; + +const currentDate = new Date(); + +const pad = (value: number) => String(value).padStart(2, '0'); + +const daysInMonth = (year: number, month: number) => new Date(year, month, 0).getDate(); + +const getMonthIndex = (year: number, month: number) => year * 12 + month - 1; + +const monthDistance = (startYear: number, startMonth: number, endYear: number, endMonth: number) => + getMonthIndex(endYear, endMonth) - getMonthIndex(startYear, startMonth) + 1; + +const getShift = (guard: (typeof guards)[number], year: number, month: number, day: number) => { + const targetUtc = Date.UTC(year, month - 1, day); + const delta = Math.floor((targetUtc - REFERENCE_UTC) / DAY_MS); + const position = ((guard.offset + delta) % guard.cycle.length + guard.cycle.length) % guard.cycle.length; + return guard.cycle[position] as ShiftCode; +}; + +const buildRoster = (year: number, month: number): GeneratedRoster => { + const totalDays = daysInMonth(year, month); + const days = Array.from({ length: totalDays }, (_, index) => { + const day = index + 1; + const weekdayIndex = new Date(year, month - 1, day).getDay(); + const mondayFirstIndex = (weekdayIndex + 6) % 7; + + return { + day, + weekday: dayAbbrevs[mondayFirstIndex], + isWeekend: weekdayIndex === 0 || weekdayIndex === 6, + }; + }); + + const rows = guards.map((guard) => { + const shifts = days.map((day) => getShift(guard, year, month, day.day)); + const totals = shifts.reduce( + (acc, shift) => ({ ...acc, [shift]: acc[shift] + 1 }), + { D: 0, N: 0, R: 0 }, + ); + + return { + guardName: guard.name, + shifts, + totals, + }; + }); + + return { + id: `${year}-${pad(month)}`, + year, + month, + monthName: `${monthNames[month - 1]} ${year}`, + days, + rows, + generatedAt: new Date().toLocaleString(), + totalDayShifts: rows.reduce((sum, row) => sum + row.totals.D, 0), + totalNightShifts: rows.reduce((sum, row) => sum + row.totals.N, 0), + totalRestDays: rows.reduce((sum, row) => sum + row.totals.R, 0), + }; +}; + +const downloadFile = (content: string, filename: string, mimeType: string) => { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + +const csvCell = (value: string | number) => `"${String(value).replace(/"/g, '""')}"`; + +const buildCsv = (roster: GeneratedRoster) => { + const lines = [ + ['SITE', SITE_NAME], + ['MONTH', roster.monthName], + [], + ['SUPERVISORS', supervisors.map((s) => `${s.name} ${s.code} ${s.phone}`.trim()).join(' | ')], + [], + ['Guard', ...roster.days.map((day) => day.weekday), 'Day total', 'Night total', 'Rest total'], + ['', ...roster.days.map((day) => day.day), '', '', ''], + ...roster.rows.map((row) => [ + row.guardName, + ...row.shifts, + row.totals.D, + row.totals.N, + row.totals.R, + ]), + ]; + + return lines.map((line) => line.map(csvCell).join(',')).join('\n'); +}; + +const buildExcelHtml = (roster: GeneratedRoster) => { + const supervisorText = supervisors.map((s) => `${s.name} ${s.code} ${s.phone}`.trim()).join(' | '); + const columns = roster.days.length + 4; + + return ` + + + + + + + + + + + + ${roster.days.map((day) => ``).join('')} + ${roster.days.map((day) => ``).join('')} + ${roster.rows + .map( + (row) => `${row.shifts + .map((shift) => ``) + .join('')}`, + ) + .join('')} +
SITE: ${SITE_NAME}
DUTY ROSTER — ${roster.monthName.toUpperCase()}
SUPERVISORS: ${supervisorText}
D = Day Shift    N = Night Shift    R = Rest Day
Guard${day.weekday}DNR
Date${day.day}
${row.guardName}${shift}${row.totals.D}${row.totals.N}${row.totals.R}
+ +`; +}; + +const buildRange = (startYear: number, startMonth: number, endYear: number, endMonth: number) => { + const count = monthDistance(startYear, startMonth, endYear, endMonth); + + return Array.from({ length: count }, (_, index) => { + const monthIndex = getMonthIndex(startYear, startMonth) + index; + return buildRoster(Math.floor(monthIndex / 12), (monthIndex % 12) + 1); + }); +}; + +const FieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( + +); + +const inputClass = + 'w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100'; + +export default function RosterGeneratorWorkflow() { + const [startYear, setStartYear] = useState(currentDate.getFullYear()); + const [startMonth, setStartMonth] = useState(currentDate.getMonth() + 1); + const [endYear, setEndYear] = useState(currentDate.getFullYear()); + const [endMonth, setEndMonth] = useState(currentDate.getMonth() + 1); + const [generatedRosters, setGeneratedRosters] = useState([]); + const [selectedRosterId, setSelectedRosterId] = useState(''); + const [error, setError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + const selectedRoster = useMemo( + () => generatedRosters.find((roster) => roster.id === selectedRosterId) ?? generatedRosters[0], + [generatedRosters, selectedRosterId], + ); + + const handleGenerate = () => { + setError(''); + setSuccessMessage(''); + + const monthCount = monthDistance(startYear, startMonth, endYear, endMonth); + + if (startYear < 2020 || endYear > 2035) { + setError('Please choose years from 2020 through 2035 for this first roster slice.'); + return; + } + + if (monthCount <= 0) { + setError('End month must be the same as, or after, the start month.'); + return; + } + + if (monthCount > MAX_MONTHS) { + setError(`Generate up to ${MAX_MONTHS} months at a time to keep the preview fast.`); + return; + } + + const rosters = buildRange(startYear, startMonth, endYear, endMonth); + setGeneratedRosters(rosters); + setSelectedRosterId(rosters[0].id); + setSuccessMessage( + `${rosters.length} roster${rosters.length === 1 ? '' : 's'} generated and ready to review or export.`, + ); + }; + + const handleExportCsv = (roster: GeneratedRoster) => { + downloadFile(buildCsv(roster), `pierneef_roster_${roster.id}.csv`, 'text/csv;charset=utf-8;'); + }; + + const handleExportExcel = (roster: GeneratedRoster) => { + downloadFile( + buildExcelHtml(roster), + `pierneef_roster_${roster.id}.xls`, + 'application/vnd.ms-excel;charset=utf-8;', + ); + }; + + return ( +
+
+ +
+
+

Roster studio

+

Generate a monthly guard roster

+

+ Select a month or date range, generate the Pierneef rotation, inspect the result, then export the active + month as CSV or Excel. +

+
+ +
+
Site
+
{SITE_NAME}
+
+ {(['D', 'N', 'R'] as ShiftCode[]).map((shift) => ( +
+ + {shift} + +

{shiftStyles[shift].label}

+
+ ))} +
+
+ +
+ + setStartYear(Number(event.target.value))} + /> + + + + + + setEndYear(Number(event.target.value))} + /> + + + + +
+ + {error &&
{error}
} + {successMessage && ( +
+ {successMessage} +
+ )} + + +
+
+ +
+ +
+
+

Generated rosters

+

+ {generatedRosters.length ? `${generatedRosters.length} month${generatedRosters.length === 1 ? '' : 's'} ready` : 'No roster yet'} +

+
+ {selectedRoster && ( +
+ handleExportCsv(selectedRoster)} /> + handleExportExcel(selectedRoster)} /> +
+ )} +
+ + {!generatedRosters.length ? ( +
+
D
+

Start by generating a month

+

+ Your generated months appear here as a review list. Select one to open the detailed duty matrix and export files. +

+
+ ) : ( +
+ {generatedRosters.map((roster) => ( + + ))} +
+ )} +
+ + {selectedRoster && ( + +
+
+

Detail preview

+

Duty roster — {selectedRoster.monthName}

+

Supervisors: {supervisors.map((s) => s.name).join(', ')}

+
+
+ {(['D', 'N', 'R'] as ShiftCode[]).map((shift) => ( + + {shift} = {shiftStyles[shift].label} + + ))} +
+
+ +
+ + + + + {selectedRoster.days.map((day) => ( + + ))} + + + + + + + {selectedRoster.days.map((day) => ( + + ))} + + + + + + + {selectedRoster.rows.map((row) => ( + + + {row.shifts.map((shift, index) => ( + + ))} + + + + + ))} + +
Guard + {day.weekday} + DNR
Date + {day.day} +
+ {row.guardName} + + {shift} + {row.totals.D}{row.totals.N}{row.totals.R}
+
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 89b84cf..424c95c 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [ label: 'Dashboard', }, + { + href: '/', + icon: icon.mdiCalendarEdit, + label: 'Roster generator', + }, + { href: '/users/users-list', label: 'Users', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 1f6666f..050deba 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,126 @@ - -import React, { useEffect, useState } from 'react'; +import React from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; -import Link from 'next/link'; import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; +import RosterGeneratorWorkflow from '../components/RosterGeneratorWorkflow'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; - export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('background'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Pierneef Duty Roster' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Pierneef Duty Roster Generator')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+ + +
+
+

+ Cultural from Pierneef Museum +

+

+ A modern duty roster generator for museum guard teams. +

+

+ Generate month-by-month D/N/R shift rotations, review guard coverage instantly, and export clean CSV or + Excel-compatible files for operations and records. +

+
+ +
+
+ {[ + ['D', 'Day shift', 'Blue coverage'], + ['N', 'Night shift', 'Purple coverage'], + ['R', 'Rest day', 'Slate balance'], + ].map(([code, label, helper]) => ( +
+
{code}
+
{label}
+
{helper}
+
+ ))} +
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+
+
+
+
+

May 2026

+

Duty matrix preview

+
+ Ready +
+
+ {[ + ['Mahlangu C.', ['D', 'D', 'N', 'N', 'R', 'R']], + ['Tshabalala N.', ['D', 'D', 'R', 'R', 'D', 'D']], + ['Malatjie P.', ['R', 'R', 'D', 'D', 'N', 'N']], + ].map(([name, shifts]) => ( +
+
{name as string}
+ {(shifts as string[]).map((shift, index) => ( + + {shift} + + ))} +
+ ))} +
+
+
+
+
+
-
+ + + ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; -