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:
BizzleBot
2026-02-16 10:01:39 +00:00
commit f718a76153
65 changed files with 6756 additions and 0 deletions
+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 };
}