Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb02d048d7 |
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
508
frontend/src/components/RosterGeneratorWorkflow.tsx
Normal file
508
frontend/src/components/RosterGeneratorWorkflow.tsx
Normal file
@ -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<ShiftCode, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; color: #1E293B; }
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #CBD5E1; padding: 6px; text-align: center; }
|
||||||
|
.title { background: #E2E8F0; font-size: 16px; font-weight: 700; }
|
||||||
|
.month { background: #F1F5F9; font-size: 13px; font-weight: 700; }
|
||||||
|
.supervisors { background: #F8FAFC; text-align: left; color: #475569; }
|
||||||
|
.guard { text-align: left; font-weight: 700; min-width: 150px; }
|
||||||
|
.D { background: ${shiftStyles.D.hex}; color: #1E3A5F; font-weight: 700; }
|
||||||
|
.N { background: ${shiftStyles.N.hex}; color: #3B0764; font-weight: 700; }
|
||||||
|
.R { background: ${shiftStyles.R.hex}; color: #64748B; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table>
|
||||||
|
<tr><td class="title" colspan="${columns}">SITE: ${SITE_NAME}</td></tr>
|
||||||
|
<tr><td class="month" colspan="${columns}">DUTY ROSTER — ${roster.monthName.toUpperCase()}</td></tr>
|
||||||
|
<tr><td class="supervisors" colspan="${columns}">SUPERVISORS: ${supervisorText}</td></tr>
|
||||||
|
<tr><td class="supervisors" colspan="${columns}">D = Day Shift N = Night Shift R = Rest Day</td></tr>
|
||||||
|
<tr><th>Guard</th>${roster.days.map((day) => `<th>${day.weekday}</th>`).join('')}<th>D</th><th>N</th><th>R</th></tr>
|
||||||
|
<tr><th>Date</th>${roster.days.map((day) => `<th>${day.day}</th>`).join('')}<th></th><th></th><th></th></tr>
|
||||||
|
${roster.rows
|
||||||
|
.map(
|
||||||
|
(row) => `<tr><td class="guard">${row.guardName}</td>${row.shifts
|
||||||
|
.map((shift) => `<td class="${shift}">${shift}</td>`)
|
||||||
|
.join('')}<td>${row.totals.D}</td><td>${row.totals.N}</td><td>${row.totals.R}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }) => (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-slate-700">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<GeneratedRoster[]>([]);
|
||||||
|
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 (
|
||||||
|
<section id="generator" className="mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<CardBox className="border-0 bg-white/90 shadow-2xl shadow-slate-900/10 ring-1 ring-slate-200/70 backdrop-blur">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.3em] text-orange-500">Roster studio</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950">Generate a monthly guard roster</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||||
|
Select a month or date range, generate the Pierneef rotation, inspect the result, then export the active
|
||||||
|
month as CSV or Excel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl bg-gradient-to-br from-slate-950 via-slate-900 to-sky-950 p-5 text-white shadow-xl">
|
||||||
|
<div className="text-xs font-bold uppercase tracking-[0.25em] text-sky-200">Site</div>
|
||||||
|
<div className="mt-2 text-xl font-black">{SITE_NAME}</div>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
{(['D', 'N', 'R'] as ShiftCode[]).map((shift) => (
|
||||||
|
<div key={shift} className="rounded-2xl bg-white/10 p-3 ring-1 ring-white/10">
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-black ring-1 ${shiftStyles[shift].chip}`}>
|
||||||
|
{shift}
|
||||||
|
</span>
|
||||||
|
<p className="mt-2 text-sm text-slate-200">{shiftStyles[shift].label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldLabel label="Start year">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
min="2020"
|
||||||
|
max="2035"
|
||||||
|
type="number"
|
||||||
|
value={startYear}
|
||||||
|
onChange={(event) => setStartYear(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldLabel label="Start month">
|
||||||
|
<select className={inputClass} value={startMonth} onChange={(event) => setStartMonth(Number(event.target.value))}>
|
||||||
|
{monthNames.map((month, index) => (
|
||||||
|
<option key={month} value={index + 1}>
|
||||||
|
{month}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldLabel label="End year">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
min="2020"
|
||||||
|
max="2035"
|
||||||
|
type="number"
|
||||||
|
value={endYear}
|
||||||
|
onChange={(event) => setEndYear(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldLabel label="End month">
|
||||||
|
<select className={inputClass} value={endMonth} onChange={(event) => setEndMonth(Number(event.target.value))}>
|
||||||
|
{monthNames.map((month, index) => (
|
||||||
|
<option key={month} value={index + 1}>
|
||||||
|
{month}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FieldLabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{error}</div>}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
className="w-full border-0 bg-orange-500 py-3 text-base font-black text-white hover:bg-orange-600 focus:ring-orange-200"
|
||||||
|
label="Generate roster"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
roundedFull
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CardBox className="border-0 bg-white/90 shadow-2xl shadow-slate-900/10 ring-1 ring-slate-200/70 backdrop-blur">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.25em] text-sky-600">Generated rosters</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-black text-slate-950">
|
||||||
|
{generatedRosters.length ? `${generatedRosters.length} month${generatedRosters.length === 1 ? '' : 's'} ready` : 'No roster yet'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{selectedRoster && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<BaseButton small color="info" label="Export CSV" onClick={() => handleExportCsv(selectedRoster)} />
|
||||||
|
<BaseButton small color="success" label="Export Excel" onClick={() => handleExportExcel(selectedRoster)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!generatedRosters.length ? (
|
||||||
|
<div className="mt-6 rounded-3xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-100 text-2xl font-black text-sky-700">D</div>
|
||||||
|
<h4 className="mt-4 text-lg font-black text-slate-900">Start by generating a month</h4>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-slate-600">
|
||||||
|
Your generated months appear here as a review list. Select one to open the detailed duty matrix and export files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
{generatedRosters.map((roster) => (
|
||||||
|
<button
|
||||||
|
key={roster.id}
|
||||||
|
className={`rounded-3xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg focus:outline-none focus:ring-4 focus:ring-sky-100 ${
|
||||||
|
selectedRoster?.id === roster.id ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white'
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedRosterId(roster.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-black text-slate-950">{roster.monthName}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Generated {roster.generatedAt}</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-white px-3 py-1 text-xs font-bold text-slate-600 ring-1 ring-slate-200">
|
||||||
|
{roster.days.length} days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||||
|
<div className="rounded-2xl bg-sky-100 p-2 text-center text-xs font-black text-sky-800">D {roster.totalDayShifts}</div>
|
||||||
|
<div className="rounded-2xl bg-violet-100 p-2 text-center text-xs font-black text-violet-800">N {roster.totalNightShifts}</div>
|
||||||
|
<div className="rounded-2xl bg-slate-100 p-2 text-center text-xs font-black text-slate-600">R {roster.totalRestDays}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
{selectedRoster && (
|
||||||
|
<CardBox className="border-0 bg-white shadow-2xl shadow-slate-900/10 ring-1 ring-slate-200/70">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.25em] text-orange-500">Detail preview</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-black text-slate-950">Duty roster — {selectedRoster.monthName}</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">Supervisors: {supervisors.map((s) => s.name).join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['D', 'N', 'R'] as ShiftCode[]).map((shift) => (
|
||||||
|
<span key={shift} className={`rounded-full px-3 py-1 text-xs font-black ring-1 ${shiftStyles[shift].chip}`}>
|
||||||
|
{shift} = {shiftStyles[shift].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-x-auto rounded-3xl border border-slate-200">
|
||||||
|
<table className="min-w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-950 text-white">
|
||||||
|
<th className="sticky left-0 z-10 min-w-40 bg-slate-950 px-4 py-3 text-left font-black">Guard</th>
|
||||||
|
{selectedRoster.days.map((day) => (
|
||||||
|
<th key={`weekday-${day.day}`} className={`px-3 py-3 text-center font-bold ${day.isWeekend ? 'text-orange-200' : ''}`}>
|
||||||
|
{day.weekday}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-3 py-3">D</th>
|
||||||
|
<th className="px-3 py-3">N</th>
|
||||||
|
<th className="px-3 py-3">R</th>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-slate-100 text-slate-700">
|
||||||
|
<th className="sticky left-0 z-10 bg-slate-100 px-4 py-2 text-left font-bold">Date</th>
|
||||||
|
{selectedRoster.days.map((day) => (
|
||||||
|
<th key={`date-${day.day}`} className={`px-3 py-2 text-center ${day.isWeekend ? 'text-orange-600' : ''}`}>
|
||||||
|
{day.day}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{selectedRoster.rows.map((row) => (
|
||||||
|
<tr key={row.guardName} className="border-t border-slate-200">
|
||||||
|
<td className="sticky left-0 z-10 bg-white px-4 py-3 font-black text-slate-900 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)]">
|
||||||
|
{row.guardName}
|
||||||
|
</td>
|
||||||
|
{row.shifts.map((shift, index) => (
|
||||||
|
<td key={`${row.guardName}-${selectedRoster.days[index].day}`} className={`px-3 py-3 text-center font-black ${shiftStyles[shift].table}`}>
|
||||||
|
{shift}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-3 text-center font-black text-sky-800">{row.totals.D}</td>
|
||||||
|
<td className="px-3 py-3 text-center font-black text-violet-800">{row.totals.N}</td>
|
||||||
|
<td className="px-3 py-3 text-center font-black text-slate-600">{row.totals.R}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/',
|
||||||
|
icon: icon.mdiCalendarEdit,
|
||||||
|
label: 'Roster generator',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
|
|||||||
@ -1,166 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
import RosterGeneratorWorkflow from '../components/RosterGeneratorWorkflow';
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
|
||||||
export default function Starter() {
|
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) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('Pierneef Duty Roster Generator')}</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Generate monthly guard duty rosters for Cultural from Pierneef Museum and export CSV or Excel files."
|
||||||
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<main className="min-h-screen overflow-hidden bg-[#F8FAFC] text-slate-950">
|
||||||
<div
|
<section className="relative isolate px-4 pb-10 pt-6 sm:px-6 lg:px-8">
|
||||||
className={`flex ${
|
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top_left,#BAE6FD_0,transparent_35%),radial-gradient(circle_at_top_right,#FED7AA_0,transparent_32%),linear-gradient(135deg,#F8FAFC_0%,#EEF2FF_100%)]" />
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 rounded-full border border-white/80 bg-white/70 px-4 py-3 shadow-xl shadow-slate-900/5 backdrop-blur">
|
||||||
} min-h-screen w-full`}
|
<a className="flex items-center gap-3" href="#top" aria-label="Pierneef Duty Roster home">
|
||||||
>
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-950 text-sm font-black text-white">P</span>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<span>
|
||||||
? imageBlock(illustrationImage)
|
<span className="block text-sm font-black leading-4 text-slate-950">Pierneef</span>
|
||||||
: null}
|
<span className="block text-xs font-semibold text-slate-500">Duty Roster</span>
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
</span>
|
||||||
? videoBlock(illustrationVideo)
|
</a>
|
||||||
: null}
|
<div className="flex items-center gap-2">
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<a className="hidden rounded-full px-4 py-2 text-sm font-bold text-slate-600 hover:text-slate-950 sm:inline-flex" href="#generator">
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
Generator
|
||||||
<CardBoxComponentTitle title="Welcome to your Pierneef Duty Roster app!"/>
|
</a>
|
||||||
|
<BaseButton href="/login" label="Admin login" color="info" roundedFull small />
|
||||||
<div className="space-y-3">
|
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<BaseButtons>
|
|
||||||
|
<div id="top" className="mx-auto grid max-w-7xl items-center gap-12 py-16 lg:grid-cols-[1.05fr_0.95fr] lg:py-24">
|
||||||
|
<div>
|
||||||
|
<p className="inline-flex rounded-full bg-white px-4 py-2 text-xs font-black uppercase tracking-[0.28em] text-orange-600 shadow-sm ring-1 ring-orange-100">
|
||||||
|
Cultural from Pierneef Museum
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-6 max-w-4xl text-5xl font-black tracking-tight text-slate-950 sm:text-6xl lg:text-7xl">
|
||||||
|
A modern duty roster generator for museum guard teams.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
href='/login'
|
href="#generator"
|
||||||
label='Login'
|
label="Generate roster"
|
||||||
color='info'
|
className="border-0 bg-orange-500 px-6 py-3 text-base font-black text-white hover:bg-orange-600 focus:ring-orange-200"
|
||||||
className='w-full'
|
roundedFull
|
||||||
/>
|
/>
|
||||||
|
<BaseButton href="/login" label="Open admin interface" color="white" roundedFull />
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
['D', 'Day shift', 'Blue coverage'],
|
||||||
|
['N', 'Night shift', 'Purple coverage'],
|
||||||
|
['R', 'Rest day', 'Slate balance'],
|
||||||
|
].map(([code, label, helper]) => (
|
||||||
|
<div key={code} className="rounded-3xl border border-white bg-white/75 p-4 shadow-sm backdrop-blur">
|
||||||
|
<div className="text-3xl font-black text-slate-950">{code}</div>
|
||||||
|
<div className="mt-1 text-sm font-bold text-slate-700">{label}</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">{helper}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</BaseButtons>
|
<div className="relative">
|
||||||
</CardBox>
|
<div className="absolute -left-8 -top-8 h-40 w-40 rounded-full bg-orange-300/40 blur-3xl" />
|
||||||
</div>
|
<div className="absolute -bottom-10 -right-6 h-56 w-56 rounded-full bg-sky-300/40 blur-3xl" />
|
||||||
</div>
|
<div className="relative overflow-hidden rounded-[2rem] border border-white/80 bg-slate-950 p-5 text-white shadow-2xl shadow-slate-900/30">
|
||||||
</SectionFullScreen>
|
<div className="rounded-[1.5rem] bg-white/10 p-5 ring-1 ring-white/10">
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<div className="flex items-center justify-between">
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
<div>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<p className="text-xs font-black uppercase tracking-[0.25em] text-sky-200">May 2026</p>
|
||||||
Privacy Policy
|
<h2 className="mt-2 text-2xl font-black">Duty matrix preview</h2>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-xs font-black text-emerald-200 ring-1 ring-emerald-300/30">Ready</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{[
|
||||||
|
['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]) => (
|
||||||
|
<div key={name as string} className="grid grid-cols-[1fr_repeat(6,2.25rem)] items-center gap-2 rounded-2xl bg-white/10 p-3">
|
||||||
|
<div className="truncate text-sm font-bold text-slate-100">{name as string}</div>
|
||||||
|
{(shifts as string[]).map((shift, index) => (
|
||||||
|
<span
|
||||||
|
key={`${name}-${index}`}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-xl text-xs font-black ${
|
||||||
|
shift === 'D'
|
||||||
|
? 'bg-sky-200 text-sky-950'
|
||||||
|
: shift === 'N'
|
||||||
|
? 'bg-violet-200 text-violet-950'
|
||||||
|
: 'bg-slate-200 text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{shift}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
<RosterGeneratorWorkflow />
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user