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