Files
route-commerce/src/components/admin/TimeTrackingSettingsClient.tsx
T
Tyler fe78645609 perf: optimize admin pages for <50ms TTFB
- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms)
- Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts)
- Auth fast-path: short-circuit Neon Auth DNS calls when not configured
- PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking
- Stale build artifacts fix (clean rebuild restores fast behavior)

Measured (production build, sequential requests, dev_session cookie):
  - 102/103 pages: TTFB ≤ 10ms
  - 1 page: TTFB 11-20ms
  - 0 pages exceed 50ms TTFB
  - First Paint (browser): 28-84ms on admin pages
2026-06-26 18:55:46 -06:00

1444 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useReducer, useEffect, useCallback, useRef } from "react";
function triggerDownload(url: string) {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
const triggerLabel: Record<string, { en: string; color: string }> = {
daily_approaching: { en: "Daily OT Approaching", color: "bg-amber-100 text-amber-700" },
daily_reached: { en: "Daily OT Reached", color: "bg-red-100 text-red-700" },
weekly_approaching: { en: "Weekly OT Approaching", color: "bg-amber-100 text-amber-700" },
weekly_reached: { en: "Weekly OT Reached", color: "bg-red-100 text-red-700" },
end_of_period_summary:{ en: "Period Summary", color: "bg-green-100 text-green-700" },
};
import {
getTimeTrackingWorkers,
getTimeTrackingTasks,
createTimeWorker,
resetTimeWorkerPin,
updateTimeWorker,
deleteTimeWorker,
createTimeTask,
updateTimeTask,
deleteTimeTask,
getTimeTrackingSettings,
updateTimeTrackingSettings,
getTimeTrackingNotificationLog,
type TimeWorker,
type TimeTask,
type TimeTrackingSettings,
type NotificationLogEntry,
} from "@/actions/time-tracking";
import { AdminButton } from "@/components/admin/design-system";
// One-color outline icons
const Icons = {
clock: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
dollarSign: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="2" y2="22"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
),
clipboard: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10"/>
<line x1="18" x2="18" y1="20" y2="4"/>
<line x1="6" x2="6" y1="20" y2="16"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
),
alert: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
<path d="M12 9v4"/>
<path d="M12 17h.01"/>
</svg>
),
};
type Tab = "dashboard" | "settings" | "export";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
interface TimeTrackingSettingsClientProps {
brandId: string;
}
type State = {
tab: Tab;
workers: TimeWorker[];
tasks: TimeTask[];
loading: boolean;
notificationLog: NotificationLogEntry[];
payPeriodStartDay: number;
payPeriodLength: number;
dailyOvertimeThreshold: number;
weeklyOvertimeThreshold: number;
overtimeNotifications: boolean;
settingsSaving: boolean;
settingsSaved: boolean;
settingsError: string | null;
notificationEmails: string[];
notificationSmsNumbers: string[];
enableDailyAlerts: boolean;
enableWeeklyAlerts: boolean;
newEmail: string;
newSms: string;
showWorkerModal: boolean;
editingWorker: TimeWorker | null;
workerName: string;
workerRole: string;
workerLang: string;
workerActive: boolean;
resetPinResult: string | null;
workerError: string | null;
submitting: boolean;
showTaskModal: boolean;
editingTask: TimeTask | null;
taskName: string;
taskNameEs: string;
taskUnit: string;
taskActive: boolean;
taskSortOrder: number;
taskError: string | null;
exportStart: string;
exportEnd: string;
exportBrand: "all" | "tuxedo" | "ird";
includeOvertime: boolean;
includeNotes: boolean;
};
type Action =
| { type: "SET_TAB"; tab: Tab }
| { type: "SET_WORKERS"; workers: TimeWorker[] }
| { type: "SET_TASKS"; tasks: TimeTask[] }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_NOTIFICATION_LOG"; log: NotificationLogEntry[] }
| { type: "SYNC_SETTINGS"; settings: TimeTrackingSettings }
| { type: "SET_PAY_PERIOD_START_DAY"; value: number }
| { type: "SET_PAY_PERIOD_LENGTH"; value: number }
| { type: "SET_DAILY_OT_THRESHOLD"; value: number }
| { type: "SET_WEEKLY_OT_THRESHOLD"; value: number }
| { type: "SET_OT_NOTIFICATIONS"; value: boolean }
| { type: "SET_SETTINGS_SAVING"; value: boolean }
| { type: "SET_SETTINGS_SAVED"; value: boolean }
| { type: "SET_SETTINGS_ERROR"; value: string | null }
| { type: "SET_NOTIFICATION_EMAILS"; value: string[] }
| { type: "SET_NOTIFICATION_SMS_NUMBERS"; value: string[] }
| { type: "SET_ENABLE_DAILY_ALERTS"; value: boolean }
| { type: "SET_ENABLE_WEEKLY_ALERTS"; value: boolean }
| { type: "SET_NEW_EMAIL"; value: string }
| { type: "SET_NEW_SMS"; value: string }
| { type: "SET_SHOW_WORKER_MODAL"; value: boolean }
| { type: "SET_EDITING_WORKER"; value: TimeWorker | null }
| { type: "SET_WORKER_NAME"; value: string }
| { type: "SET_WORKER_ROLE"; value: string }
| { type: "SET_WORKER_LANG"; value: string }
| { type: "SET_WORKER_ACTIVE"; value: boolean }
| { type: "SET_RESET_PIN_RESULT"; value: string | null }
| { type: "SET_WORKER_ERROR"; value: string | null }
| { type: "SET_SUBMITTING"; value: boolean }
| { type: "RESET_WORKER_MODAL" }
| { type: "SET_SHOW_TASK_MODAL"; value: boolean }
| { type: "SET_EDITING_TASK"; value: TimeTask | null }
| { type: "SET_TASK_NAME"; value: string }
| { type: "SET_TASK_NAME_ES"; value: string }
| { type: "SET_TASK_UNIT"; value: string }
| { type: "SET_TASK_ACTIVE"; value: boolean }
| { type: "SET_TASK_SORT_ORDER"; value: number }
| { type: "SET_TASK_ERROR"; value: string | null }
| { type: "RESET_TASK_MODAL" }
| { type: "SET_EXPORT_START"; value: string }
| { type: "SET_EXPORT_END"; value: string }
| { type: "SET_EXPORT_BRAND"; value: "all" | "tuxedo" | "ird" }
| { type: "SET_INCLUDE_OVERTIME"; value: boolean }
| { type: "SET_INCLUDE_NOTES"; value: boolean };
const initialState: State = {
tab: "dashboard",
workers: [],
tasks: [],
loading: true,
notificationLog: [],
payPeriodStartDay: 0,
payPeriodLength: 7,
dailyOvertimeThreshold: 12,
weeklyOvertimeThreshold: 56,
overtimeNotifications: true,
settingsSaving: false,
settingsSaved: false,
settingsError: null,
notificationEmails: [],
notificationSmsNumbers: [],
enableDailyAlerts: true,
enableWeeklyAlerts: true,
newEmail: "",
newSms: "",
showWorkerModal: false,
editingWorker: null,
workerName: "",
workerRole: "worker",
workerLang: "en",
workerActive: true,
resetPinResult: null,
workerError: null,
submitting: false,
showTaskModal: false,
editingTask: null,
taskName: "",
taskNameEs: "",
taskUnit: "hours",
taskActive: true,
taskSortOrder: 0,
taskError: null,
exportStart: "",
exportEnd: "",
exportBrand: "all",
includeOvertime: true,
includeNotes: true,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_TAB":
return { ...state, tab: action.tab };
case "SET_WORKERS":
return { ...state, workers: action.workers };
case "SET_TASKS":
return { ...state, tasks: action.tasks };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_NOTIFICATION_LOG":
return { ...state, notificationLog: action.log };
case "SYNC_SETTINGS":
return {
...state,
payPeriodStartDay: action.settings.pay_period_start_day,
payPeriodLength: action.settings.pay_period_length_days,
dailyOvertimeThreshold: action.settings.daily_overtime_threshold,
weeklyOvertimeThreshold: action.settings.weekly_overtime_threshold,
overtimeNotifications: action.settings.overtime_notifications,
notificationEmails: action.settings.notification_emails ?? [],
notificationSmsNumbers: action.settings.notification_sms_numbers ?? [],
enableDailyAlerts: action.settings.enable_daily_alerts ?? true,
enableWeeklyAlerts: action.settings.enable_weekly_alerts ?? true,
};
case "SET_PAY_PERIOD_START_DAY":
return { ...state, payPeriodStartDay: action.value };
case "SET_PAY_PERIOD_LENGTH":
return { ...state, payPeriodLength: action.value };
case "SET_DAILY_OT_THRESHOLD":
return { ...state, dailyOvertimeThreshold: action.value };
case "SET_WEEKLY_OT_THRESHOLD":
return { ...state, weeklyOvertimeThreshold: action.value };
case "SET_OT_NOTIFICATIONS":
return { ...state, overtimeNotifications: action.value };
case "SET_SETTINGS_SAVING":
return { ...state, settingsSaving: action.value };
case "SET_SETTINGS_SAVED":
return { ...state, settingsSaved: action.value };
case "SET_SETTINGS_ERROR":
return { ...state, settingsError: action.value };
case "SET_NOTIFICATION_EMAILS":
return { ...state, notificationEmails: action.value };
case "SET_NOTIFICATION_SMS_NUMBERS":
return { ...state, notificationSmsNumbers: action.value };
case "SET_ENABLE_DAILY_ALERTS":
return { ...state, enableDailyAlerts: action.value };
case "SET_ENABLE_WEEKLY_ALERTS":
return { ...state, enableWeeklyAlerts: action.value };
case "SET_NEW_EMAIL":
return { ...state, newEmail: action.value };
case "SET_NEW_SMS":
return { ...state, newSms: action.value };
case "SET_SHOW_WORKER_MODAL":
return { ...state, showWorkerModal: action.value };
case "SET_EDITING_WORKER":
return { ...state, editingWorker: action.value };
case "SET_WORKER_NAME":
return { ...state, workerName: action.value };
case "SET_WORKER_ROLE":
return { ...state, workerRole: action.value };
case "SET_WORKER_LANG":
return { ...state, workerLang: action.value };
case "SET_WORKER_ACTIVE":
return { ...state, workerActive: action.value };
case "SET_RESET_PIN_RESULT":
return { ...state, resetPinResult: action.value };
case "SET_WORKER_ERROR":
return { ...state, workerError: action.value };
case "SET_SUBMITTING":
return { ...state, submitting: action.value };
case "RESET_WORKER_MODAL":
return {
...state,
editingWorker: null,
workerName: "",
workerRole: "worker",
workerLang: "en",
workerActive: true,
workerError: null,
resetPinResult: null,
};
case "SET_SHOW_TASK_MODAL":
return { ...state, showTaskModal: action.value };
case "SET_EDITING_TASK":
return { ...state, editingTask: action.value };
case "SET_TASK_NAME":
return { ...state, taskName: action.value };
case "SET_TASK_NAME_ES":
return { ...state, taskNameEs: action.value };
case "SET_TASK_UNIT":
return { ...state, taskUnit: action.value };
case "SET_TASK_ACTIVE":
return { ...state, taskActive: action.value };
case "SET_TASK_SORT_ORDER":
return { ...state, taskSortOrder: action.value };
case "SET_TASK_ERROR":
return { ...state, taskError: action.value };
case "RESET_TASK_MODAL":
return {
...state,
editingTask: null,
taskName: "",
taskNameEs: "",
taskUnit: "hours",
taskActive: true,
taskSortOrder: 0,
taskError: null,
};
case "SET_EXPORT_START":
return { ...state, exportStart: action.value };
case "SET_EXPORT_END":
return { ...state, exportEnd: action.value };
case "SET_EXPORT_BRAND":
return { ...state, exportBrand: action.value };
case "SET_INCLUDE_OVERTIME":
return { ...state, includeOvertime: action.value };
case "SET_INCLUDE_NOTES":
return { ...state, includeNotes: action.value };
}
}
/* ── Subcomponents ─────────────────────────────────────────────── */
function StatRow({
workersCount,
tasksCount,
alertsSent,
}: {
workersCount: number;
tasksCount: number;
alertsSent: number;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
<p className="text-3xl font-bold text-[var(--admin-text-primary)]">{workersCount}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">Workers</p>
</div>
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
<p className="text-3xl font-bold text-[var(--admin-text-primary)]">{tasksCount}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">Tasks</p>
</div>
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
<p className="text-3xl font-bold text-amber-600">{alertsSent}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">Alerts Sent</p>
</div>
</div>
);
}
function ExportSection({
exportStart,
exportEnd,
buildExportUrl,
onChangeStart,
onChangeEnd,
}: {
exportStart: string;
exportEnd: string;
buildExportUrl: (type: "quickbooks" | "payroll" | "detailed" | "summary") => string;
onChangeStart: (v: string) => void;
onChangeEnd: (v: string) => void;
}) {
return (
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Export Data</h3>
<div className="grid grid-cols-2 gap-3">
<button type="button" onClick={() => triggerDownload(buildExportUrl("quickbooks"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-[var(--admin-accent)] transition-all text-left" aria-label="QuickBooks Time import CSV">
<span className="text-[var(--admin-text-muted)]">{Icons.clock("h-5 w-5")}</span>
<div>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">QuickBooks</p>
<p className="text-xs text-[var(--admin-text-muted)]">Time import CSV</p>
</div>
</button>
<button type="button" onClick={() => triggerDownload(buildExportUrl("payroll"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-blue-500 transition-all text-left" aria-label="Payroll ADP / Gusto / Generic">
<span className="text-[var(--admin-text-muted)]">{Icons.dollarSign("h-5 w-5")}</span>
<div>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Payroll</p>
<p className="text-xs text-[var(--admin-text-muted)]">ADP / Gusto / Generic</p>
</div>
</button>
<button type="button" onClick={() => triggerDownload(buildExportUrl("detailed"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-violet-500 transition-all text-left" aria-label="Detailed Log Full audit report">
<span className="text-[var(--admin-text-muted)]">{Icons.clipboard("h-5 w-5")}</span>
<div>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Detailed Log</p>
<p className="text-xs text-[var(--admin-text-muted)]">Full audit report</p>
</div>
</button>
<button type="button" onClick={() => triggerDownload(buildExportUrl("summary"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-amber-500 transition-all text-left" aria-label="Period Summary Per-worker totals">
<span className="text-[var(--admin-text-muted)]">{Icons.chart("h-5 w-5")}</span>
<div>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Period Summary</p>
<p className="text-xs text-[var(--admin-text-muted)]">Per-worker totals</p>
</div>
</button>
</div>
<div className="flex items-center gap-4 text-xs text-[var(--admin-text-muted)]">
<span>From:</span>
<input aria-label="Date" type="date" value={exportStart} onChange={e => onChangeStart(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)]" />
<span>To:</span>
<input aria-label="Date" type="date" value={exportEnd} onChange={e => onChangeEnd(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
</div>
);
}
function NotificationLogSection({ log }: { log: NotificationLogEntry[] }) {
return (
<div className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Recent Alerts</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Time</th>
<th className="text-left px-5 py-3 font-medium">Worker</th>
<th className="text-left px-5 py-3 font-medium">Alert</th>
<th className="text-left px-5 py-3 font-medium">Email</th>
<th className="text-left px-5 py-3 font-medium">SMS</th>
</tr>
</thead>
<tbody>
{log.length === 0 ? (
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No alerts sent yet</td></tr>
) : log.slice(0, 20).map(entry => {
const meta = triggerLabel[entry.trigger_type] ?? { en: entry.trigger_type, color: "bg-stone-100 text-stone-500" };
return (
<tr key={entry.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(entry.created_at).toLocaleString()}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-medium">{entry.worker_name ?? "—"}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>{meta.en}</span>
</td>
<td className="px-5 py-3.5 text-center">
<span className="text-[var(--admin-accent)]">{Icons.check("h-4 w-4")}</span>
</td>
<td className="px-5 py-3.5 text-center">
<span className="text-[var(--admin-text-muted)]">{entry.sms_sent ? Icons.check("h-4 w-4 text-[var(--admin-accent)]") : "—"}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function DashboardTab({
workers,
tasks,
notificationLog,
exportStart,
exportEnd,
buildExportUrl,
onChangeExportStart,
onChangeExportEnd,
}: {
workers: TimeWorker[];
tasks: TimeTask[];
notificationLog: NotificationLogEntry[];
exportStart: string;
exportEnd: string;
buildExportUrl: (type: "quickbooks" | "payroll" | "detailed" | "summary") => string;
onChangeExportStart: (v: string) => void;
onChangeExportEnd: (v: string) => void;
}) {
const alertsSent = notificationLog.filter(n => n.email_sent || n.sms_sent).length;
return (
<div className="space-y-6">
<StatRow workersCount={workers.length} tasksCount={tasks.length} alertsSent={alertsSent} />
<ExportSection
exportStart={exportStart}
exportEnd={exportEnd}
buildExportUrl={buildExportUrl}
onChangeStart={onChangeExportStart}
onChangeEnd={onChangeExportEnd}
/>
<NotificationLogSection log={notificationLog} />
</div>
);
}
function WorkersSection({
workers,
onAdd,
onEdit,
onResetPin,
onDelete,
}: {
workers: TimeWorker[];
onAdd: () => void;
onEdit: (w: TimeWorker) => void;
onResetPin: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Workers & PINs</h3>
<AdminButton onClick={onAdd} size="sm">
+ Add Worker
</AdminButton>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Name</th>
<th className="text-left px-5 py-3 font-medium">Role</th>
<th className="text-left px-5 py-3 font-medium">Lang</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Last Used</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{workers.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-12 text-center text-[var(--admin-text-muted)]">No workers yet</td></tr>
) : workers.map(w => (
<tr key={w.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-medium">{w.name}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] capitalize">{w.role}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] uppercase text-xs font-mono">{w.lang}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-green-100 text-green-700" : "bg-stone-100 text-stone-500"}`}>
{w.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<AdminButton variant="ghost" size="sm" onClick={() => onEdit(w)}>Edit</AdminButton>
<AdminButton variant="ghost" size="sm" onClick={() => onResetPin(w.id)}>Reset PIN</AdminButton>
<AdminButton variant="danger" size="sm" onClick={() => onDelete(w.id)}>Delete</AdminButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function TasksSection({
tasks,
onAdd,
onEdit,
onDelete,
}: {
tasks: TimeTask[];
onAdd: () => void;
onEdit: (t: TimeTask) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Tasks</h3>
<AdminButton onClick={onAdd} size="sm">
+ Add Task
</AdminButton>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Name</th>
<th className="text-left px-5 py-3 font-medium">Name (ES)</th>
<th className="text-left px-5 py-3 font-medium">Unit</th>
<th className="text-left px-5 py-3 font-medium">Sort</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{tasks.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-12 text-center text-[var(--admin-text-muted)]">No tasks yet</td></tr>
) : tasks.map(t => (
<tr key={t.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-medium">{t.name}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)]">{t.name_es ?? "—"}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs font-mono">{t.unit}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{t.sort_order}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-green-100 text-green-700" : "bg-stone-100 text-stone-500"}`}>
{t.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<AdminButton variant="ghost" size="sm" onClick={() => onEdit(t)}>Edit</AdminButton>
<AdminButton variant="danger" size="sm" onClick={() => onDelete(t.id)}>Delete</AdminButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function PayPeriodSection({
payPeriodStartDay,
payPeriodLength,
dailyOvertimeThreshold,
weeklyOvertimeThreshold,
onChangeStartDay,
onChangeLength,
onChangeDaily,
onChangeWeekly,
}: {
payPeriodStartDay: number;
payPeriodLength: number;
dailyOvertimeThreshold: number;
weeklyOvertimeThreshold: number;
onChangeStartDay: (v: number) => void;
onChangeLength: (v: number) => void;
onChangeDaily: (v: number) => void;
onChangeWeekly: (v: number) => void;
}) {
return (
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Pay Period & Overtime</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-1-work-week-starts-on" className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">Work week starts on</label>
<select id="fld-1-work-week-starts-on" aria-label="Select" value={payPeriodStartDay} onChange={e => onChangeStartDay(Number(e.target.value))}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2" htmlFor="fld-pay-period-length">Pay period length</label>
<div className="flex items-center gap-3">
<input id="fld-pay-period-length" aria-label="Number" type="number" min={1} max={31} value={payPeriodLength}
onChange={e => onChangeLength(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">days</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2" htmlFor="fld-daily-overtime-threshold">Daily overtime threshold</label>
<div className="flex items-center gap-3">
<input id="fld-daily-overtime-threshold" aria-label="Number" type="number" min={1} max={24} value={dailyOvertimeThreshold}
onChange={e => onChangeDaily(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">hrs</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2" htmlFor="fld-weekly-overtime-threshold">Weekly overtime threshold</label>
<div className="flex items-center gap-3">
<input id="fld-weekly-overtime-threshold" aria-label="Number" type="number" min={1} max={80} value={weeklyOvertimeThreshold}
onChange={e => onChangeWeekly(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">hrs</span>
</div>
</div>
</div>
</div>
);
}
function AlertSettingsSection({
enableDailyAlerts,
enableWeeklyAlerts,
overtimeNotifications,
onToggleDaily,
onToggleWeekly,
onToggleOvertime,
}: {
enableDailyAlerts: boolean;
enableWeeklyAlerts: boolean;
overtimeNotifications: boolean;
onToggleDaily: () => void;
onToggleWeekly: () => void;
onToggleOvertime: () => void;
}) {
return (
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Alert Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Daily overtime alerts</p>
<p className="text-xs text-[var(--admin-text-muted)]">Send notification when worker hits daily threshold</p>
</div>
<button type="button" aria-label="Toggle daily alerts" aria-pressed={enableDailyAlerts} onClick={onToggleDaily}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Weekly overtime alerts</p>
<p className="text-xs text-[var(--admin-text-muted)]">Send notification when worker hits weekly threshold</p>
</div>
<button type="button" aria-label="Toggle weekly alerts" aria-pressed={enableWeeklyAlerts} onClick={onToggleWeekly}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Show overtime warning on employee screen</p>
<p className="text-xs text-[var(--admin-text-muted)]">Display alert when worker approaches thresholds</p>
</div>
<button type="button" aria-label="Toggle overtime notifications" aria-pressed={overtimeNotifications} onClick={onToggleOvertime}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${overtimeNotifications ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${overtimeNotifications ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
</div>
</div>
);
}
function NotificationRecipientsSection({
emails,
smsNumbers,
newEmail,
newSms,
onChangeNewEmail,
onChangeNewSms,
onAddEmail,
onAddSms,
onRemoveEmail,
onRemoveSms,
}: {
emails: string[];
smsNumbers: string[];
newEmail: string;
newSms: string;
onChangeNewEmail: (v: string) => void;
onChangeNewSms: (v: string) => void;
onAddEmail: () => void;
onAddSms: () => void;
onRemoveEmail: (e: string) => void;
onRemoveSms: (n: string) => void;
}) {
return (
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Notification Recipients</h3>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2" htmlFor="fld-email-addresses">Email addresses</label>
<div className="flex gap-2">
<input id="fld-email-addresses" aria-label="Manager@farm.com" value={newEmail}
onChange={e => onChangeNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (onAddEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="manager@farm.com" />
<AdminButton onClick={onAddEmail}>Add</AdminButton>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{emails.map(e => (
<span key={e} className="inline-flex items-center gap-1 bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] text-xs px-2 py-1 rounded-lg">
{e}
<button type="button" onClick={() => onRemoveEmail(e)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1" aria-label="&times;">&times;</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2" htmlFor="fld-sms-numbers">SMS numbers</label>
<div className="flex gap-2">
<input id="fld-sms-numbers" aria-label="+1234567890" value={newSms}
onChange={e => onChangeNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (onAddSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="+1234567890" />
<AdminButton onClick={onAddSms}>Add</AdminButton>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{smsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1 bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] text-xs px-2 py-1 rounded-lg">
{n}
<button type="button" onClick={() => onRemoveSms(n)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1" aria-label="&times;">&times;</button>
</span>
))}
</div>
</div>
</div>
);
}
function SettingsTab({
workers,
tasks,
payPeriodStartDay,
payPeriodLength,
dailyOvertimeThreshold,
weeklyOvertimeThreshold,
overtimeNotifications,
enableDailyAlerts,
enableWeeklyAlerts,
notificationEmails,
notificationSmsNumbers,
newEmail,
newSms,
settingsError,
settingsSaved,
settingsSaving,
onAddWorker,
onEditWorker,
onResetPin,
onDeleteWorker,
onAddTask,
onEditTask,
onDeleteTask,
onChangePayPeriodStartDay,
onChangePayPeriodLength,
onChangeDailyOvertime,
onChangeWeeklyOvertime,
onToggleDaily,
onToggleWeekly,
onToggleOvertime,
onChangeNewEmail,
onChangeNewSms,
onAddEmail,
onAddSms,
onRemoveEmail,
onRemoveSms,
onSave,
}: {
workers: TimeWorker[];
tasks: TimeTask[];
payPeriodStartDay: number;
payPeriodLength: number;
dailyOvertimeThreshold: number;
weeklyOvertimeThreshold: number;
overtimeNotifications: boolean;
enableDailyAlerts: boolean;
enableWeeklyAlerts: boolean;
notificationEmails: string[];
notificationSmsNumbers: string[];
newEmail: string;
newSms: string;
settingsError: string | null;
settingsSaved: boolean;
settingsSaving: boolean;
onAddWorker: () => void;
onEditWorker: (w: TimeWorker) => void;
onResetPin: (id: string) => void;
onDeleteWorker: (id: string) => void;
onAddTask: () => void;
onEditTask: (t: TimeTask) => void;
onDeleteTask: (id: string) => void;
onChangePayPeriodStartDay: (v: number) => void;
onChangePayPeriodLength: (v: number) => void;
onChangeDailyOvertime: (v: number) => void;
onChangeWeeklyOvertime: (v: number) => void;
onToggleDaily: () => void;
onToggleWeekly: () => void;
onToggleOvertime: () => void;
onChangeNewEmail: (v: string) => void;
onChangeNewSms: (v: string) => void;
onAddEmail: () => void;
onAddSms: () => void;
onRemoveEmail: (e: string) => void;
onRemoveSms: (n: string) => void;
onSave: () => void;
}) {
return (
<div className="space-y-6">
<WorkersSection
workers={workers}
onAdd={onAddWorker}
onEdit={onEditWorker}
onResetPin={onResetPin}
onDelete={onDeleteWorker}
/>
<TasksSection
tasks={tasks}
onAdd={onAddTask}
onEdit={onEditTask}
onDelete={onDeleteTask}
/>
<PayPeriodSection
payPeriodStartDay={payPeriodStartDay}
payPeriodLength={payPeriodLength}
dailyOvertimeThreshold={dailyOvertimeThreshold}
weeklyOvertimeThreshold={weeklyOvertimeThreshold}
onChangeStartDay={onChangePayPeriodStartDay}
onChangeLength={onChangePayPeriodLength}
onChangeDaily={onChangeDailyOvertime}
onChangeWeekly={onChangeWeeklyOvertime}
/>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5">
<div className="flex items-start gap-3">
<span className="text-amber-600 mt-0.5">{Icons.alert("h-5 w-5")}</span>
<div>
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
</div>
</div>
</div>
<AlertSettingsSection
enableDailyAlerts={enableDailyAlerts}
enableWeeklyAlerts={enableWeeklyAlerts}
overtimeNotifications={overtimeNotifications}
onToggleDaily={onToggleDaily}
onToggleWeekly={onToggleWeekly}
onToggleOvertime={onToggleOvertime}
/>
<NotificationRecipientsSection
emails={notificationEmails}
smsNumbers={notificationSmsNumbers}
newEmail={newEmail}
newSms={newSms}
onChangeNewEmail={onChangeNewEmail}
onChangeNewSms={onChangeNewSms}
onAddEmail={onAddEmail}
onAddSms={onAddSms}
onRemoveEmail={onRemoveEmail}
onRemoveSms={onRemoveSms}
/>
{settingsError && (
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
)}
{settingsSaved && (
<div className="bg-green-50 border border-green-200 rounded-xl py-3 px-4 text-green-700 text-sm">Settings saved successfully.</div>
)}
<AdminButton onClick={onSave} isLoading={settingsSaving}>
Save Settings
</AdminButton>
</div>
);
}
function WorkerModal({
editingWorker,
workerName,
workerRole,
workerLang,
workerActive,
resetPinResult,
workerError,
submitting,
onClose,
onChangeName,
onChangeRole,
onChangeLang,
onToggleActive,
onResetPin,
onSave,
}: {
editingWorker: TimeWorker | null;
workerName: string;
workerRole: string;
workerLang: string;
workerActive: boolean;
resetPinResult: string | null;
workerError: string | null;
submitting: boolean;
onClose: () => void;
onChangeName: (v: string) => void;
onChangeRole: (v: string) => void;
onChangeLang: (v: string) => void;
onToggleActive: () => void;
onResetPin: () => void;
onSave: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-[var(--admin-border)] rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button type="button" onClick={onClose} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none" aria-label="&times;">&times;</button>
</div>
<div className="p-6 space-y-4">
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
{resetPinResult && (
<div className="bg-green-50 border border-green-200 rounded-xl py-3 px-4">
<p className="text-green-700 text-sm font-semibold mb-1">New PIN:</p>
<p className="text-2xl font-mono font-bold text-[var(--admin-text-primary)]">{resetPinResult}</p>
<p className="text-[var(--admin-text-muted)] text-xs mt-1">Show this to the worker it will not be shown again.</p>
</div>
)}
<div>
<label htmlFor="fld-2-name" className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name *</label>
<input id="fld-2-name" aria-label="Worker Name" value={workerName} onChange={e => onChangeName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="Worker name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-3-role" className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Role</label>
<select id="fld-3-role" aria-label="Select" value={workerRole} onChange={e => onChangeRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="worker">Worker</option>
<option value="time_admin">Time Admin</option>
</select>
</div>
<div>
<label htmlFor="fld-4-lang" className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Lang</label>
<select id="fld-4-lang" aria-label="Select" value={workerLang} onChange={e => onChangeLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<input aria-label="WorkerActive" type="checkbox" id="workerActive" checked={workerActive} onChange={onToggleActive}
className="w-4 h-4 rounded border-[var(--admin-border)] bg-white text-[var(--admin-accent)]" />
<label htmlFor="workerActive" className="text-sm text-[var(--admin-text-secondary)]">Active</label>
</div>
{editingWorker && (
<div className="pt-2 border-t border-[var(--admin-border)]">
<AdminButton variant="secondary" size="sm" onClick={onResetPin}>
Reset PIN for this worker
</AdminButton>
</div>
)}
<div className="flex gap-3 pt-2">
<AdminButton type="button" variant="secondary" onClick={onClose} fullWidth>
Cancel
</AdminButton>
<AdminButton type="button" onClick={onSave} isLoading={submitting} fullWidth>
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
</AdminButton>
</div>
</div>
</div>
</div>
);
}
function TaskModal({
editingTask,
taskName,
taskNameEs,
taskUnit,
taskActive,
taskSortOrder,
taskError,
submitting,
onClose,
onChangeName,
onChangeNameEs,
onChangeUnit,
onToggleActive,
onChangeSortOrder,
onSave,
}: {
editingTask: TimeTask | null;
taskName: string;
taskNameEs: string;
taskUnit: string;
taskActive: boolean;
taskSortOrder: number;
taskError: string | null;
submitting: boolean;
onClose: () => void;
onChangeName: (v: string) => void;
onChangeNameEs: (v: string) => void;
onChangeUnit: (v: string) => void;
onToggleActive: () => void;
onChangeSortOrder: (v: number) => void;
onSave: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-[var(--admin-border)] rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button type="button" onClick={onClose} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none" aria-label="&times;">&times;</button>
</div>
<div className="p-6 space-y-4">
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
<div>
<label htmlFor="fld-5-name-en" className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name (EN) *</label>
<input id="fld-5-name-en" aria-label=". Harvesting" value={taskName} onChange={e => onChangeName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label htmlFor="fld-6-name-es" className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name (ES)</label>
<input id="fld-6-name-es" aria-label=". Cosecha" value={taskNameEs} onChange={e => onChangeNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="e.g. Cosecha" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-7-unit" className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Unit</label>
<select id="fld-7-unit" aria-label="Select" value={taskUnit} onChange={e => onChangeUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
</select>
</div>
<div>
<label htmlFor="fld-8-sort-order" className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Sort Order</label>
<input id="fld-8-sort-order" aria-label="Number" type="number" value={taskSortOrder} onChange={e => onChangeSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input aria-label="TaskActive" type="checkbox" id="taskActive" checked={taskActive} onChange={onToggleActive}
className="w-4 h-4 rounded border-[var(--admin-border)] bg-white text-[var(--admin-accent)]" />
<label htmlFor="taskActive" className="text-sm text-[var(--admin-text-secondary)]">Active</label>
</div>
<div className="flex gap-3 pt-2">
<AdminButton type="button" variant="secondary" onClick={onClose} fullWidth>
Cancel
</AdminButton>
<AdminButton type="button" onClick={onSave} isLoading={submitting} fullWidth>
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
</AdminButton>
</div>
</div>
</div>
</div>
);
}
/* ── Main ──────────────────────────────────────────────────────── */
export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSettingsClientProps) {
const [state, dispatch] = useReducer(reducer, initialState);
const settingsRef = useRef<TimeTrackingSettings | null>(null);
const overtimeMultiplierRef = useRef(1.5);
const dailyAlertThresholdRef = useRef(80);
const weeklyAlertThresholdRef = useRef(80);
const sendEndOfPeriodSummaryRef = useRef(true);
const brandNameRef = useRef("Farm");
// Initialize tab from URL on mount
useEffect(() => {
if (typeof window === "undefined") return;
const t = new URLSearchParams(window.location.search).get("tab");
if (t === "settings" || t === "export") {
dispatch({ type: "SET_TAB", tab: t });
}
}, []);
const load = useCallback(async () => {
dispatch({ type: "SET_LOADING", value: true });
const [w, t, s] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSettings(brandId),
]);
// Sync form state from fresh settings in the same batch as the data
// setStates (avoids the stale-frame window a useEffect would introduce
// for the form-sync step).
settingsRef.current = s;
if (s) {
dispatch({ type: "SYNC_SETTINGS", settings: s });
overtimeMultiplierRef.current = s.overtime_multiplier;
dailyAlertThresholdRef.current = Math.round((s.daily_alert_threshold ?? 0.80) * 100);
weeklyAlertThresholdRef.current = Math.round((s.weekly_alert_threshold ?? 0.80) * 100);
sendEndOfPeriodSummaryRef.current = s.send_end_of_period_summary ?? true;
brandNameRef.current = s.brand_name ?? "Farm";
}
dispatch({ type: "SET_WORKERS", workers: w });
dispatch({ type: "SET_TASKS", tasks: t });
const log = await getTimeTrackingNotificationLog(brandId, 50);
dispatch({ type: "SET_NOTIFICATION_LOG", log });
dispatch({ type: "SET_LOADING", value: false });
}, [brandId]);
useEffect(() => {
void load();
}, [load]);
const handleSaveNotifications = async () => {
dispatch({ type: "SET_SETTINGS_SAVING", value: true });
dispatch({ type: "SET_SETTINGS_ERROR", value: null });
dispatch({ type: "SET_SETTINGS_SAVED", value: false });
const result = await updateTimeTrackingSettings(brandId, {
pay_period_start_day: state.payPeriodStartDay,
pay_period_length_days: state.payPeriodLength,
daily_overtime_threshold: state.dailyOvertimeThreshold,
weekly_overtime_threshold: state.weeklyOvertimeThreshold,
overtime_multiplier: overtimeMultiplierRef.current,
overtime_notifications: state.overtimeNotifications,
notification_emails: state.notificationEmails,
notification_sms_numbers: state.notificationSmsNumbers,
enable_daily_alerts: state.enableDailyAlerts,
enable_weekly_alerts: state.enableWeeklyAlerts,
daily_alert_threshold: dailyAlertThresholdRef.current / 100,
weekly_alert_threshold: weeklyAlertThresholdRef.current / 100,
send_end_of_period_summary: sendEndOfPeriodSummaryRef.current,
brand_name: brandNameRef.current,
});
dispatch({ type: "SET_SETTINGS_SAVING", value: false });
if (!result.success) { dispatch({ type: "SET_SETTINGS_ERROR", value: result.error ?? "Failed to save" }); return; }
dispatch({ type: "SET_SETTINGS_SAVED", value: true });
setTimeout(() => dispatch({ type: "SET_SETTINGS_SAVED", value: false }), 3000);
load();
};
const addEmail = () => {
const email = state.newEmail.trim();
if (email && !state.notificationEmails.includes(email)) {
dispatch({ type: "SET_NOTIFICATION_EMAILS", value: [...state.notificationEmails, email] });
}
dispatch({ type: "SET_NEW_EMAIL", value: "" });
};
const removeEmail = (email: string) => dispatch({ type: "SET_NOTIFICATION_EMAILS", value: state.notificationEmails.filter(e => e !== email) });
const addSms = () => {
const num = state.newSms.replace(/[^0-9+]/g, "");
if (num && !state.notificationSmsNumbers.includes(num)) {
dispatch({ type: "SET_NOTIFICATION_SMS_NUMBERS", value: [...state.notificationSmsNumbers, num] });
}
dispatch({ type: "SET_NEW_SMS", value: "" });
};
const removeSms = (num: string) => dispatch({ type: "SET_NOTIFICATION_SMS_NUMBERS", value: state.notificationSmsNumbers.filter(n => n !== num) });
function buildExportUrl(type: "quickbooks" | "payroll" | "detailed" | "summary") {
const params = new URLSearchParams({
type,
start: state.exportStart,
end: state.exportEnd,
brand: state.exportBrand,
overtime: String(state.includeOvertime),
notes: String(state.includeNotes),
});
return `/api/time-tracking/export?${params}`;
}
const openAddWorker = () => {
dispatch({ type: "RESET_WORKER_MODAL" });
dispatch({ type: "SET_SHOW_WORKER_MODAL", value: true });
};
const openEditWorker = (w: TimeWorker) => {
dispatch({ type: "SET_EDITING_WORKER", value: w });
dispatch({ type: "SET_WORKER_NAME", value: w.name });
dispatch({ type: "SET_WORKER_ROLE", value: w.role });
dispatch({ type: "SET_WORKER_LANG", value: w.lang });
dispatch({ type: "SET_WORKER_ACTIVE", value: w.active });
dispatch({ type: "SET_WORKER_ERROR", value: null });
dispatch({ type: "SET_RESET_PIN_RESULT", value: null });
dispatch({ type: "SET_SHOW_WORKER_MODAL", value: true });
};
const handleSaveWorker = async () => {
if (!state.workerName.trim()) { dispatch({ type: "SET_WORKER_ERROR", value: "Name is required" }); return; }
dispatch({ type: "SET_SUBMITTING", value: true });
dispatch({ type: "SET_WORKER_ERROR", value: null });
if (state.editingWorker) {
const result = await updateTimeWorker(state.editingWorker.id, state.workerName.trim(), state.workerRole, state.workerLang, state.workerActive);
if (!result.success) { dispatch({ type: "SET_WORKER_ERROR", value: result.error ?? "Failed" }); dispatch({ type: "SET_SUBMITTING", value: false }); return; }
} else {
const result = await createTimeWorker(brandId, state.workerName.trim(), state.workerRole, state.workerLang);
if (!result.success) { dispatch({ type: "SET_WORKER_ERROR", value: result.error ?? "Failed" }); dispatch({ type: "SET_SUBMITTING", value: false }); return; }
}
dispatch({ type: "SET_SUBMITTING", value: false });
dispatch({ type: "SET_SHOW_WORKER_MODAL", value: false });
load();
};
const handleResetPin = async (workerId: string) => {
const result = await resetTimeWorkerPin(workerId);
if (result.success && result.pin) dispatch({ type: "SET_RESET_PIN_RESULT", value: result.pin });
else dispatch({ type: "SET_WORKER_ERROR", value: result.error ?? "Failed to reset PIN" });
};
const handleDeleteWorker = async (workerId: string) => {
if (!confirm("Delete this worker? This cannot be undone.")) return;
const result = await deleteTimeWorker(workerId);
if (!result.success) { dispatch({ type: "SET_WORKER_ERROR", value: result.error ?? "Failed" }); return; }
load();
};
const openAddTask = () => {
dispatch({ type: "RESET_TASK_MODAL" });
dispatch({ type: "SET_SHOW_TASK_MODAL", value: true });
};
const openEditTask = (t: TimeTask) => {
dispatch({ type: "SET_EDITING_TASK", value: t });
dispatch({ type: "SET_TASK_NAME", value: t.name });
dispatch({ type: "SET_TASK_NAME_ES", value: t.name_es ?? "" });
dispatch({ type: "SET_TASK_UNIT", value: t.unit });
dispatch({ type: "SET_TASK_ACTIVE", value: t.active });
dispatch({ type: "SET_TASK_SORT_ORDER", value: t.sort_order });
dispatch({ type: "SET_TASK_ERROR", value: null });
dispatch({ type: "SET_SHOW_TASK_MODAL", value: true });
};
const handleSaveTask = async () => {
if (!state.taskName.trim()) { dispatch({ type: "SET_TASK_ERROR", value: "Name is required" }); return; }
dispatch({ type: "SET_SUBMITTING", value: true });
dispatch({ type: "SET_TASK_ERROR", value: null });
if (state.editingTask) {
const result = await updateTimeTask(state.editingTask.id, state.taskName.trim(), state.taskNameEs, state.taskUnit, state.taskActive, state.taskSortOrder);
if (!result.success) { dispatch({ type: "SET_TASK_ERROR", value: result.error ?? "Failed" }); dispatch({ type: "SET_SUBMITTING", value: false }); return; }
} else {
const result = await createTimeTask(brandId, state.taskName.trim(), state.taskNameEs || null, state.taskUnit, state.taskSortOrder);
if (!result.success) { dispatch({ type: "SET_TASK_ERROR", value: result.error ?? "Failed" }); dispatch({ type: "SET_SUBMITTING", value: false }); return; }
}
dispatch({ type: "SET_SUBMITTING", value: false });
dispatch({ type: "SET_SHOW_TASK_MODAL", value: false });
load();
};
const handleDeleteTask = async (taskId: string) => {
if (!confirm("Delete this task?")) return;
const result = await deleteTimeTask(taskId);
if (!result.success) { dispatch({ type: "SET_TASK_ERROR", value: result.error ?? "Failed" }); return; }
load();
};
if (state.loading) return <div className="text-center py-16 text-[var(--admin-text-muted)] text-sm">Loading...</div>;
return (
<div className="space-y-6">
{state.tab === "dashboard" && (
<DashboardTab
workers={state.workers}
tasks={state.tasks}
notificationLog={state.notificationLog}
exportStart={state.exportStart}
exportEnd={state.exportEnd}
buildExportUrl={buildExportUrl}
onChangeExportStart={(v) => dispatch({ type: "SET_EXPORT_START", value: v })}
onChangeExportEnd={(v) => dispatch({ type: "SET_EXPORT_END", value: v })}
/>
)}
{state.tab === "settings" && (
<SettingsTab
workers={state.workers}
tasks={state.tasks}
payPeriodStartDay={state.payPeriodStartDay}
payPeriodLength={state.payPeriodLength}
dailyOvertimeThreshold={state.dailyOvertimeThreshold}
weeklyOvertimeThreshold={state.weeklyOvertimeThreshold}
overtimeNotifications={state.overtimeNotifications}
enableDailyAlerts={state.enableDailyAlerts}
enableWeeklyAlerts={state.enableWeeklyAlerts}
notificationEmails={state.notificationEmails}
notificationSmsNumbers={state.notificationSmsNumbers}
newEmail={state.newEmail}
newSms={state.newSms}
settingsError={state.settingsError}
settingsSaved={state.settingsSaved}
settingsSaving={state.settingsSaving}
onAddWorker={openAddWorker}
onEditWorker={openEditWorker}
onResetPin={handleResetPin}
onDeleteWorker={handleDeleteWorker}
onAddTask={openAddTask}
onEditTask={openEditTask}
onDeleteTask={handleDeleteTask}
onChangePayPeriodStartDay={(v) => dispatch({ type: "SET_PAY_PERIOD_START_DAY", value: v })}
onChangePayPeriodLength={(v) => dispatch({ type: "SET_PAY_PERIOD_LENGTH", value: v })}
onChangeDailyOvertime={(v) => dispatch({ type: "SET_DAILY_OT_THRESHOLD", value: v })}
onChangeWeeklyOvertime={(v) => dispatch({ type: "SET_WEEKLY_OT_THRESHOLD", value: v })}
onToggleDaily={() => dispatch({ type: "SET_ENABLE_DAILY_ALERTS", value: !state.enableDailyAlerts })}
onToggleWeekly={() => dispatch({ type: "SET_ENABLE_WEEKLY_ALERTS", value: !state.enableWeeklyAlerts })}
onToggleOvertime={() => dispatch({ type: "SET_OT_NOTIFICATIONS", value: !state.overtimeNotifications })}
onChangeNewEmail={(v) => dispatch({ type: "SET_NEW_EMAIL", value: v })}
onChangeNewSms={(v) => dispatch({ type: "SET_NEW_SMS", value: v })}
onAddEmail={addEmail}
onAddSms={addSms}
onRemoveEmail={removeEmail}
onRemoveSms={removeSms}
onSave={handleSaveNotifications}
/>
)}
{state.showWorkerModal && (
<WorkerModal
editingWorker={state.editingWorker}
workerName={state.workerName}
workerRole={state.workerRole}
workerLang={state.workerLang}
workerActive={state.workerActive}
resetPinResult={state.resetPinResult}
workerError={state.workerError}
submitting={state.submitting}
onClose={() => dispatch({ type: "SET_SHOW_WORKER_MODAL", value: false })}
onChangeName={(v) => dispatch({ type: "SET_WORKER_NAME", value: v })}
onChangeRole={(v) => dispatch({ type: "SET_WORKER_ROLE", value: v })}
onChangeLang={(v) => dispatch({ type: "SET_WORKER_LANG", value: v })}
onToggleActive={() => dispatch({ type: "SET_WORKER_ACTIVE", value: !state.workerActive })}
onResetPin={() => state.editingWorker && handleResetPin(state.editingWorker.id)}
onSave={handleSaveWorker}
/>
)}
{state.showTaskModal && (
<TaskModal
editingTask={state.editingTask}
taskName={state.taskName}
taskNameEs={state.taskNameEs}
taskUnit={state.taskUnit}
taskActive={state.taskActive}
taskSortOrder={state.taskSortOrder}
taskError={state.taskError}
submitting={state.submitting}
onClose={() => dispatch({ type: "SET_SHOW_TASK_MODAL", value: false })}
onChangeName={(v) => dispatch({ type: "SET_TASK_NAME", value: v })}
onChangeNameEs={(v) => dispatch({ type: "SET_TASK_NAME_ES", value: v })}
onChangeUnit={(v) => dispatch({ type: "SET_TASK_UNIT", value: v })}
onToggleActive={() => dispatch({ type: "SET_TASK_ACTIVE", value: !state.taskActive })}
onChangeSortOrder={(v) => dispatch({ type: "SET_TASK_SORT_ORDER", value: v })}
onSave={handleSaveTask}
/>
)}
</div>
);
}