v2.0.0: Complete rewrite — React + Express + PostgreSQL + Docker

BREAKING: Full rewrite from static HTML to production-grade stack.

Features:
- React 18 + Vite + Tailwind CSS (mobile-first)
- Express + Prisma + PostgreSQL backend
- JWT authentication with role-based access
- Weekly Mon-Sun timesheets with auto-save
- Multiple homeowner entries per day
- Submit → Approve/Reject workflow
- Server-side PDF generation
- SMTP email integration
- Admin panel with reporting & filters
- Dark mode (system-aware)
- Docker Compose one-command deploy
- Non-root containers, Helmet, bcrypt, Zod validation
This commit is contained in:
BizzleBot
2026-02-15 20:16:46 +00:00
parent 68832bd958
commit a7c138add1
88 changed files with 5522 additions and 9159 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌊</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1" />
<meta name="theme-color" content="#0ea5e9" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Coastal Timesheet</title>
</head>
<body class="bg-gray-50 dark:bg-gray-950 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "coastal-timesheet-v2",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+129
View File
@@ -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 };
+90
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
}
+124
View File
@@ -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
</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>
);
}
+49
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+86
View File
@@ -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 };
+79
View File
@@ -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;
+56
View File
@@ -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 };
}
+37
View File
@@ -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 };
}
+38
View File
@@ -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 };
}
+244
View File
@@ -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%;
}
+47
View File
@@ -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>
);
+480
View File
@@ -0,0 +1,480 @@
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 } 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);
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);
}
}
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">
{timesheets.map((ts) => {
const start = new Date(ts.weekStart);
const end = new Date(ts.weekEnd);
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>
<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 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 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>
<span className={`text-xs font-medium px-2.5 py-1 rounded-full ${
u.role === 'super_admin' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400' :
u.role === 'admin' ? 'bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400' :
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
}`}>
{u.role}
</span>
</div>
))}
</div>
);
}
function ManageHomeowners() {
const [homeowners, setHomeowners] = useState([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [adding, setAdding] = useState(false);
useEffect(() => { loadHomeowners(); }, []);
async function loadHomeowners() {
try {
const res = await api.get('/admin/homeowners');
setHomeowners(res.data.homeowners || res.data || []);
} catch (err) { console.error(err); }
finally { setLoading(false); }
}
async function addHomeowner(e) {
e.preventDefault();
if (!newName.trim()) return;
setAdding(true);
try {
await api.post('/admin/homeowners', { name: newName.trim() });
setNewName('');
loadHomeowners();
} catch (err) {
alert(err.response?.data?.error || 'Failed to add');
} finally {
setAdding(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">
<form onSubmit={addHomeowner} className="flex gap-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New homeowner name..."
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 type="submit" disabled={adding || !newName.trim()} className="px-4 py-2.5 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50 flex items-center gap-1.5">
<Plus size={16} /> Add
</button>
</form>
<div className="space-y-1">
{homeowners.map((h) => (
<div key={h.id} className="flex items-center justify-between px-4 py-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
<span className="text-sm text-gray-900 dark:text-white">{h.name}</span>
<span className={`text-xs ${h.isActive !== false ? 'text-emerald-500' : 'text-gray-400'}`}>
{h.isActive !== false ? 'Active' : 'Inactive'}
</span>
</div>
))}
</div>
</div>
);
}
function Reports() {
const [users, setUsers] = useState([]);
const [timesheets, setTimesheets] = useState([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({ userId: '', status: '', startDate: '', endDate: '' });
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'),
]).then(([usersRes, tsRes]) => {
setUsers(usersRes.data.users || usersRes.data || []);
setTimesheets(tsRes.data.timesheets || tsRes.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);
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.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>
<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>
);
}
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 === '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 === 'users' && <ManageUsers />}
{tab === 'homeowners' && <ManageHomeowners />}
</div>
);
}
+101
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+311
View File
@@ -0,0 +1,311 @@
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 } 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 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);
}
}
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>
</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>
);
}
+88
View File
@@ -0,0 +1,88 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
ocean: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
coastal: {
50: '#f0fdfa',
100: '#ccfbf1',
200: '#99f6e4',
300: '#5eead4',
400: '#2dd4bf',
500: '#14b8a6',
600: '#0d9488',
700: '#0f766e',
800: '#115e59',
900: '#134e4a',
},
},
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'SF Pro Display',
'SF Pro Text',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
borderRadius: {
'2xl': '1rem',
'3xl': '1.5rem',
},
boxShadow: {
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
'soft-lg': '0 10px 40px -10px rgba(0, 0, 0, 0.1), 0 2px 10px -2px rgba(0, 0, 0, 0.04)',
'inner-soft': 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.04)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideDown: {
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
pulseSoft: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.5' },
},
},
},
},
plugins: [require('@tailwindcss/forms')],
};
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});