Files
route-commerce/src/components/admin/SettingsSections.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

1272 lines
56 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";
import { useState, useReducer, useEffect, useCallback, useRef } from "react";
import {
getTimeTrackingWorkers,
getTimeTrackingTasks,
createTimeWorker,
resetTimeWorkerPin,
updateTimeWorker,
deleteTimeWorker,
createTimeTask,
updateTimeTask,
deleteTimeTask,
getTimeTrackingSettings,
updateTimeTrackingSettings,
type TimeWorker,
type TimeTask,
type TimeTrackingSettings,
} from "@/actions/time-tracking";
import AdminToggle from "./design-system/AdminToggle";
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system/AdminFormElements";
import AdminButton from "./design-system/AdminButton";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
// ──────────────────────────────────────────────────────────────────────────
// State (useReducer)
// ──────────────────────────────────────────────────────────────────────────
type State = {
workers: TimeWorker[];
tasks: TimeTask[];
loadedSettings: TimeTrackingSettings | null;
loading: boolean;
// Settings form
payPeriodStartDay: number;
payPeriodLength: number;
dailyOvertimeThreshold: number;
weeklyOvertimeThreshold: number;
notificationEmails: string[];
notificationSmsNumbers: string[];
enableDailyAlerts: boolean;
enableWeeklyAlerts: boolean;
newEmail: string;
newSms: string;
settingsSaving: boolean;
settingsSaved: boolean;
settingsError: string | null;
// Worker modal
showWorkerModal: boolean;
editingWorker: TimeWorker | null;
workerName: string;
workerRole: string;
workerLang: string;
workerActive: boolean;
resetPinResult: string | null;
workerError: string | null;
submitting: boolean;
// Task modal
showTaskModal: boolean;
editingTask: TimeTask | null;
taskName: string;
taskNameEs: string;
taskUnit: string;
taskActive: boolean;
taskSortOrder: number;
taskError: string | null;
};
type Action =
| { type: "SET_WORKERS"; value: TimeWorker[] }
| { type: "SET_TASKS"; value: TimeTask[] }
| { type: "SET_LOADED_SETTINGS"; value: TimeTrackingSettings | null }
| { type: "SET_LOADING"; value: boolean }
// Settings form
| { type: "SET_PAY_PERIOD_START_DAY"; value: number }
| { type: "SET_PAY_PERIOD_LENGTH"; value: number }
| { type: "SET_DAILY_OVERTIME_THRESHOLD"; value: number }
| { type: "SET_WEEKLY_OVERTIME_THRESHOLD"; value: number }
| { type: "SET_NOTIFICATION_EMAILS"; value: string[] }
| { type: "ADD_EMAIL"; value: string }
| { type: "REMOVE_EMAIL"; value: string }
| { type: "SET_NOTIFICATION_SMS"; value: string[] }
| { type: "ADD_SMS"; value: string }
| { type: "REMOVE_SMS"; 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_SETTINGS_SAVING"; value: boolean }
| { type: "SET_SETTINGS_SAVED"; value: boolean }
| { type: "SET_SETTINGS_ERROR"; value: string | null }
// Worker modal
| { type: "OPEN_ADD_WORKER" }
| { type: "OPEN_EDIT_WORKER"; worker: TimeWorker }
| { type: "CLOSE_WORKER_MODAL" }
| { 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 }
// Task modal
| { type: "OPEN_ADD_TASK" }
| { type: "OPEN_EDIT_TASK"; task: TimeTask }
| { type: "CLOSE_TASK_MODAL" }
| { 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 };
const initialState: State = {
workers: [],
tasks: [],
loadedSettings: null,
loading: true,
payPeriodStartDay: 0,
payPeriodLength: 7,
dailyOvertimeThreshold: 12,
weeklyOvertimeThreshold: 56,
notificationEmails: [],
notificationSmsNumbers: [],
enableDailyAlerts: true,
enableWeeklyAlerts: true,
newEmail: "",
newSms: "",
settingsSaving: false,
settingsSaved: false,
settingsError: null,
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,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_WORKERS":
return { ...state, workers: action.value };
case "SET_TASKS":
return { ...state, tasks: action.value };
case "SET_LOADED_SETTINGS":
// Hydrate form state from the freshly-loaded settings object
if (!action.value) {
return { ...state, loadedSettings: null };
}
return {
...state,
loadedSettings: action.value,
payPeriodStartDay: action.value.pay_period_start_day,
payPeriodLength: action.value.pay_period_length_days,
dailyOvertimeThreshold: action.value.daily_overtime_threshold,
weeklyOvertimeThreshold: action.value.weekly_overtime_threshold,
notificationEmails: action.value.notification_emails ?? [],
notificationSmsNumbers: action.value.notification_sms_numbers ?? [],
enableDailyAlerts: action.value.enable_daily_alerts ?? true,
enableWeeklyAlerts: action.value.enable_weekly_alerts ?? true,
};
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_PAY_PERIOD_START_DAY":
return { ...state, payPeriodStartDay: action.value };
case "SET_PAY_PERIOD_LENGTH":
return { ...state, payPeriodLength: action.value };
case "SET_DAILY_OVERTIME_THRESHOLD":
return { ...state, dailyOvertimeThreshold: action.value };
case "SET_WEEKLY_OVERTIME_THRESHOLD":
return { ...state, weeklyOvertimeThreshold: action.value };
case "SET_NOTIFICATION_EMAILS":
return { ...state, notificationEmails: action.value };
case "ADD_EMAIL":
if (!action.value || state.notificationEmails.includes(action.value)) {
return { ...state, newEmail: "" };
}
return {
...state,
notificationEmails: [...state.notificationEmails, action.value],
newEmail: "",
};
case "REMOVE_EMAIL":
return {
...state,
notificationEmails: state.notificationEmails.filter((e) => e !== action.value),
};
case "SET_NOTIFICATION_SMS":
return { ...state, notificationSmsNumbers: action.value };
case "ADD_SMS":
if (!action.value || state.notificationSmsNumbers.includes(action.value)) {
return { ...state, newSms: "" };
}
return {
...state,
notificationSmsNumbers: [...state.notificationSmsNumbers, action.value],
newSms: "",
};
case "REMOVE_SMS":
return {
...state,
notificationSmsNumbers: state.notificationSmsNumbers.filter((n) => n !== 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_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 "OPEN_ADD_WORKER":
return {
...state,
editingWorker: null,
workerName: "",
workerRole: "worker",
workerLang: "en",
workerActive: true,
workerError: null,
resetPinResult: null,
showWorkerModal: true,
};
case "OPEN_EDIT_WORKER":
return {
...state,
editingWorker: action.worker,
workerName: action.worker.name,
workerRole: action.worker.role,
workerLang: action.worker.lang,
workerActive: action.worker.active,
workerError: null,
resetPinResult: null,
showWorkerModal: true,
};
case "CLOSE_WORKER_MODAL":
return { ...state, showWorkerModal: false };
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 "OPEN_ADD_TASK":
return {
...state,
editingTask: null,
taskName: "",
taskNameEs: "",
taskUnit: "hours",
taskActive: true,
taskSortOrder: 0,
taskError: null,
showTaskModal: true,
};
case "OPEN_EDIT_TASK":
return {
...state,
editingTask: action.task,
taskName: action.task.name,
taskNameEs: action.task.name_es ?? "",
taskUnit: action.task.unit,
taskActive: action.task.active,
taskSortOrder: action.task.sort_order,
taskError: null,
showTaskModal: true,
};
case "CLOSE_TASK_MODAL":
return { ...state, showTaskModal: false };
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 };
}
}
// ── Accordion wrapper (kept untouched) ─────────────────────────────────────
type AccordionProps = {
title: string;
id?: string;
description?: string;
defaultOpen?: boolean;
accentColor?: "emerald" | "violet" | "amber" | "stone";
children: React.ReactNode;
};
function Accordion({ title, id, description, defaultOpen = false, accentColor = "stone", children }: AccordionProps) {
const [open, setOpen] = useState(defaultOpen);
const accent = {
emerald: "bg-emerald-50 text-emerald-700 border-emerald-200",
violet: "bg-violet-50 text-violet-700 border-violet-200",
amber: "bg-amber-50 text-amber-700 border-amber-200",
stone: "bg-stone-50 text-stone-700 border-stone-200",
}[accentColor];
const borderColor = open ? "border-stone-300" : "border-stone-200";
return (
<div id={id} className={`rounded-xl border ${borderColor} bg-white shadow-sm overflow-hidden transition-all duration-200`}>
<button type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-stone-50 transition-colors"
aria-expanded={open}
aria-label=")}">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-stone-900">{title}</span>
{description && (
<span className="text-xs text-stone-500 hidden sm:block">{description}</span>
)}
</div>
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-1 rounded-md ${accent}`}>
{open ? "Open" : "Closed"}
</span>
<svg
className={`w-4 h-4 text-stone-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
</button>
<div className={`transition-all duration-200 ${open ? "block" : "hidden"}`}>
<div className="px-5 pb-5 pt-2">{children}</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────
// Main component
// ──────────────────────────────────────────────────────────────────────────
type Props = {
brandId: string;
workersOnly?: boolean;
tasksOnly?: boolean;
};
export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Props) {
const [state, dispatch] = useReducer(reducer, initialState);
// Refs for values that don't need to trigger re-renders
const overtimeMultiplierRef = useRef(1.5);
const overtimeNotificationsRef = useRef(true);
const dailyAlertThresholdRef = useRef(80);
const weeklyAlertThresholdRef = useRef(80);
const sendEndOfPeriodSummaryRef = useRef(true);
const brandNameRef = useRef("Farm");
// Sync the non-rendered ref values when fresh settings arrive. The
// form-state portion of the reducer is hydrated atomically by the
// SET_LOADED_SETTINGS action above, so there's no stale-frame window
// for fields the user sees — these refs only hold values used at
// save time and don't need to drive a re-render.
useEffect(() => {
if (state.loadedSettings) {
overtimeMultiplierRef.current = state.loadedSettings.overtime_multiplier;
overtimeNotificationsRef.current = state.loadedSettings.overtime_notifications;
dailyAlertThresholdRef.current = Math.round((state.loadedSettings.daily_alert_threshold ?? 0.80) * 100);
weeklyAlertThresholdRef.current = Math.round((state.loadedSettings.weekly_alert_threshold ?? 0.80) * 100);
sendEndOfPeriodSummaryRef.current = state.loadedSettings.send_end_of_period_summary ?? true;
brandNameRef.current = state.loadedSettings.brand_name ?? "Farm";
}
}, [state.loadedSettings]);
const load = useCallback(async () => {
dispatch({ type: "SET_LOADING", value: true });
const [w, t, s] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSettings(brandId),
]);
dispatch({ type: "SET_WORKERS", value: w });
dispatch({ type: "SET_TASKS", value: t });
dispatch({ type: "SET_LOADED_SETTINGS", value: s });
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: overtimeNotificationsRef.current,
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 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: "CLOSE_WORKER_MODAL" });
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 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: "CLOSE_TASK_MODAL" });
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 <LoadingState />;
}
return (
<div className="space-y-4">
{!workersOnly && !tasksOnly && (
<GeneralSettingsAccordion
payPeriodStartDay={state.payPeriodStartDay}
payPeriodLength={state.payPeriodLength}
dailyOvertimeThreshold={state.dailyOvertimeThreshold}
weeklyOvertimeThreshold={state.weeklyOvertimeThreshold}
enableDailyAlerts={state.enableDailyAlerts}
enableWeeklyAlerts={state.enableWeeklyAlerts}
notificationEmails={state.notificationEmails}
notificationSmsNumbers={state.notificationSmsNumbers}
newEmail={state.newEmail}
newSms={state.newSms}
settingsSaving={state.settingsSaving}
settingsSaved={state.settingsSaved}
settingsError={state.settingsError}
onSetPayPeriodStartDay={(v) => dispatch({ type: "SET_PAY_PERIOD_START_DAY", value: v })}
onSetPayPeriodLength={(v) => dispatch({ type: "SET_PAY_PERIOD_LENGTH", value: v })}
onSetDailyOvertimeThreshold={(v) => dispatch({ type: "SET_DAILY_OVERTIME_THRESHOLD", value: v })}
onSetWeeklyOvertimeThreshold={(v) => dispatch({ type: "SET_WEEKLY_OVERTIME_THRESHOLD", value: v })}
onSetEnableDailyAlerts={(v) => dispatch({ type: "SET_ENABLE_DAILY_ALERTS", value: v })}
onSetEnableWeeklyAlerts={(v) => dispatch({ type: "SET_ENABLE_WEEKLY_ALERTS", value: v })}
onSetNewEmail={(v) => dispatch({ type: "SET_NEW_EMAIL", value: v })}
onSetNewSms={(v) => dispatch({ type: "SET_NEW_SMS", value: v })}
onAddEmail={() => dispatch({ type: "ADD_EMAIL", value: state.newEmail.trim() })}
onRemoveEmail={(e) => dispatch({ type: "REMOVE_EMAIL", value: e })}
onAddSms={() =>
dispatch({ type: "ADD_SMS", value: state.newSms.replace(/[^0-9+]/g, "") })
}
onRemoveSms={(n) => dispatch({ type: "REMOVE_SMS", value: n })}
onSave={handleSaveNotifications}
/>
)}
{(workersOnly || (!workersOnly && !tasksOnly)) && (
<WorkersAccordion
workers={state.workers}
defaultOpen={workersOnly}
onAdd={() => dispatch({ type: "OPEN_ADD_WORKER" })}
onEdit={(w) => dispatch({ type: "OPEN_EDIT_WORKER", worker: w })}
onResetPin={handleResetPin}
onDelete={handleDeleteWorker}
/>
)}
{(tasksOnly || (!workersOnly && !tasksOnly)) && (
<TasksAccordion
tasks={state.tasks}
defaultOpen={tasksOnly}
onAdd={() => dispatch({ type: "OPEN_ADD_TASK" })}
onEdit={(t) => dispatch({ type: "OPEN_EDIT_TASK", task: t })}
onDelete={handleDeleteTask}
/>
)}
{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}
onSetWorkerName={(v) => dispatch({ type: "SET_WORKER_NAME", value: v })}
onSetWorkerRole={(v) => dispatch({ type: "SET_WORKER_ROLE", value: v })}
onSetWorkerLang={(v) => dispatch({ type: "SET_WORKER_LANG", value: v })}
onSetWorkerActive={(v) => dispatch({ type: "SET_WORKER_ACTIVE", value: v })}
onResetPin={() => state.editingWorker && handleResetPin(state.editingWorker.id)}
onClose={() => dispatch({ type: "CLOSE_WORKER_MODAL" })}
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}
onSetTaskName={(v) => dispatch({ type: "SET_TASK_NAME", value: v })}
onSetTaskNameEs={(v) => dispatch({ type: "SET_TASK_NAME_ES", value: v })}
onSetTaskUnit={(v) => dispatch({ type: "SET_TASK_UNIT", value: v })}
onSetTaskActive={(v) => dispatch({ type: "SET_TASK_ACTIVE", value: v })}
onSetTaskSortOrder={(v) => dispatch({ type: "SET_TASK_SORT_ORDER", value: v })}
onClose={() => dispatch({ type: "CLOSE_TASK_MODAL" })}
onSave={handleSaveTask}
/>
)}
</div>
);
}
// ── LoadingState subcomponent ──────────────────────────────────────────────
function LoadingState() {
return (
<div className="flex items-center justify-center py-20">
<div className="flex items-center gap-3 text-stone-500 text-sm">
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading settings...
</div>
</div>
);
}
// ── GeneralSettingsAccordion subcomponent ──────────────────────────────────
function GeneralSettingsAccordion({
payPeriodStartDay,
payPeriodLength,
dailyOvertimeThreshold,
weeklyOvertimeThreshold,
enableDailyAlerts,
enableWeeklyAlerts,
notificationEmails,
notificationSmsNumbers,
newEmail,
newSms,
settingsSaving,
settingsSaved,
settingsError,
onSetPayPeriodStartDay,
onSetPayPeriodLength,
onSetDailyOvertimeThreshold,
onSetWeeklyOvertimeThreshold,
onSetEnableDailyAlerts,
onSetEnableWeeklyAlerts,
onSetNewEmail,
onSetNewSms,
onAddEmail,
onRemoveEmail,
onAddSms,
onRemoveSms,
onSave,
}: {
payPeriodStartDay: number;
payPeriodLength: number;
dailyOvertimeThreshold: number;
weeklyOvertimeThreshold: number;
enableDailyAlerts: boolean;
enableWeeklyAlerts: boolean;
notificationEmails: string[];
notificationSmsNumbers: string[];
newEmail: string;
newSms: string;
settingsSaving: boolean;
settingsSaved: boolean;
settingsError: string | null;
onSetPayPeriodStartDay: (v: number) => void;
onSetPayPeriodLength: (v: number) => void;
onSetDailyOvertimeThreshold: (v: number) => void;
onSetWeeklyOvertimeThreshold: (v: number) => void;
onSetEnableDailyAlerts: (v: boolean) => void;
onSetEnableWeeklyAlerts: (v: boolean) => void;
onSetNewEmail: (v: string) => void;
onSetNewSms: (v: string) => void;
onAddEmail: () => void;
onRemoveEmail: (e: string) => void;
onAddSms: () => void;
onRemoveSms: (n: string) => void;
onSave: () => void;
}) {
return (
<Accordion
id="general"
title="General Settings"
description="Pay period, overtime, alerts"
defaultOpen={true}
accentColor="stone"
>
<div className="space-y-5">
{/* Pay Period & Overtime */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Pay Period & Overtime</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="fld-1-work-week-starts-on" className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
<select id="fld-1-work-week-starts-on" aria-label="Select" value={payPeriodStartDay} onChange={e => onSetPayPeriodStartDay(Number(e.target.value))}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 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-stone-500 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 => onSetPayPeriodLength(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">days</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 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 => onSetDailyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 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 => onSetWeeklyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
</div>
</div>
{/* Colorado notice */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<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>
{/* Alert Settings */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Alert Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-[var(--admin-border)]">
<div>
<p className="text-sm text-[var(--admin-text-primary)] font-medium">Daily overtime alerts</p>
<p className="text-xs text-[var(--admin-text-muted)]">Notify when worker hits daily limit</p>
</div>
<AdminToggle
checked={enableDailyAlerts}
onChange={onSetEnableDailyAlerts}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-[var(--admin-border)]">
<div>
<p className="text-sm text-[var(--admin-text-primary)] font-medium">Weekly overtime alerts</p>
<p className="text-xs text-[var(--admin-text-muted)]">Notify when worker hits weekly limit</p>
</div>
<AdminToggle
checked={enableWeeklyAlerts}
onChange={onSetEnableWeeklyAlerts}
/>
</div>
</div>
</div>
{/* Notification Recipients */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Notification Recipients</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 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 => onSetNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (onAddEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
placeholder="manager@farm.com" />
<button type="button" onClick={onAddEmail} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm" aria-label="Add">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationEmails.map(e => (
<span key={e} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
{e}
<button type="button" onClick={() => onRemoveEmail(e)} className="text-stone-400 hover:text-red-500 ml-1" aria-label="Close">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 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 => onSetNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (onAddSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
placeholder="+1234567890" />
<button type="button" onClick={onAddSms} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm" aria-label="Add">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationSmsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
{n}
<button type="button" onClick={() => onRemoveSms(n)} className="text-stone-400 hover:text-red-500 ml-1" aria-label="Close">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
</div>
</div>
</div>
{settingsError && (
<div className="rounded-xl border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)] flex items-center gap-3">
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
{settingsError}
</div>
)}
{settingsSaved && (
<div className="rounded-xl border border-[var(--admin-success)]/30 bg-[var(--admin-success)]/10 px-4 py-3 text-sm text-[var(--admin-success)] flex items-center gap-3">
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Settings saved successfully!
</div>
)}
<div className="flex items-center gap-3">
<AdminButton
variant="primary"
size="md"
onClick={onSave}
disabled={settingsSaving}
>
{settingsSaving ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
Saving...
</span>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Save Settings
</>
)}
</AdminButton>
</div>
</div>
</Accordion>
);
}
// ── WorkersAccordion subcomponent ──────────────────────────────────────────
function WorkersAccordion({
workers,
defaultOpen,
onAdd,
onEdit,
onResetPin,
onDelete,
}: {
workers: TimeWorker[];
defaultOpen?: boolean;
onAdd: () => void;
onEdit: (w: TimeWorker) => void;
onResetPin: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<Accordion
id="workers"
title="Workers & PINs"
description={`${workers.length} worker${workers.length !== 1 ? "s" : ""}`}
defaultOpen={defaultOpen}
accentColor="emerald"
>
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-stone-500">Manage time tracking workers and PIN codes.</p>
<button type="button" onClick={onAdd}
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all" aria-label="+ Add Worker">+ Add Worker</button>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Role</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Lang</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Last Used</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{workers.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No workers yet add one to get started.</td></tr>
) : workers.map(w => (
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-900 font-medium">{w.name}</td>
<td className="px-4 py-3.5 text-stone-500 capitalize text-xs">{w.role}</td>
<td className="px-4 py-3.5 text-stone-400 uppercase text-xs font-mono hidden sm:table-cell">{w.lang}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{w.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<button type="button" onClick={() => onEdit(w)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all" aria-label="Edit">Edit</button>
<button type="button" onClick={() => onResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all" aria-label="Reset PIN">Reset PIN</button>
<button type="button" onClick={() => onDelete(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all" aria-label="Delete">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Accordion>
);
}
// ── TasksAccordion subcomponent ────────────────────────────────────────────
function TasksAccordion({
tasks,
defaultOpen,
onAdd,
onEdit,
onDelete,
}: {
tasks: TimeTask[];
defaultOpen?: boolean;
onAdd: () => void;
onEdit: (t: TimeTask) => void;
onDelete: (id: string) => void;
}) {
return (
<Accordion
id="tasks"
title="Tasks"
description={`${tasks.length} task${tasks.length !== 1 ? "s" : ""}`}
defaultOpen={defaultOpen}
accentColor="amber"
>
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-stone-500">Define tasks workers can clock into.</p>
<button type="button" onClick={onAdd}
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all" aria-label="+ Add Task">+ Add Task</button>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-4 py-3 font-medium">Name (EN)</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Name (ES)</th>
<th className="text-left px-4 py-3 font-medium">Unit</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Sort</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{tasks.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No tasks yet add one to get started.</td></tr>
) : tasks.map(t => (
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-900 font-medium">{t.name}</td>
<td className="px-4 py-3.5 text-stone-500 text-xs hidden sm:table-cell">{t.name_es ?? "—"}</td>
<td className="px-4 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{t.sort_order}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{t.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<button type="button" onClick={() => onEdit(t)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all" aria-label="Edit">Edit</button>
<button type="button" onClick={() => onDelete(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all" aria-label="Delete">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Accordion>
);
}
// ── WorkerModal subcomponent ───────────────────────────────────────────────
function WorkerModal({
editingWorker,
workerName,
workerRole,
workerLang,
workerActive,
resetPinResult,
workerError,
submitting,
onSetWorkerName,
onSetWorkerRole,
onSetWorkerLang,
onSetWorkerActive,
onResetPin,
onClose,
onSave,
}: {
editingWorker: TimeWorker | null;
workerName: string;
workerRole: string;
workerLang: string;
workerActive: boolean;
resetPinResult: string | null;
workerError: string | null;
submitting: boolean;
onSetWorkerName: (v: string) => void;
onSetWorkerRole: (v: string) => void;
onSetWorkerLang: (v: string) => void;
onSetWorkerActive: (v: boolean) => void;
onResetPin: () => void;
onClose: () => 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-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button type="button" onClick={onClose} className="text-stone-400 hover:text-stone-600" aria-label="Close">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</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-emerald-50 border border-emerald-200 rounded-xl py-3 px-4">
<p className="text-emerald-700 text-sm font-semibold mb-1">New PIN:</p>
<p className="text-2xl font-mono font-bold text-stone-900">{resetPinResult}</p>
<p className="text-stone-500 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-stone-500 mb-1.5">Name *</label>
<input id="fld-2-name" aria-label="Worker Name" value={workerName} onChange={e => onSetWorkerName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 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-stone-500 mb-1.5">Role</label>
<select id="fld-3-role" aria-label="Select" value={workerRole} onChange={e => onSetWorkerRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 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-stone-500 mb-1.5">Lang</label>
<select id="fld-4-lang" aria-label="Select" value={workerLang} onChange={e => onSetWorkerLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 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={e => onSetWorkerActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
</div>
{editingWorker && (
<div className="pt-2 border-t border-stone-100">
<button type="button" onClick={onResetPin} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2" aria-label="Reset PIN for this worker">
Reset PIN for this worker
</button>
</div>
)}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all" aria-label="Cancel">
Cancel
</button>
<button type="button" onClick={onSave} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
</button>
</div>
</div>
</div>
</div>
);
}
// ── TaskModal subcomponent ─────────────────────────────────────────────────
function TaskModal({
editingTask,
taskName,
taskNameEs,
taskUnit,
taskActive,
taskSortOrder,
taskError,
submitting,
onSetTaskName,
onSetTaskNameEs,
onSetTaskUnit,
onSetTaskActive,
onSetTaskSortOrder,
onClose,
onSave,
}: {
editingTask: TimeTask | null;
taskName: string;
taskNameEs: string;
taskUnit: string;
taskActive: boolean;
taskSortOrder: number;
taskError: string | null;
submitting: boolean;
onSetTaskName: (v: string) => void;
onSetTaskNameEs: (v: string) => void;
onSetTaskUnit: (v: string) => void;
onSetTaskActive: (v: boolean) => void;
onSetTaskSortOrder: (v: number) => void;
onClose: () => 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-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button type="button" onClick={onClose} className="text-stone-400 hover:text-stone-600" aria-label="Close">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</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-stone-500 mb-1.5">Name (EN) *</label>
<input id="fld-5-name-en" aria-label=". Harvesting" value={taskName} onChange={e => onSetTaskName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label htmlFor="fld-6-name-es" className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
<input id="fld-6-name-es" aria-label=". Cosecha" value={taskNameEs} onChange={e => onSetTaskNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 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-stone-500 mb-1.5">Unit</label>
<select id="fld-7-unit" aria-label="Select" value={taskUnit} onChange={e => onSetTaskUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 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-stone-500 mb-1.5">Sort Order</label>
<input id="fld-8-sort-order" aria-label="Number" type="number" value={taskSortOrder} onChange={e => onSetTaskSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input aria-label="TaskActive" type="checkbox" id="taskActive" checked={taskActive} onChange={e => onSetTaskActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all" aria-label="Cancel">
Cancel
</button>
<button type="button" onClick={onSave} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
</button>
</div>
</div>
</div>
</div>
);
}