fe78645609
- 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
1272 lines
56 KiB
TypeScript
1272 lines
56 KiB
TypeScript
"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>
|
||
);
|
||
}
|