Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
bb02d048d7 v1 2026-05-31 03:56:41 +00:00
5 changed files with 620 additions and 148 deletions

View File

@ -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'

View 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 &nbsp;&nbsp; N = Night Shift &nbsp;&nbsp; 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>
);
}

View File

@ -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'

View File

@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [
label: 'Dashboard',
},
{
href: '/',
icon: icon.mdiCalendarEdit,
label: 'Roster generator',
},
{
href: '/users/users-list',
label: 'Users',

View File

@ -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) => (
<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 (
<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>
<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>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Pierneef Duty Roster app!"/>
<main className="min-h-screen overflow-hidden bg-[#F8FAFC] text-slate-950">
<section className="relative isolate px-4 pb-10 pt-6 sm:px-6 lg:px-8">
<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%)]" />
<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">
<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>
<span>
<span className="block text-sm font-black leading-4 text-slate-950">Pierneef</span>
<span className="block text-xs font-semibold text-slate-500">Duty Roster</span>
</span>
</a>
<div className="flex items-center gap-2">
<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">
Generator
</a>
<BaseButton href="/login" label="Admin login" color="info" roundedFull small />
</div>
</div>
<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 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
href="#generator"
label="Generate roster"
className="border-0 bg-orange-500 px-6 py-3 text-base font-black text-white hover:bg-orange-600 focus:ring-orange-200"
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>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<div className="relative">
<div className="absolute -left-8 -top-8 h-40 w-40 rounded-full bg-orange-300/40 blur-3xl" />
<div className="absolute -bottom-10 -right-6 h-56 w-56 rounded-full bg-sky-300/40 blur-3xl" />
<div className="relative overflow-hidden rounded-[2rem] border border-white/80 bg-slate-950 p-5 text-white shadow-2xl shadow-slate-900/30">
<div className="rounded-[1.5rem] bg-white/10 p-5 ring-1 ring-white/10">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-black uppercase tracking-[0.25em] text-sky-200">May 2026</p>
<h2 className="mt-2 text-2xl font-black">Duty matrix preview</h2>
</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>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
<RosterGeneratorWorkflow />
</main>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};