Coastal Timesheet v2 — full feature buildout
Features: - Copy Previous Week (entries duplication) - Bulk Approve/Reject (admin workflow) - Overtime tracking (employee + admin views) - DayCard component with auto-save - PDF generation, email notifications - React + Tailwind frontend, Prisma + PostgreSQL backend - Docker deployment (3 containers)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
/* ─── Token helpers ─── */
|
||||
|
||||
function getAccessToken() {
|
||||
return localStorage.getItem('accessToken');
|
||||
}
|
||||
|
||||
function getRefreshToken() {
|
||||
return localStorage.getItem('refreshToken');
|
||||
}
|
||||
|
||||
function setTokens(accessToken, refreshToken) {
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
/* ─── Request interceptor: attach access token ─── */
|
||||
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
/* ─── Response interceptor: auto-refresh on 401 ─── */
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue = [];
|
||||
|
||||
function processQueue(error, token) {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
/* If 401 with TOKEN_EXPIRED and we haven't retried yet */
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
error.response?.data?.code === 'TOKEN_EXPIRED' &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
/* Queue this request until the refresh completes */
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return client(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
/* Call refresh endpoint directly (bypass interceptor) */
|
||||
const { data } = await axios.post('/api/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
processQueue(null, data.accessToken);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return client(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
clearTokens();
|
||||
/* Redirect to login */
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* If 401 on non-refresh, clear and redirect */
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest.url?.includes('/auth/refresh')
|
||||
) {
|
||||
clearTokens();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export { client as default, setTokens, clearTokens, getAccessToken, getRefreshToken };
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, Plus } from 'lucide-react';
|
||||
import EntryForm from './EntryForm';
|
||||
|
||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
export default function DayCard({ date, entries, homeowners, onEntryChange, onAddEntry, onDeleteEntry, disabled, defaultExpanded }) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
// Parse YYYY-MM-DD as local date (avoid UTC midnight timezone shift)
|
||||
const [y, m, dy] = date.split('-').map(Number);
|
||||
const d = new Date(y, m - 1, dy);
|
||||
const isToday = new Date().toLocaleDateString('en-CA') === date;
|
||||
const dayName = DAY_NAMES[d.getDay()];
|
||||
const shortDay = SHORT_DAYS[d.getDay()];
|
||||
const dateLabel = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
const totalHours = (entries || []).reduce((sum, e) => sum + (parseFloat(e.hoursWorked) || 0), 0);
|
||||
const entryCount = (entries || []).filter((e) => e.homeownerId || e.hoursWorked || e.workDescription).length;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border transition-all ${
|
||||
isToday
|
||||
? 'border-sky-200 dark:border-sky-500/30 bg-sky-50/30 dark:bg-sky-500/5 shadow-sm shadow-sky-100 dark:shadow-none'
|
||||
: 'border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900/50'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between px-4 py-3.5 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-sm font-bold ${
|
||||
isToday
|
||||
? 'bg-sky-500 text-white shadow-md shadow-sky-500/30'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{shortDay}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{dayName}</span>
|
||||
{isToday && <span className="text-[10px] font-bold text-sky-500 bg-sky-100 dark:bg-sky-500/20 px-1.5 py-0.5 rounded-md">TODAY</span>}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{dateLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{totalHours > 0 && (
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{totalHours.toFixed(1)}h
|
||||
</span>
|
||||
)}
|
||||
{entryCount > 0 && (
|
||||
<span className="text-xs bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400 px-2 py-0.5 rounded-full font-medium">
|
||||
{entryCount}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={18} className={`text-gray-400 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Entries */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{(entries || []).map((entry) => (
|
||||
<EntryForm
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
homeowners={homeowners}
|
||||
onChange={onEntryChange}
|
||||
onDelete={onDeleteEntry}
|
||||
canDelete={(entries || []).length > 1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => onAddEntry(date)}
|
||||
className="w-full py-2.5 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 text-gray-400 hover:text-sky-500 hover:border-sky-300 dark:hover:border-sky-500/40 text-sm font-medium flex items-center justify-center gap-1.5 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import HomeownerSelect from './HomeownerSelect';
|
||||
|
||||
export default function EntryForm({ entry, homeowners, onChange, onDelete, canDelete, disabled }) {
|
||||
function handleChange(field, value) {
|
||||
onChange(entry.id, { ...entry, [field]: value });
|
||||
}
|
||||
|
||||
const hasInput = entry.homeownerId || entry.hoursWorked || entry.workDescription;
|
||||
const isComplete = entry.homeownerId && entry.hoursWorked && entry.workDescription;
|
||||
const isPartial = hasInput && !isComplete;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 space-y-3 transition-all ${
|
||||
isPartial
|
||||
? 'border-amber-300 dark:border-amber-500/40 bg-amber-50/50 dark:bg-amber-500/5'
|
||||
: 'border-gray-200 dark:border-gray-700/60 bg-white dark:bg-gray-800/50'
|
||||
} ${disabled ? 'opacity-60 pointer-events-none' : ''}`}>
|
||||
{/* Homeowner */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Homeowner {isPartial && !entry.homeownerId && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<HomeownerSelect
|
||||
homeowners={homeowners}
|
||||
value={entry.homeownerId || ''}
|
||||
onChange={(v) => handleChange('homeownerId', v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours + Delete */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Hours {isPartial && !entry.hoursWorked && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
max="24"
|
||||
value={entry.hoursWorked || ''}
|
||||
onChange={(e) => handleChange('hoursWorked', e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="0.0"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
{canDelete && !disabled && (
|
||||
<button
|
||||
onClick={() => onDelete(entry.id)}
|
||||
className="p-2.5 rounded-xl text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
|
||||
title="Remove entry"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Work Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Work Description {isPartial && !entry.workDescription && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={entry.workDescription || ''}
|
||||
onChange={(e) => handleChange('workDescription', e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Describe work performed..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white text-sm resize-none focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, Plus, Search } from 'lucide-react';
|
||||
|
||||
export default function HomeownerSelect({ homeowners, value, onChange, disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && inputRef.current) inputRef.current.focus();
|
||||
}, [open]);
|
||||
|
||||
const filtered = homeowners.filter((h) =>
|
||||
h.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const selectedLabel = homeowners.find((h) => h.id === value)?.name || '';
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl border text-left text-sm transition-all ${
|
||||
disabled
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed border-gray-200 dark:border-gray-700'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white hover:border-sky-400 focus:ring-2 focus:ring-sky-500/40'
|
||||
}`}
|
||||
>
|
||||
<span className={selectedLabel ? '' : 'text-gray-400'}>{selectedLabel || 'Select homeowner...'}</span>
|
||||
<ChevronDown size={16} className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-xl shadow-gray-200/50 dark:shadow-black/40 max-h-64 overflow-hidden">
|
||||
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border-none text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-48 p-1">
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
)}
|
||||
{filtered.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
onClick={() => { onChange(h.id); setOpen(false); setSearch(''); }}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||
h.id === value
|
||||
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && search && !adding && (
|
||||
<div className="px-3 py-4 text-center">
|
||||
<p className="text-sm text-gray-400 mb-2">No match found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Outlet, NavLink, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { Calendar, Clock, Shield, LogOut, Menu, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Clock, label: 'Timesheet' },
|
||||
{ to: '/history', icon: Calendar, label: 'History' },
|
||||
...(isAdmin ? [{ to: '/admin', icon: Shield, label: 'Admin' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-b border-gray-200/60 dark:border-gray-800/60">
|
||||
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-semibold bg-gradient-to-r from-sky-500 to-teal-400 bg-clip-text text-transparent">
|
||||
Coastal Contracting
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden sm:flex items-center gap-1">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<div className="hidden sm:flex items-center gap-2 pl-2 border-l border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 max-w-[120px] truncate">{user?.name}</span>
|
||||
<button onClick={logout} className="p-2 rounded-xl text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all" title="Logout">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setMenuOpen(!menuOpen)} className="sm:hidden p-2 rounded-xl text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
{menuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{menuOpen && (
|
||||
<div className="sm:hidden border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 py-3 space-y-1">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-xl text-base font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={20} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-800 mt-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{user?.name} ({user?.role})</span>
|
||||
<button onClick={logout} className="flex items-center gap-2 text-red-500 text-sm font-medium">
|
||||
<LogOut size={16} /> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-t border-gray-200/60 dark:border-gray-800/60 safe-area-bottom">
|
||||
<div className="flex justify-around py-2">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-0.5 px-4 py-1.5 rounded-xl transition-all min-w-[64px] ${
|
||||
isActive
|
||||
? 'text-sky-500 dark:text-sky-400'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-5xl mx-auto px-4 py-6 pb-24 sm:pb-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Check, Loader2, AlertCircle, CloudOff } from 'lucide-react';
|
||||
|
||||
const STATUS_MAP = {
|
||||
idle: {
|
||||
icon: null,
|
||||
text: '',
|
||||
color: '',
|
||||
},
|
||||
saving: {
|
||||
icon: Loader2,
|
||||
text: 'Saving…',
|
||||
color: 'text-ocean-500',
|
||||
animate: 'animate-spin',
|
||||
},
|
||||
saved: {
|
||||
icon: Check,
|
||||
text: 'Saved',
|
||||
color: 'text-emerald-500',
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
text: 'Save failed',
|
||||
color: 'text-red-500',
|
||||
},
|
||||
offline: {
|
||||
icon: CloudOff,
|
||||
text: 'Offline',
|
||||
color: 'text-amber-500',
|
||||
},
|
||||
};
|
||||
|
||||
export default function SaveIndicator({ status = 'idle' }) {
|
||||
const config = STATUS_MAP[status] || STATUS_MAP.idle;
|
||||
|
||||
if (!config.icon) return null;
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium ${config.color} transition-opacity duration-300`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Icon size={14} className={config.animate || ''} />
|
||||
<span>{config.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
FileEdit,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
draft: {
|
||||
label: 'Draft',
|
||||
bg: 'bg-gray-100 dark:bg-gray-800',
|
||||
text: 'text-gray-600 dark:text-gray-400',
|
||||
icon: FileEdit,
|
||||
},
|
||||
submitted: {
|
||||
label: 'Submitted',
|
||||
bg: 'bg-ocean-50 dark:bg-ocean-950',
|
||||
text: 'text-ocean-600 dark:text-ocean-400',
|
||||
icon: Send,
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-950',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
bg: 'bg-red-50 dark:bg-red-950',
|
||||
text: 'text-red-600 dark:text-red-400',
|
||||
icon: XCircle,
|
||||
},
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, size = 'md' }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.draft;
|
||||
const Icon = config.icon;
|
||||
|
||||
const sizeClasses = size === 'sm'
|
||||
? 'px-2 py-0.5 text-[10px] gap-1'
|
||||
: 'px-3 py-1 text-xs gap-1.5';
|
||||
|
||||
const iconSize = size === 'sm' ? 10 : 12;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`badge ${config.bg} ${config.text} ${sizeClasses}`}
|
||||
>
|
||||
<Icon size={iconSize} />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { isDark, toggle } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="btn-ghost relative w-10 h-10 p-0 rounded-full"
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ${
|
||||
isDark ? 'opacity-0 rotate-90 scale-50' : 'opacity-100 rotate-0 scale-100'
|
||||
}`}
|
||||
>
|
||||
<Sun size={18} className="text-amber-500" />
|
||||
</span>
|
||||
<span
|
||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ${
|
||||
isDark ? 'opacity-100 rotate-0 scale-100' : 'opacity-0 -rotate-90 scale-50'
|
||||
}`}
|
||||
>
|
||||
<Moon size={18} className="text-ocean-300" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react';
|
||||
|
||||
function getWeekRange(date) {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const start = new Date(d);
|
||||
start.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); // Monday
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6); // Sunday
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function isCurrentWeek(date) {
|
||||
const now = new Date();
|
||||
const { start, end } = getWeekRange(date);
|
||||
return now >= start && now <= end;
|
||||
}
|
||||
|
||||
export default function WeekNavigator({ selectedDate, onDateChange, onSwipeHandlers }) {
|
||||
const { start, end } = getWeekRange(selectedDate);
|
||||
|
||||
function prevWeek() {
|
||||
const d = new Date(selectedDate);
|
||||
d.setDate(d.getDate() - 7);
|
||||
onDateChange(d);
|
||||
}
|
||||
|
||||
function nextWeek() {
|
||||
const d = new Date(selectedDate);
|
||||
d.setDate(d.getDate() + 7);
|
||||
onDateChange(d);
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
onDateChange(new Date());
|
||||
}
|
||||
|
||||
const current = isCurrentWeek(selectedDate);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between" {...(onSwipeHandlers || {})}>
|
||||
<button
|
||||
onClick={prevWeek}
|
||||
className="p-3 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-95 transition-all"
|
||||
aria-label="Previous week"
|
||||
>
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{formatDate(start)} — {formatDate(end)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{start.getFullYear()}
|
||||
{current && <span className="ml-2 text-sky-500 font-medium">This Week</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!current && (
|
||||
<button
|
||||
onClick={goToday}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 text-xs font-medium hover:bg-sky-100 dark:hover:bg-sky-500/20 transition-all"
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={nextWeek}
|
||||
className="p-3 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-95 transition-all"
|
||||
aria-label="Next week"
|
||||
>
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { getWeekRange };
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import api, { setTokens, clearTokens, getAccessToken } from '../api/client';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('user');
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
/* On mount, verify the stored token is still valid */
|
||||
useEffect(() => {
|
||||
async function verify() {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get('/auth/me');
|
||||
setUser(data.user);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
} catch {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
verify();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const { data } = await api.post('/auth/login', { email, password });
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
setUser(data.user);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch {
|
||||
/* ignore — we clear locally regardless */
|
||||
}
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
isAdmin,
|
||||
isAuthenticated: !!user,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Auto-save hook with debouncing and visual status.
|
||||
*
|
||||
* Returns:
|
||||
* saveStatus — 'idle' | 'saving' | 'saved' | 'error'
|
||||
* triggerSave — call with an async fn that does the actual save
|
||||
* resetStatus — manually reset to idle
|
||||
*/
|
||||
export function useAutoSave(debounceMs = 500) {
|
||||
const [saveStatus, setSaveStatus] = useState('idle');
|
||||
const timerRef = useRef(null);
|
||||
const savedTimerRef = useRef(null);
|
||||
|
||||
/* Clean up on unmount */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerSave = useCallback(
|
||||
(saveFn) => {
|
||||
/* Clear any pending debounce */
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
|
||||
setSaveStatus('saving');
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await saveFn();
|
||||
setSaveStatus('saved');
|
||||
/* Revert to idle after 3 seconds */
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus('idle');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Auto-save error:', err);
|
||||
setSaveStatus('error');
|
||||
/* Revert to idle after 5 seconds */
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus('idle');
|
||||
}, 5000);
|
||||
}
|
||||
}, debounceMs);
|
||||
},
|
||||
[debounceMs]
|
||||
);
|
||||
|
||||
const resetStatus = useCallback(() => setSaveStatus('idle'), []);
|
||||
|
||||
return { saveStatus, triggerSave, resetStatus };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Simple swipe detection hook for mobile week navigation.
|
||||
*
|
||||
* Usage:
|
||||
* const { onTouchStart, onTouchEnd } = useSwipe({ onSwipeLeft, onSwipeRight });
|
||||
* <div onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
|
||||
*/
|
||||
export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 50 }) {
|
||||
const touchStartX = useRef(0);
|
||||
const touchStartY = useRef(0);
|
||||
|
||||
const onTouchStart = useCallback((e) => {
|
||||
touchStartX.current = e.changedTouches[0].clientX;
|
||||
touchStartY.current = e.changedTouches[0].clientY;
|
||||
}, []);
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e) => {
|
||||
const deltaX = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
|
||||
|
||||
/* Only trigger if horizontal swipe is dominant */
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > threshold) {
|
||||
if (deltaX < 0 && onSwipeLeft) {
|
||||
onSwipeLeft();
|
||||
} else if (deltaX > 0 && onSwipeRight) {
|
||||
onSwipeRight();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSwipeLeft, onSwipeRight, threshold]
|
||||
);
|
||||
|
||||
return { onTouchStart, onTouchEnd };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export function useTheme() {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored) return stored === 'dark';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isDark) {
|
||||
root.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
/* Listen for system theme changes when no preference is stored */
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
function handleChange(e) {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (!stored) {
|
||||
setIsDark(e.matches);
|
||||
}
|
||||
}
|
||||
mq.addEventListener('change', handleChange);
|
||||
return () => mq.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => setIsDark((prev) => !prev), []);
|
||||
|
||||
return { isDark, toggle };
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Apple-like Design System ── */
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
font-feature-settings: 'kern' 1, 'liga' 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Safe area padding for notched phones */
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Remove default focus outlines, add our own */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
@apply ring-2 ring-ocean-500 ring-offset-2 ring-offset-white dark:ring-offset-gray-900;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Smooth scrolling containers */
|
||||
.scroll-container {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Glass morphism card */
|
||||
.glass-card {
|
||||
@apply bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl;
|
||||
@apply border border-gray-200/50 dark:border-gray-700/50;
|
||||
@apply rounded-2xl shadow-soft;
|
||||
}
|
||||
|
||||
/* Elevated card */
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-900;
|
||||
@apply border border-gray-200 dark:border-gray-800;
|
||||
@apply rounded-2xl shadow-soft;
|
||||
@apply transition-shadow duration-200;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply shadow-soft-lg;
|
||||
}
|
||||
|
||||
/* Apple-style input */
|
||||
.input-field {
|
||||
@apply w-full px-4 py-3;
|
||||
@apply bg-gray-100 dark:bg-gray-800;
|
||||
@apply border border-transparent;
|
||||
@apply rounded-xl;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply placeholder-gray-400 dark:placeholder-gray-500;
|
||||
@apply transition-all duration-200;
|
||||
@apply text-base;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
@apply bg-white dark:bg-gray-700;
|
||||
@apply border-ocean-500;
|
||||
@apply ring-2 ring-ocean-500/20;
|
||||
}
|
||||
|
||||
/* Primary button */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-6 py-3;
|
||||
@apply bg-ocean-500 hover:bg-ocean-600 active:bg-ocean-700;
|
||||
@apply text-white font-semibold;
|
||||
@apply rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply shadow-sm hover:shadow-md active:shadow-sm;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
@apply bg-ocean-300 dark:bg-ocean-800 cursor-not-allowed shadow-none;
|
||||
@apply active:scale-100;
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-6 py-3;
|
||||
@apply bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700;
|
||||
@apply text-gray-700 dark:text-gray-300 font-semibold;
|
||||
@apply rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Success button (for submit) */
|
||||
.btn-success {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-6 py-4;
|
||||
@apply bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700;
|
||||
@apply text-white font-bold text-lg;
|
||||
@apply rounded-2xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply shadow-lg shadow-emerald-500/25 hover:shadow-xl hover:shadow-emerald-500/30;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.btn-success:disabled {
|
||||
@apply bg-emerald-300 dark:bg-emerald-800 cursor-not-allowed shadow-none;
|
||||
@apply active:scale-100;
|
||||
}
|
||||
|
||||
/* Danger button */
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-6 py-3;
|
||||
@apply bg-red-500 hover:bg-red-600 active:bg-red-700;
|
||||
@apply text-white font-semibold;
|
||||
@apply rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Ghost button */
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply px-4 py-2;
|
||||
@apply text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-gray-800;
|
||||
@apply rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
@apply active:scale-[0.98];
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Badge base */
|
||||
.badge {
|
||||
@apply inline-flex items-center;
|
||||
@apply px-3 py-1;
|
||||
@apply text-xs font-semibold uppercase tracking-wider;
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
/* Section header */
|
||||
.section-title {
|
||||
@apply text-xs font-semibold uppercase tracking-wider;
|
||||
@apply text-gray-400 dark:text-gray-500;
|
||||
@apply mb-2 px-1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Touch-friendly sizing */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Transitions for route changes ── */
|
||||
.page-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.page-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.25s ease-out, transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* ── Custom scrollbar for desktop ── */
|
||||
@media (hover: hover) {
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-700;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Skeleton loading ── */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme('colors.gray.200') 25%,
|
||||
theme('colors.gray.100') 50%,
|
||||
theme('colors.gray.200') 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark .skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme('colors.gray.800') 25%,
|
||||
theme('colors.gray.700') 50%,
|
||||
theme('colors.gray.800') 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Timesheet from './pages/Timesheet';
|
||||
import History from './pages/History';
|
||||
import Admin from './pages/Admin';
|
||||
import './index.css';
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><div className="animate-spin w-8 h-8 border-4 border-sky-500 border-t-transparent rounded-full" /></div>;
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
function AdminRoute({ children }) {
|
||||
const { isAdmin, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return isAdmin ? children : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route index element={<Timesheet />} />
|
||||
<Route path="history" element={<History />} />
|
||||
<Route path="admin" element={<AdminRoute><Admin /></AdminRoute>} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,757 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../api/client';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle } from 'lucide-react';
|
||||
|
||||
function TabButton({ active, onClick, icon: Icon, label, count }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||
active
|
||||
? 'bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
{count > 0 && (
|
||||
<span className="bg-sky-500 text-white text-xs px-1.5 py-0.5 rounded-full min-w-[20px] text-center">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingReviews() {
|
||||
const [timesheets, setTimesheets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionId, setActionId] = useState(null);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [bulkAction, setBulkAction] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadPending();
|
||||
}, []);
|
||||
|
||||
async function loadPending() {
|
||||
try {
|
||||
const res = await api.get('/admin/timesheets?status=submitted');
|
||||
setTimesheets(res.data.timesheets || res.data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selected.size === timesheets.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(timesheets.map((t) => t.id)));
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkApprove() {
|
||||
if (selected.size === 0) return;
|
||||
setBulkAction(true);
|
||||
try {
|
||||
const res = await api.post('/admin/timesheets/bulk-approve', { timesheetIds: [...selected] });
|
||||
alert(`Approved: ${res.data.approved}${res.data.failed?.length ? `, Failed: ${res.data.failed.length}` : ''}`);
|
||||
setSelected(new Set());
|
||||
loadPending();
|
||||
} catch (err) { alert('Bulk approve failed'); }
|
||||
finally { setBulkAction(false); }
|
||||
}
|
||||
|
||||
async function bulkReject() {
|
||||
if (selected.size === 0) return;
|
||||
const notes = prompt('Rejection reason:');
|
||||
if (notes === null) return;
|
||||
setBulkAction(true);
|
||||
try {
|
||||
const res = await api.post('/admin/timesheets/bulk-reject', { timesheetIds: [...selected], notes });
|
||||
alert(`Rejected: ${res.data.rejected}`);
|
||||
setSelected(new Set());
|
||||
loadPending();
|
||||
} catch (err) { alert('Bulk reject failed'); }
|
||||
finally { setBulkAction(false); }
|
||||
}
|
||||
|
||||
async function approve(id) {
|
||||
setActionId(id);
|
||||
try {
|
||||
await api.put(`/admin/timesheets/${id}/approve`, {});
|
||||
setTimesheets((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to approve');
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function reject(id) {
|
||||
const notes = prompt('Rejection reason (optional):');
|
||||
if (notes === null) return;
|
||||
setActionId(id);
|
||||
try {
|
||||
await api.put(`/admin/timesheets/${id}/reject`, { notes });
|
||||
setTimesheets((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to reject');
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||
|
||||
if (timesheets.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10">
|
||||
<Check size={40} className="mx-auto text-emerald-400 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">All caught up! No pending reviews.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Bulk action bar */}
|
||||
{timesheets.length > 1 && (
|
||||
<div className="flex items-center justify-between bg-gray-50 dark:bg-gray-800/50 rounded-xl px-4 py-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" checked={selected.size === timesheets.length && timesheets.length > 0} onChange={toggleSelectAll} className="rounded" />
|
||||
{selected.size > 0 ? `${selected.size} selected` : 'Select all'}
|
||||
</label>
|
||||
{selected.size > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={bulkApprove} disabled={bulkAction} className="px-3 py-1.5 rounded-lg bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600 disabled:opacity-50 flex items-center gap-1">
|
||||
<Check size={14} /> Approve All
|
||||
</button>
|
||||
<button onClick={bulkReject} disabled={bulkAction} className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-xs font-medium hover:bg-red-600 disabled:opacity-50 flex items-center gap-1">
|
||||
<X size={14} /> Reject All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timesheets.map((ts) => {
|
||||
const [sy, sm, sd] = (ts.weekStart || '').split('-').map(Number);
|
||||
const [ey, em, ed] = (ts.weekEnd || '').split('-').map(Number);
|
||||
const start = new Date(sy, sm - 1, sd);
|
||||
const end = new Date(ey, em - 1, ed);
|
||||
const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return (
|
||||
<div key={ts.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" checked={selected.has(ts.id)} onChange={() => toggleSelect(ts.id)} className="rounded" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{ts.userName || ts.user?.name || 'Employee'}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => approve(ts.id)}
|
||||
disabled={actionId === ts.id}
|
||||
className="p-2.5 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 transition-all"
|
||||
title="Approve"
|
||||
>
|
||||
<Check size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reject(ts.id)}
|
||||
disabled={actionId === ts.id}
|
||||
className="p-2.5 rounded-xl bg-red-50 dark:bg-red-500/10 text-red-500 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-500/20 transition-all"
|
||||
title="Reject"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageUsers() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'employee' });
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => { loadUsers(); }, []);
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await api.get('/admin/users');
|
||||
setUsers(res.data.users || res.data || []);
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function createUser(e) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/admin/users', form);
|
||||
setForm({ name: '', email: '', password: '', role: 'employee' });
|
||||
setShowForm(false);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to create user');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 text-sm font-medium hover:bg-sky-100 dark:hover:bg-sky-500/20 transition-all"
|
||||
>
|
||||
<UserPlus size={16} /> Add Employee
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={createUser} className="bg-sky-50/50 dark:bg-sky-500/5 rounded-2xl border border-sky-200 dark:border-sky-500/20 p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required placeholder="Full name" className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required type="email" placeholder="Email" className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<input value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required type="password" placeholder="Password (min 8 chars)" minLength={8} className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||
<option value="employee">Employee</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 rounded-xl text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
|
||||
<button type="submit" disabled={creating} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50">
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className={`bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 ${u.isActive === false ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{u.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{u.email}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${u.id}`, { role: e.target.value });
|
||||
loadUsers();
|
||||
} catch (err) { alert(err.response?.data?.error || 'Failed'); }
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="employee">Employee</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{u.isActive === false ? (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try { await api.put(`/admin/users/${u.id}`, { isActive: true }); loadUsers(); }
|
||||
catch (err) { alert('Failed to reactivate'); }
|
||||
}}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 font-medium"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Deactivate ${u.name}? They won't be able to log in.`)) return;
|
||||
try { await api.delete(`/admin/users/${u.id}`); loadUsers(); }
|
||||
catch (err) { alert(err.response?.data?.error || 'Failed'); }
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
|
||||
title="Deactivate"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageHomeowners() {
|
||||
const [homeowners, setHomeowners] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ name: '', address: '' });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', address: '' });
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => { loadHomeowners(); }, [showInactive]);
|
||||
|
||||
async function loadHomeowners() {
|
||||
try {
|
||||
const res = await api.get(`/admin/homeowners?includeInactive=${showInactive}`);
|
||||
setHomeowners(res.data.homeowners || res.data || []);
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function addHomeowner(e) {
|
||||
e.preventDefault();
|
||||
if (!newForm.name.trim()) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await api.post('/admin/homeowners', { name: newForm.name.trim(), address: newForm.address.trim() || undefined });
|
||||
setNewForm({ name: '', address: '' });
|
||||
setShowAdd(false);
|
||||
loadHomeowners();
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to add');
|
||||
} finally { setAdding(false); }
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
try {
|
||||
await api.put(`/admin/homeowners/${id}`, { name: editForm.name.trim(), address: editForm.address.trim() || null });
|
||||
setEditId(null);
|
||||
loadHomeowners();
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to update');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(h) {
|
||||
try {
|
||||
await api.put(`/admin/homeowners/${h.id}`, { isActive: !h.isActive });
|
||||
loadHomeowners();
|
||||
} catch (err) { alert('Failed'); }
|
||||
}
|
||||
|
||||
const filtered = homeowners.filter((h) =>
|
||||
h.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(h.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search + Controls */}
|
||||
<div className="flex gap-2">
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search homeowners..." className="flex-1 px-4 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="px-3 py-2.5 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 flex items-center gap-1.5">
|
||||
<Plus size={16} /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<input type="checkbox" checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} className="rounded" />
|
||||
Show inactive homeowners
|
||||
</label>
|
||||
|
||||
{/* Add Form */}
|
||||
{showAdd && (
|
||||
<form onSubmit={addHomeowner} className="bg-sky-50/50 dark:bg-sky-500/5 rounded-2xl border border-sky-200 dark:border-sky-500/20 p-4 space-y-3">
|
||||
<input value={newForm.name} onChange={(e) => setNewForm({ ...newForm, name: e.target.value })} required placeholder="Homeowner name (e.g. Smith, 142)" className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<input value={newForm.address} onChange={(e) => setNewForm({ ...newForm, address: e.target.value })} placeholder="Address (optional)" className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 rounded-xl text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
|
||||
<button type="submit" disabled={adding} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50">{adding ? 'Adding...' : 'Add Homeowner'}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{filtered.length} homeowners</div>
|
||||
|
||||
{/* Homeowner List */}
|
||||
<div className="space-y-1">
|
||||
{filtered.map((h) => (
|
||||
<div key={h.id} className={`rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 ${h.isActive === false ? 'opacity-50' : ''}`}>
|
||||
{editId === h.id ? (
|
||||
/* Edit Mode */
|
||||
<div className="p-3 space-y-2">
|
||||
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} className="w-full px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Name" />
|
||||
<input value={editForm.address} onChange={(e) => setEditForm({ ...editForm, address: e.target.value })} className="w-full px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Address" />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setEditId(null)} className="px-3 py-1.5 rounded-lg text-xs text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
|
||||
<button onClick={() => saveEdit(h.id)} className="px-3 py-1.5 rounded-lg bg-sky-500 text-white text-xs font-medium hover:bg-sky-600">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View Mode */
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{h.name}</div>
|
||||
{h.address && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">#{h.address}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<button
|
||||
onClick={() => { setEditId(h.id); setEditForm({ name: h.name, address: h.address || '' }); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-all"
|
||||
title="Edit"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleActive(h)}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg font-medium transition-all ${
|
||||
h.isActive !== false
|
||||
? 'text-emerald-600 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10'
|
||||
: 'text-gray-400 bg-gray-100 dark:bg-gray-800 hover:bg-emerald-50 hover:text-emerald-500 dark:hover:bg-emerald-500/10'
|
||||
}`}
|
||||
>
|
||||
{h.isActive !== false ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Reports() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [timesheets, setTimesheets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [homeowners, setHomeowners] = useState([]);
|
||||
const [filters, setFilters] = useState({ userId: '', status: '', startDate: '', endDate: '', homeownerId: '' });
|
||||
const [selectedTs, setSelectedTs] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/admin/users'),
|
||||
api.get('/admin/timesheets'),
|
||||
api.get('/admin/homeowners?includeInactive=true'),
|
||||
]).then(([usersRes, tsRes, hoRes]) => {
|
||||
setUsers(usersRes.data.users || usersRes.data || []);
|
||||
setTimesheets(tsRes.data.timesheets || tsRes.data || []);
|
||||
setHomeowners(hoRes.data.homeowners || hoRes.data || []);
|
||||
}).catch(console.error).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function applyFilters() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.userId) params.set('userId', filters.userId);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.homeownerId) params.set('homeownerId', filters.homeownerId);
|
||||
if (filters.startDate) params.set('from', filters.startDate);
|
||||
if (filters.endDate) params.set('to', filters.endDate);
|
||||
const res = await api.get(`/admin/timesheets?${params}`);
|
||||
setTimesheets(res.data.timesheets || res.data || []);
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function viewDetail(ts) {
|
||||
setSelectedTs(ts);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/admin/timesheets/${ts.id}`);
|
||||
setDetail(res.data.timesheet || res.data);
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setDetailLoading(false); }
|
||||
}
|
||||
|
||||
function parseDateLocal(str) {
|
||||
if (!str) return new Date();
|
||||
const [y, m, d] = str.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
const fmtDate = (str) => {
|
||||
if (!str) return '';
|
||||
const d = parseDateLocal(str);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Filter timesheets client-side by date range too
|
||||
const filtered = timesheets.filter((ts) => {
|
||||
if (filters.startDate && ts.weekStart < filters.startDate) return false;
|
||||
if (filters.endDate && ts.weekEnd > filters.endDate) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Summary stats
|
||||
const totalHours = filtered.reduce((s, t) => s + (parseFloat(t.totalHours) || 0), 0);
|
||||
const pending = filtered.filter((t) => t.status === 'submitted').length;
|
||||
const approved = filtered.filter((t) => t.status === 'approved').length;
|
||||
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-sky-50 dark:bg-sky-500/10 rounded-xl p-3 text-center">
|
||||
<div className="text-xl font-bold text-sky-600 dark:text-sky-400">{totalHours.toFixed(1)}</div>
|
||||
<div className="text-[10px] text-sky-500/70 font-medium">TOTAL HOURS</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 dark:bg-amber-500/10 rounded-xl p-3 text-center">
|
||||
<div className="text-xl font-bold text-amber-600 dark:text-amber-400">{pending}</div>
|
||||
<div className="text-[10px] text-amber-500/70 font-medium">PENDING</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 dark:bg-emerald-500/10 rounded-xl p-3 text-center">
|
||||
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{approved}</div>
|
||||
<div className="text-[10px] text-emerald-500/70 font-medium">APPROVED</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300">Filters</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select value={filters.userId} onChange={(e) => setFilters({ ...filters, userId: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||
<option value="">All Employees</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
<select value={filters.homeownerId} onChange={(e) => setFilters({ ...filters, homeownerId: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||
<option value="">All Homeowners</option>
|
||||
{homeowners.map((h) => <option key={h.id} value={h.id}>{h.name}{h.address ? ` #${h.address}` : ''}</option>)}
|
||||
</select>
|
||||
<select value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||
<option value="">All Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<div></div>
|
||||
<input type="date" value={filters.startDate} onChange={(e) => setFilters({ ...filters, startDate: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Start" />
|
||||
<input type="date" value={filters.endDate} onChange={(e) => setFilters({ ...filters, endDate: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="End" />
|
||||
</div>
|
||||
<button onClick={applyFilters} className="w-full py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 transition-all">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timesheet List */}
|
||||
<div className="space-y-2">
|
||||
{filtered.length === 0 && <p className="text-center py-6 text-gray-400 text-sm">No timesheets match filters</p>}
|
||||
{filtered.map((ts) => (
|
||||
<button key={ts.id} onClick={() => viewDetail(ts)} className="w-full text-left bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 hover:border-sky-300 dark:hover:border-sky-500/40 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{ts.user?.name || ts.userName || 'Unknown'}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{fmtDate(ts.weekStart)} — {fmtDate(ts.weekEnd)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<StatusBadge status={ts.status} />
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mt-1">
|
||||
{ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : '0h'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedTs && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4" onClick={() => { setSelectedTs(null); setDetail(null); }}>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-lg max-h-[80vh] overflow-y-auto p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{selectedTs.user?.name || 'Employee'}</h3>
|
||||
<button onClick={() => { setSelectedTs(null); setDetail(null); }} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={selectedTs.status} />
|
||||
<span className="text-sm text-gray-500">{fmtDate(selectedTs.weekStart)} — {fmtDate(selectedTs.weekEnd)}</span>
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="flex justify-center py-8"><Loader2 className="animate-spin text-sky-500" /></div>
|
||||
) : detail ? (
|
||||
<div className="space-y-3">
|
||||
{(detail.entries || []).length === 0 && <p className="text-gray-400 text-sm text-center py-4">No entries</p>}
|
||||
{(detail.entries || []).map((e) => {
|
||||
const d = e.date?.split('T')[0] || e.date;
|
||||
const [ey, em, ed] = (d || '').split('-').map(Number);
|
||||
const entryDate = new Date(ey, em - 1, ed);
|
||||
return (
|
||||
<div key={e.id} className="bg-gray-50 dark:bg-gray-800 rounded-xl p-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{e.homeownerName || 'Unknown'}</div>
|
||||
<div className="text-xs text-gray-500">{entryDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-sky-600 dark:text-sky-400">{e.hoursWorked}h</span>
|
||||
</div>
|
||||
{e.workDescription && <p className="text-xs text-gray-500 mt-1">{e.workDescription}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 flex justify-between">
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Total</span>
|
||||
<span className="font-bold text-lg text-gray-900 dark:text-white">
|
||||
{(detail.entries || []).reduce((s, e) => s + (parseFloat(e.hoursWorked) || 0), 0).toFixed(1)}h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OvertimeReport() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
|
||||
useEffect(() => { loadOvertime(); }, []);
|
||||
|
||||
async function loadOvertime() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (dateRange.from) params.set('from', dateRange.from);
|
||||
if (dateRange.to) params.set('to', dateRange.to);
|
||||
const res = await api.get(`/admin/overtime?${params}`);
|
||||
setData(res.data);
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||
if (!data) return <p className="text-center py-10 text-gray-400">Failed to load</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Date filters */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||
<input type="date" value={dateRange.from} onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })} className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||
<input type="date" value={dateRange.to} onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })} className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<button onClick={loadOvertime} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600">Filter</button>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-sky-50 dark:bg-sky-500/10 rounded-xl p-3 text-center">
|
||||
<div className="text-xl font-bold text-sky-600 dark:text-sky-400">{data.totals.totalHours}</div>
|
||||
<div className="text-[10px] text-sky-500/70 font-medium">TOTAL HOURS</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 dark:bg-emerald-500/10 rounded-xl p-3 text-center">
|
||||
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{data.totals.regularHours}</div>
|
||||
<div className="text-[10px] text-emerald-500/70 font-medium">REGULAR</div>
|
||||
</div>
|
||||
<div className={`rounded-xl p-3 text-center ${data.totals.overtimeHours > 0 ? 'bg-amber-50 dark:bg-amber-500/10' : 'bg-gray-50 dark:bg-gray-800'}`}>
|
||||
<div className={`text-xl font-bold ${data.totals.overtimeHours > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400'}`}>{data.totals.overtimeHours}</div>
|
||||
<div className="text-[10px] text-amber-500/70 font-medium">OVERTIME</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employee list */}
|
||||
{data.employees.length === 0 && <p className="text-center py-6 text-gray-400 text-sm">No data for selected period</p>}
|
||||
{data.employees.map((emp) => (
|
||||
<div key={emp.user.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{emp.user.name}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{emp.summary.overtimeHours > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 px-2 py-1 rounded-lg">
|
||||
<AlertTriangle size={12} /> {emp.summary.overtimeHours}h OT
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">{emp.summary.totalHours}h</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Week breakdown */}
|
||||
<div className="space-y-1">
|
||||
{emp.weeks.map((w) => (
|
||||
<div key={w.week} className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Week of {w.week}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{w.regularHours}h reg</span>
|
||||
{w.overtimeHours > 0 && <span className="text-amber-500 font-medium">+{w.overtimeHours}h OT</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const [tab, setTab] = useState('reviews');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/admin/timesheets?status=submitted')
|
||||
.then((res) => setPendingCount((res.data.timesheets || res.data || []).length))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Admin</h1>
|
||||
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
<TabButton active={tab === 'reviews'} onClick={() => setTab('reviews')} icon={Clock} label="Reviews" count={pendingCount} />
|
||||
<TabButton active={tab === 'reports'} onClick={() => setTab('reports')} icon={Download} label="Reports" count={0} />
|
||||
<TabButton active={tab === 'overtime'} onClick={() => setTab('overtime')} icon={TrendingUp} label="Overtime" count={0} />
|
||||
<TabButton active={tab === 'users'} onClick={() => setTab('users')} icon={Users} label="Users" count={0} />
|
||||
<TabButton active={tab === 'homeowners'} onClick={() => setTab('homeowners')} icon={Home} label="Homeowners" count={0} />
|
||||
</div>
|
||||
|
||||
{tab === 'reviews' && <PendingReviews />}
|
||||
{tab === 'reports' && <Reports />}
|
||||
{tab === 'overtime' && <OvertimeReport />}
|
||||
{tab === 'users' && <ManageUsers />}
|
||||
{tab === 'homeowners' && <ManageHomeowners />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../api/client';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { Download, Loader2, Calendar, Clock } from 'lucide-react';
|
||||
|
||||
export default function History() {
|
||||
const [timesheets, setTimesheets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api.get('/timesheets/history');
|
||||
const data = res.data;
|
||||
setTimesheets(Array.isArray(data) ? data : (data.timesheets || []));
|
||||
} catch (err) {
|
||||
console.error('Failed to load history:', err);
|
||||
setTimesheets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function downloadPdf(id, weekStart) {
|
||||
try {
|
||||
const res = await api.get(`/timesheets/${id}/pdf`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `timesheet-${weekStart}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
alert('Failed to download PDF');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-20"><Loader2 size={32} className="animate-spin text-sky-500" /></div>;
|
||||
}
|
||||
|
||||
if (timesheets.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<Calendar size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">No timesheets yet</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Submit your first timesheet to see it here</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Timesheet History</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
{timesheets.map((ts) => {
|
||||
const [sy, sm, sd] = ts.weekStart.split('-').map(Number);
|
||||
const [ey, em, ed] = ts.weekEnd.split('-').map(Number);
|
||||
const start = new Date(sy, sm - 1, sd);
|
||||
const end = new Date(ey, em - 1, ed);
|
||||
const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<div key={ts.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<Clock size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{fmt(start)} — {fmt(end)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<StatusBadge status={ts.status} />
|
||||
{ts.totalHours && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{parseFloat(ts.totalHours).toFixed(1)}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(ts.status === 'submitted' || ts.status === 'approved') && (
|
||||
<button
|
||||
onClick={() => downloadPdf(ts.id, ts.weekStart)}
|
||||
className="p-2.5 rounded-xl text-gray-400 hover:text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-all"
|
||||
title="Download PDF"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-sky-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-sky-500 to-teal-400 shadow-lg shadow-sky-500/25 mb-4">
|
||||
<span className="text-2xl font-bold text-white">C</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Coastal Timesheet</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Sign in to track your hours</p>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl shadow-gray-200/50 dark:shadow-black/30 border border-gray-200/60 dark:border-gray-800/60 p-6 space-y-5">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 text-sm rounded-xl px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
placeholder="you@coastal.com"
|
||||
className="w-full px-4 py-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 pr-12 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all text-base"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-sky-500 to-sky-600 hover:from-sky-600 hover:to-sky-700 text-white font-semibold text-base shadow-lg shadow-sky-500/25 hover:shadow-sky-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? <><Loader2 size={18} className="animate-spin" /> Signing in...</> : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 dark:text-gray-600 mt-6">
|
||||
Coastal Contracting of FL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import api from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import WeekNavigator, { getWeekRange } from '../components/WeekNavigator';
|
||||
import DayCard from '../components/DayCard';
|
||||
import SaveIndicator from '../components/SaveIndicator';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { Send, Download, Mail, Loader2, Copy, TrendingUp } from 'lucide-react';
|
||||
|
||||
function toLocalDateStr(d) {
|
||||
// Format as YYYY-MM-DD in local timezone (avoid UTC shift)
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function getWeekDates(date) {
|
||||
const { start } = getWeekRange(date);
|
||||
const dates = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
dates.push(toLocalDateStr(d));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function formatWeekParam(date) {
|
||||
const { start } = getWeekRange(date);
|
||||
return toLocalDateStr(start);
|
||||
}
|
||||
|
||||
export default function Timesheet() {
|
||||
const { user } = useAuth();
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const [entries, setEntries] = useState({});
|
||||
const [homeowners, setHomeowners] = useState([]);
|
||||
const [timesheet, setTimesheet] = useState(null);
|
||||
const [saveStatus, setSaveStatus] = useState('saved');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [overtime, setOvertime] = useState(null);
|
||||
const saveTimer = useRef(null);
|
||||
const pendingChanges = useRef({});
|
||||
|
||||
const weekDates = getWeekDates(selectedDate);
|
||||
const weekParam = formatWeekParam(selectedDate);
|
||||
// rejected timesheets can be edited and resubmitted
|
||||
const timesheetId = timesheet?.id || timesheet?.timesheetId;
|
||||
const isLocked = timesheet?.status === 'submitted' || timesheet?.status === 'approved';
|
||||
|
||||
// Load data for current week
|
||||
const loadWeek = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [entriesRes, timesheetRes, homeownersRes] = await Promise.all([
|
||||
api.get(`/entries?week=${weekParam}`),
|
||||
api.get(`/timesheets?week=${weekParam}`).catch(() => ({ data: null })),
|
||||
api.get('/homeowners'),
|
||||
]);
|
||||
|
||||
// Organize entries by date
|
||||
const byDate = {};
|
||||
weekDates.forEach((d) => { byDate[d] = []; });
|
||||
(entriesRes.data.entries || entriesRes.data || []).forEach((e) => {
|
||||
const dk = e.date?.split('T')[0] || e.date;
|
||||
if (byDate[dk]) byDate[dk].push(e);
|
||||
});
|
||||
// Ensure at least one empty entry per day
|
||||
weekDates.forEach((d) => {
|
||||
if (byDate[d].length === 0) {
|
||||
byDate[d] = [{ id: `new-${d}-0`, date: d, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
||||
}
|
||||
});
|
||||
|
||||
setEntries(byDate);
|
||||
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
|
||||
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load week:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [weekParam]);
|
||||
|
||||
useEffect(() => { loadWeek(); }, [loadWeek]);
|
||||
|
||||
// Auto-save logic (debounced)
|
||||
// Only save when entry has all required fields
|
||||
function isEntryComplete(data) {
|
||||
return data.date && data.homeownerId && parseFloat(data.hoursWorked) > 0 && data.workDescription?.trim();
|
||||
}
|
||||
|
||||
const saveEntry = useCallback(async (entryId, data) => {
|
||||
// Don't save incomplete entries
|
||||
if (!isEntryComplete(data)) {
|
||||
setSaveStatus('saved'); // Reset indicator, not an error
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveStatus('saving');
|
||||
// Normalize data for API
|
||||
const payload = {
|
||||
date: data.date,
|
||||
homeownerId: data.homeownerId,
|
||||
hoursWorked: parseFloat(data.hoursWorked),
|
||||
workDescription: data.workDescription?.trim() || '',
|
||||
};
|
||||
|
||||
try {
|
||||
if (data._isNew || entryId.startsWith('new-')) {
|
||||
const res = await api.post('/entries', payload);
|
||||
// Replace temp ID with real ID
|
||||
setEntries((prev) => {
|
||||
const dk = data.date;
|
||||
return {
|
||||
...prev,
|
||||
[dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
await api.put(`/entries/${entryId}`, payload);
|
||||
}
|
||||
setSaveStatus('saved');
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
setSaveStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleEntryChange(entryId, updatedEntry) {
|
||||
const dk = updatedEntry.date;
|
||||
setEntries((prev) => ({
|
||||
...prev,
|
||||
[dk]: prev[dk].map((e) => (e.id === entryId ? updatedEntry : e)),
|
||||
}));
|
||||
|
||||
// Debounced save
|
||||
setSaveStatus('saving');
|
||||
pendingChanges.current[entryId] = updatedEntry;
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
Object.entries(pendingChanges.current).forEach(([id, data]) => {
|
||||
saveEntry(id, data);
|
||||
});
|
||||
pendingChanges.current = {};
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function handleAddEntry(date) {
|
||||
const newEntry = {
|
||||
id: `new-${date}-${Date.now()}`,
|
||||
date,
|
||||
homeownerId: '',
|
||||
hoursWorked: '',
|
||||
workDescription: '',
|
||||
_isNew: true,
|
||||
};
|
||||
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
|
||||
}
|
||||
|
||||
async function handleDeleteEntry(entryId) {
|
||||
try {
|
||||
if (!entryId.startsWith('new-')) {
|
||||
await api.delete(`/entries/${entryId}`);
|
||||
}
|
||||
setEntries((prev) => {
|
||||
const updated = {};
|
||||
Object.entries(prev).forEach(([dk, dayEntries]) => {
|
||||
const filtered = dayEntries.filter((e) => e.id !== entryId);
|
||||
updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
setSaveStatus('saved');
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch overtime for current week
|
||||
useEffect(() => {
|
||||
if (!weekParam) return;
|
||||
api.get(`/timesheets/overtime?week=${weekParam}`)
|
||||
.then((res) => setOvertime(res.data))
|
||||
.catch(() => setOvertime(null));
|
||||
}, [weekParam]);
|
||||
|
||||
async function handleCopyPrevWeek() {
|
||||
const prevMonday = new Date(weekDates[0] + 'T00:00:00');
|
||||
prevMonday.setDate(prevMonday.getDate() - 7);
|
||||
const fromWeek = toLocalDateStr(prevMonday);
|
||||
|
||||
if (!confirm(`Copy entries from week of ${fromWeek} to this week? This will replace any existing entries.`)) return;
|
||||
setCopying(true);
|
||||
try {
|
||||
const res = await api.post('/entries/copy-week', { fromWeek, toWeek: weekParam });
|
||||
alert(`Copied ${res.data.copied} entries!`);
|
||||
loadWeek();
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to copy week');
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Save any pending changes first
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
for (const [id, data] of Object.entries(pendingChanges.current)) {
|
||||
await saveEntry(id, data);
|
||||
}
|
||||
pendingChanges.current = {};
|
||||
|
||||
const res = await api.post('/timesheets/submit', { weekStart: weekParam });
|
||||
setTimesheet(res.data);
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to submit timesheet');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadPdf() {
|
||||
if (!timesheetId) return;
|
||||
setPdfLoading(true);
|
||||
try {
|
||||
const res = await api.get(`/timesheets/${timesheetId}/pdf`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `timesheet-${user.name}-${weekParam}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert('Failed to generate PDF');
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalHours = Object.values(entries)
|
||||
.flat()
|
||||
.reduce((sum, e) => sum + (parseFloat(e.hoursWorked) || 0), 0);
|
||||
|
||||
const today = toLocalDateStr(new Date());
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-sky-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Week Navigator + Status */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||
<WeekNavigator selectedDate={selectedDate} onDateChange={setSelectedDate} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={timesheet?.status || 'draft'} />
|
||||
<SaveIndicator status={saveStatus} />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{totalHours.toFixed(1)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">hours this week</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overtime indicator */}
|
||||
{overtime && overtime.overtimeHours > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
|
||||
<TrendingUp size={16} className="text-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
{overtime.overtimeHours.toFixed(1)}h overtime (over {overtime.threshold}h)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy previous week button */}
|
||||
{!isLocked && totalHours === 0 && (
|
||||
<button
|
||||
onClick={handleCopyPrevWeek}
|
||||
disabled={copying}
|
||||
className="w-full py-2.5 rounded-xl border-2 border-dashed border-sky-200 dark:border-sky-500/30 text-sky-600 dark:text-sky-400 text-sm font-medium flex items-center justify-center gap-2 hover:bg-sky-50 dark:hover:bg-sky-500/5 transition-all active:scale-[0.98]"
|
||||
>
|
||||
{copying ? <Loader2 size={16} className="animate-spin" /> : <Copy size={16} />}
|
||||
{copying ? 'Copying...' : 'Copy Previous Week'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day Cards */}
|
||||
<div className="space-y-3">
|
||||
{weekDates.map((date) => (
|
||||
<DayCard
|
||||
key={date}
|
||||
date={date}
|
||||
entries={entries[date] || []}
|
||||
homeowners={homeowners}
|
||||
onEntryChange={handleEntryChange}
|
||||
onAddEntry={handleAddEntry}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
disabled={isLocked}
|
||||
defaultExpanded={date === today}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||
{!isLocked ? (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || totalHours === 0}
|
||||
className="w-full py-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-semibold text-lg shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 active:scale-[0.98]"
|
||||
>
|
||||
{submitting ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
|
||||
{submitting ? 'Submitting...' : 'Submit Timesheet'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={pdfLoading}
|
||||
className="flex-1 py-3.5 rounded-xl bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-semibold flex items-center justify-center gap-2 hover:bg-sky-100 dark:hover:bg-sky-500/20 transition-all"
|
||||
>
|
||||
{pdfLoading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
|
||||
Download PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (timesheetId) {
|
||||
window.open(`mailto:?subject=Timesheet - ${user.name} - Week of ${weekParam}&body=Please find my timesheet attached.`);
|
||||
}
|
||||
}}
|
||||
className="flex-1 py-3.5 rounded-xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 font-semibold flex items-center justify-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
||||
>
|
||||
<Mail size={18} />
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timesheet?.status === 'rejected' && timesheet?.notes && (
|
||||
<div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl px-4 py-3">
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400">Rejected</p>
|
||||
<p className="text-sm text-red-500 dark:text-red-400/80 mt-1">{timesheet.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user