Files
route-commerce/src/components/admin/TimeTrackingAdminPanel.tsx
T
tyler 1fe5ffee8d Refactor: move public storefront stop data to server-side + parallel agent work
Server-side / caching refactor (Grok):
- New RPC get_public_stops_for_brand (migration 148) for public storefront stops
- New server action getPublicStopsForBrand with revalidate=300 + tags
- Add revalidateTag invalidation to createStopsBatch + publishStop
- Convert /tuxedo/stops and /indian-river-direct/stops to Server Components
- Extract TuxedoStopsList + IndianRiverStopsList as client islands (GSAP only)
- Removes supabase-js from browser bundle on those routes
- Both pages now statically prerendered (5m ISR)

Parallel agent changes also staged:
- AI provider model list refresh (claude-sonnet-4-5, etc.)
- ESLint directive patches for react-hooks/set-state-in-effect
- Admin + storefront + checkout + cart updates
- New admin_create_stop_rpcs migration (147)
- Misc fixes across ~90 files

Build verified: typecheck clean, lint clean on new files, production build succeeds.
2026-06-03 02:04:21 +00:00

918 lines
43 KiB
TypeScript

"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect, useCallback } from "react";
import GlassModal from "@/components/admin/GlassModal";
import {
getTimeTrackingWorkers,
getTimeTrackingTasks,
getWorkerTimeLogs,
getTimeTrackingSummary,
createTimeWorker,
updateTimeWorker,
deleteTimeWorker,
resetTimeWorkerPin,
createTimeTask,
updateTimeTask,
deleteTimeTask,
type TimeWorker,
type TimeTask,
type TimeLog,
type TimeSummary,
} from "@/actions/time-tracking";
import { formatDate } from "@/lib/format-date";
import TimeTrackingSettingsClient from "./TimeTrackingSettingsClient";
import { AdminButton, AdminFilterTabs } from "./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>
),
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>
),
user: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</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>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 4v16m8-8H4" />
</svg>
),
refresh: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
),
download: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" x2="12" y1="15" y2="3"/>
</svg>
),
edit: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
),
trash: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
),
fileText: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" x2="8" y1="13" y2="13"/>
<line x1="16" x2="8" y1="17" y2="17"/>
<line x1="10" x2="8" y1="9" y2="9"/>
</svg>
),
timer: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" x2="14" y1="2" y2="2"/>
<line x1="12" x2="15" y1="14" y2="11"/>
<circle cx="12" cy="14" r="8"/>
</svg>
),
settings: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
};
const PAGE_SIZE = 50;
// ── Status Badge ─────────────────────────────────────────────────────────────
function StatusBadge({ active }: { active: boolean }) {
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold ${
active ? "bg-green-100 text-green-700" : "bg-stone-100 text-stone-500"
}`}>
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-green-500" : "bg-stone-400"}`} />
{active ? "Active" : "Inactive"}
</span>
);
}
// ── Formatters ────────────────────────────────────────────────────────────────
function formatHours(h: number): string {
return h < 1 ? `${(h * 60).toFixed(0)}m` : `${h.toFixed(1)}h`;
}
function formatMinutes(minutes: number): string {
const h = minutes / 60;
return h < 1 ? `${minutes}m` : `${h.toFixed(1)}h`;
}
// ── Main Component ───────────────────────────────────────────────────────────
type Tab = "summary" | "workers" | "tasks" | "logs" | "settings";
const TABS = [
{ value: "summary", label: "Summary", icon: Icons.chart("h-4 w-4") },
{ value: "workers", label: "Workers", icon: Icons.user("h-4 w-4") },
{ value: "tasks", label: "Tasks", icon: Icons.clipboard("h-4 w-4") },
{ value: "logs", label: "Logs", icon: Icons.clock("h-4 w-4") },
{ value: "settings", label: "Settings", icon: Icons.settings("h-4 w-4") },
];
export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }) {
const [tab, setTab] = useState<Tab>(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
const t = params.get("tab");
if (t === "tasks" || t === "workers") return t as Tab;
}
return "summary" as Tab;
});
const [workers, setWorkers] = useState<TimeWorker[]>([]);
const [tasks, setTasks] = useState<TimeTask[]>([]);
const [logs, setLogs] = useState<TimeLog[]>([]);
const [summary, setSummary] = useState<TimeSummary | null>(null);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState<{ start: string; end: string }>(() => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
return { start, end: now.toISOString().slice(0, 10) };
});
const [selectedWorker, setSelectedWorker] = useState("");
const [selectedTask, setSelectedTask] = useState("");
const [logPage, setLogPage] = useState(0);
const [workerModal, setWorkerModal] = useState<TimeWorker | null | "new">(null);
const [taskModal, setTaskModal] = useState<TimeTask | null | "new">(null);
const [exportModal, setExportModal] = useState(false);
const [logModal, setLogModal] = useState<TimeLog | null>(null);
const load = useCallback(async () => {
if (!brandId) return;
setLoading(true);
const [w, t, s, l] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSummary(brandId, dateRange.start, dateRange.end),
getWorkerTimeLogs(brandId, {
workerId: selectedWorker || undefined,
taskId: selectedTask || undefined,
start: dateRange.start,
end: dateRange.end,
limit: PAGE_SIZE,
offset: logPage * PAGE_SIZE,
}),
]);
setWorkers(w);
setTasks(t);
setSummary(s);
setLogs(l);
setLoading(false);
}, [brandId, dateRange, selectedWorker, selectedTask, logPage]);
useEffect(() => { load(); }, [load]);
useEffect(() => { setLogPage(0); }, [dateRange, selectedWorker, selectedTask]);
if (!brandId) {
return (
<div className="text-center py-20 text-[var(--admin-text-muted)]">
<p className="text-sm">Select a brand to view time tracking.</p>
</div>
);
}
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header with date range */}
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-2">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
{Icons.clock("h-5 w-5 sm:h-6 sm:w-6 text-white")}
</div>
<div>
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Time Tracking</h1>
<p className="text-xs text-[var(--admin-text-muted)]">{dateRange.start} {dateRange.end}</p>
</div>
</div>
<AdminButton
onClick={() => setExportModal(true)}
icon={Icons.download("h-4 w-4")}
variant="secondary"
>
Export
</AdminButton>
</div>
{/* Tab navigation - using AdminFilterTabs */}
<AdminFilterTabs
activeTab={tab}
onTabChange={(t) => setTab(t as Tab)}
tabs={TABS}
size="md"
showCounts={false}
/>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
{/* ── Summary Tab ── */}
{tab === "summary" && (
<SummaryTab
summary={summary}
workers={workers}
loading={loading}
dateRange={dateRange}
setDateRange={setDateRange}
onRefresh={load}
/>
)}
{/* ── Workers Tab ── */}
{tab === "workers" && (
<WorkersTab
workers={workers}
onAdd={() => setWorkerModal("new")}
onEdit={(w) => setWorkerModal(w)}
brandId={brandId}
onSave={() => {
load();
setWorkerModal(null);
}}
/>
)}
{/* ── Tasks Tab ── */}
{tab === "tasks" && (
<TasksTab
tasks={tasks}
onAdd={() => setTaskModal("new")}
onEdit={(t) => setTaskModal(t)}
brandId={brandId}
onSave={() => {
load();
setTaskModal(null);
}}
/>
)}
{/* ── Logs Tab ── */}
{tab === "logs" && (
<LogsTab
logs={logs}
workers={workers}
tasks={tasks}
dateRange={dateRange}
setDateRange={setDateRange}
selectedWorker={selectedWorker}
setSelectedWorker={setSelectedWorker}
selectedTask={selectedTask}
setSelectedTask={setSelectedTask}
loading={loading}
onRefresh={load}
onViewLog={setLogModal}
/>
)}
{/* ── Settings Tab ── */}
{tab === "settings" && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<div className="flex items-center gap-2 sm:gap-3 mb-6">
<div className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-lg sm:rounded-xl bg-[var(--admin-text-primary)]">
{Icons.settings("w-4 h-4 sm:w-5 sm:h-5 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-sm sm:text-lg font-bold text-[var(--admin-text-primary)]">Time Tracking Settings</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Configure your time tracking workflow</p>
</div>
</div>
<TimeTrackingSettingsClient brandId={brandId} />
</div>
)}
</div>
{/* Worker Modal */}
{workerModal !== null && (
<WorkerModal
worker={workerModal === "new" ? null : workerModal}
brandId={brandId}
onClose={() => setWorkerModal(null)}
onSave={(saved) => {
if (saved) load();
setWorkerModal(null);
}}
/>
)}
{/* Task Modal */}
{taskModal !== null && (
<TaskModal
task={taskModal === "new" ? null : taskModal}
brandId={brandId}
onClose={() => setTaskModal(null)}
onSave={(saved) => {
if (saved) load();
setTaskModal(null);
}}
/>
)}
{/* Export Modal */}
{exportModal && (
<GlassModal title="Export Time Logs" onClose={() => setExportModal(false)}>
<div className="p-6">
<p className="text-sm text-[var(--admin-text-secondary)] mb-4">Download time logs for the selected date range.</p>
<AdminButton
onClick={() => {
window.location.href = `/api/time-tracking/export?brandId=${brandId}&start=${dateRange.start}&end=${dateRange.end}`;
setExportModal(false);
}}
icon={Icons.download("h-4 w-4")}
fullWidth
>
Download CSV
</AdminButton>
</div>
</GlassModal>
)}
{/* Log Detail Modal */}
{logModal && (
<GlassModal title="Time Log Detail" onClose={() => setLogModal(null)}>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Worker</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.worker_name}</p>
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Task</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.task_name || "—"}</p>
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Clock In</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.clock_in ? formatDate(logModal.clock_in) : "—"}</p>
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Clock Out</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.clock_out ? formatDate(logModal.clock_out) : "Open"}</p>
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Duration</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{formatMinutes(logModal.total_minutes)}</p>
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Notes</p>
<p className="text-sm text-[var(--admin-text-secondary)]">{logModal.notes || "—"}</p>
</div>
</div>
</div>
</GlassModal>
)}
</div>
);
}
// ── Summary Tab ──────────────────────────────────────────────────────────────
function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefresh }: {
summary: TimeSummary | null;
workers: TimeWorker[];
loading: boolean;
dateRange: { start: string; end: string };
setDateRange: React.Dispatch<React.SetStateAction<{ start: string; end: string }>>;
onRefresh: () => void;
}) {
return (
<div className="space-y-5">
{/* Filters */}
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4">
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">From</label>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">To</label>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<AdminButton onClick={onRefresh} icon={Icons.refresh("h-4 w-4")}>
Refresh
</AdminButton>
</div>
</div>
{/* Loading */}
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-[var(--admin-text-muted)]">
<svg className="h-5 w-5 animate-spin" 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>
<span className="text-sm font-medium">Loading...</span>
</div>
</div>
) : summary ? (
<>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-[var(--admin-text-muted)]">{Icons.timer("h-4 w-4")}</span>
<span className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wider">Total Hours</span>
</div>
<p className="text-3xl font-black text-[var(--admin-text-primary)]">{formatMinutes(summary.totals.total_hours * 60)}</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">{summary.totals.entry_count} entries</p>
</div>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-[var(--admin-text-muted)]">{Icons.fileText("h-4 w-4")}</span>
<span className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wider">Entries</span>
</div>
<p className="text-3xl font-black text-[var(--admin-text-primary)]">{summary.totals.entry_count}</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">This period</p>
</div>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-[var(--admin-text-muted)]">{Icons.clock("h-4 w-4")}</span>
<span className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wider">Open Clock-ins</span>
</div>
<p className="text-3xl font-black text-[var(--admin-text-primary)]">{summary.totals.open_count}</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">In progress</p>
</div>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-[var(--admin-text-muted)]">{Icons.user("h-4 w-4")}</span>
<span className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wider">Workers</span>
</div>
<p className="text-3xl font-black text-[var(--admin-text-primary)]">{workers.length}</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">Active</p>
</div>
</div>
{/* By Worker Table */}
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">By Worker</h3>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Worker</th>
<th className="px-5 py-3 text-right text-xs font-bold text-[var(--admin-text-muted)] uppercase">Entries</th>
<th className="px-5 py-3 text-right text-xs font-bold text-[var(--admin-text-muted)] uppercase">Total Hours</th>
</tr>
</thead>
<tbody>
{(summary.by_worker ?? []).length === 0 ? (
<tr><td colSpan={3} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No data</td></tr>
) : (
(summary.by_worker ?? []).map((w) => (
<tr key={w.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{w.name}</td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-secondary)] text-right">{w.entry_count}</td>
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)] text-right">{formatMinutes(w.total_hours * 60)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
) : (
<div className="text-center py-16 text-[var(--admin-text-muted)]">
<p className="text-sm">No summary data available</p>
</div>
)}
</div>
);
}
// ── Workers Tab ───────────────────────────────────────────────────────────────
function WorkersTab({ workers, onAdd, onEdit, brandId, onSave }: {
workers: TimeWorker[];
onAdd: () => void;
onEdit: (w: TimeWorker) => void;
brandId: string;
onSave: () => void;
}) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)]">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Workers ({workers.length})</h3>
<AdminButton onClick={onAdd} icon={Icons.plus("h-4 w-4")}>
Add Worker
</AdminButton>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Name</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">PIN</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Status</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Language</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody>
{workers.length === 0 ? (
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No workers yet</td></tr>
) : (
workers.map((w) => (
<tr key={w.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{w.name}</td>
<td className="px-5 py-4 text-sm font-mono text-[var(--admin-text-secondary)]">{w.pin || "—"}</td>
<td className="px-5 py-4"><StatusBadge active={w.active} /></td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-muted)]">{w.lang?.toUpperCase() ?? "EN"}</td>
<td className="px-5 py-4 text-right">
<button onClick={() => onEdit(w)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
{Icons.edit("h-4 w-4")}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
// ── Tasks Tab ────────────────────────────────────────────────────────────────
function TasksTab({ tasks, onAdd, onEdit, brandId, onSave }: {
tasks: TimeTask[];
onAdd: () => void;
onEdit: (t: TimeTask) => void;
brandId: string;
onSave: () => void;
}) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)]">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Tasks ({tasks.length})</h3>
<AdminButton onClick={onAdd} icon={Icons.plus("h-4 w-4")}>
Add Task
</AdminButton>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Name</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Unit</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Status</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody>
{tasks.length === 0 ? (
<tr><td colSpan={4} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No tasks yet</td></tr>
) : (
tasks.map((t) => (
<tr key={t.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4">
<div className="text-sm font-semibold text-[var(--admin-text-primary)]">{t.name}</div>
{t.name_es && <div className="text-xs text-[var(--admin-text-muted)]">{t.name_es}</div>}
</td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-secondary)] capitalize">{t.unit || "hours"}</td>
<td className="px-5 py-4"><StatusBadge active={t.active} /></td>
<td className="px-5 py-4 text-right">
<button onClick={() => onEdit(t)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
{Icons.edit("h-4 w-4")}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
// ── Logs Tab ─────────────────────────────────────────────────────────────────
function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker, setSelectedWorker, selectedTask, setSelectedTask, loading, onRefresh, onViewLog }: {
logs: TimeLog[];
workers: TimeWorker[];
tasks: TimeTask[];
dateRange: { start: string; end: string };
setDateRange: React.Dispatch<React.SetStateAction<{ start: string; end: string }>>;
selectedWorker: string;
setSelectedWorker: (v: string) => void;
selectedTask: string;
setSelectedTask: (v: string) => void;
loading: boolean;
onRefresh: () => void;
onViewLog: (log: TimeLog) => void;
}) {
return (
<div className="space-y-5">
{/* Filters */}
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4">
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">From</label>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">To</label>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">Worker</label>
<select
value={selectedWorker}
onChange={(e) => setSelectedWorker(e.target.value)}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)] min-w-[160px]"
>
<option value="">All Workers</option>
{workers.map((w) => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">Task</label>
<select
value={selectedTask}
onChange={(e) => setSelectedTask(e.target.value)}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)] min-w-[160px]"
>
<option value="">All Tasks</option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<AdminButton onClick={onRefresh} icon={Icons.refresh("h-4 w-4")}>
Refresh
</AdminButton>
</div>
</div>
{/* Logs Table */}
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-[var(--admin-text-muted)]">
<svg className="h-5 w-5 animate-spin" 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>
<span className="text-sm font-medium">Loading...</span>
</div>
</div>
) : (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Time Logs</h3>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Worker</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Task</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Clock In</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Clock Out</th>
<th className="px-5 py-3 text-right text-xs font-bold text-[var(--admin-text-muted)] uppercase">Duration</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No logs yet</td></tr>
) : (
logs.map((log) => (
<tr key={log.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors cursor-pointer" onClick={() => onViewLog(log)}>
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{log.worker_name}</td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-secondary)]">{log.task_name || "—"}</td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-muted)]">{log.clock_in ? formatDate(log.clock_in) : "—"}</td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-muted)]">{log.clock_out ? formatDate(log.clock_out) : "Open"}</td>
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)] text-right">{formatMinutes(log.total_minutes)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── Worker Modal ─────────────────────────────────────────────────────────────
function WorkerModal({ worker, brandId, onClose, onSave }: {
worker: TimeWorker | null;
brandId: string;
onClose: () => void;
onSave: (saved: boolean) => void;
}) {
const [name, setName] = useState(worker?.name ?? "");
const [role, setRole] = useState(worker?.role ?? "worker");
const [lang, setLang] = useState(worker?.lang ?? "en");
const [active, setActive] = useState(worker?.active ?? true);
const [loading, setLoading] = useState(false);
const isEdit = !!worker;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
if (isEdit && worker) {
await updateTimeWorker(worker.id, name, role, lang, active);
} else {
await createTimeWorker(brandId, name, role, lang);
}
onSave(true);
} catch {
onSave(false);
}
setLoading(false);
}
async function handleResetPin() {
if (!worker) return;
setLoading(true);
await resetTimeWorkerPin(worker.id);
setLoading(false);
}
async function handleDelete() {
if (!worker || !confirm("Delete this worker?")) return;
setLoading(true);
await deleteTimeWorker(worker.id);
onSave(true);
setLoading(false);
}
return (
<GlassModal title={isEdit ? "Edit Worker" : "Add Worker"} onClose={onClose}>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Role</label>
<select value={role} onChange={(e) => setRole(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="worker">Worker</option>
<option value="supervisor">Supervisor</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Language</label>
<select value={lang} onChange={(e) => setLang(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} className="rounded border-[var(--admin-border)]" />
<span className="text-sm text-[var(--admin-text-secondary)]">Active</span>
</label>
<div className="flex gap-2 pt-2">
{isEdit && worker && (
<>
<AdminButton type="button" variant="secondary" size="sm" onClick={handleResetPin} disabled={loading} icon={Icons.refresh("h-3.5 w-3.5")}>
Reset PIN
</AdminButton>
<AdminButton type="button" variant="danger" size="sm" onClick={handleDelete} disabled={loading} icon={Icons.trash("h-3.5 w-3.5")}>
Delete
</AdminButton>
</>
)}
<AdminButton type="submit" disabled={loading} isLoading={loading} className="ml-auto">
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</AdminButton>
</div>
</form>
</GlassModal>
);
}
// ── Task Modal ───────────────────────────────────────────────────────────────
function TaskModal({ task, brandId, onClose, onSave }: {
task: TimeTask | null;
brandId: string;
onClose: () => void;
onSave: (saved: boolean) => void;
}) {
const [name, setName] = useState(task?.name ?? "");
const [nameEs, setNameEs] = useState(task?.name_es ?? "");
const [unit, setUnit] = useState(task?.unit ?? "hours");
const [active, setActive] = useState(task?.active ?? true);
const [loading, setLoading] = useState(false);
const isEdit = !!task;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
if (isEdit && task) {
await updateTimeTask(task.id, name, nameEs, unit, active, task.sort_order);
} else {
await createTimeTask(brandId, name, nameEs || null, unit);
}
onSave(true);
} catch {
onSave(false);
}
setLoading(false);
}
async function handleDelete() {
if (!task || !confirm("Delete this task?")) return;
setLoading(true);
await deleteTimeTask(task.id);
onSave(true);
setLoading(false);
}
return (
<GlassModal title={isEdit ? "Edit Task" : "Add Task"} onClose={onClose}>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Task Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g., Harvesting" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Name in Spanish</label>
<input value={nameEs} onChange={(e) => setNameEs(e.target.value)} placeholder="e.g., Cosecha" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit</label>
<select value={unit} onChange={(e) => setUnit(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} className="rounded border-[var(--admin-border)]" />
<span className="text-sm text-[var(--admin-text-secondary)]">Active</span>
</label>
<div className="flex gap-2 pt-2">
{isEdit && task && (
<AdminButton type="button" variant="danger" size="sm" onClick={handleDelete} disabled={loading} icon={Icons.trash("h-3.5 w-3.5")}>
Delete
</AdminButton>
)}
<AdminButton type="submit" disabled={loading} isLoading={loading} className="ml-auto">
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</AdminButton>
</div>
</form>
</GlassModal>
);
}