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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user