From a04ff2e143939cf2b8f0f453fff4b17367f54204 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Fri, 23 Jan 2026 01:28:53 +0000 Subject: [PATCH] Generated by Spark: Add shift pattern templates for recurring night/weekend schedules --- spark.meta.json | 2 +- src/App.tsx | 13 +- src/components/DetailedTimesheetEntry.tsx | 134 +++++- src/components/ShiftPatternManager.tsx | 537 ++++++++++++++++++++++ src/lib/types.ts | 24 + 5 files changed, 696 insertions(+), 14 deletions(-) create mode 100644 src/components/ShiftPatternManager.tsx diff --git a/spark.meta.json b/spark.meta.json index e64524a..a277bc5 100644 --- a/spark.meta.json +++ b/spark.meta.json @@ -1,4 +1,4 @@ { "templateVersion": 1, - "dbType": null + "dbType": "kv" } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 54ab26b..059ef85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,6 +72,7 @@ import { CreditNoteGenerator } from '@/components/CreditNoteGenerator' import { ShiftPremiumCalculator } from '@/components/ShiftPremiumCalculator' import { ContractValidator } from '@/components/ContractValidator' import { DetailedTimesheetEntry } from '@/components/DetailedTimesheetEntry' +import { ShiftPatternManager } from '@/components/ShiftPatternManager' import type { Timesheet, Invoice, @@ -88,7 +89,7 @@ import type { ShiftEntry } from '@/lib/types' -type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' +type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' function App() { const [currentView, setCurrentView] = useState('dashboard') @@ -548,6 +549,12 @@ function App() { active={currentView === 'rate-templates'} onClick={() => setCurrentView('rate-templates')} /> + } + label="Shift Patterns" + active={currentView === 'shift-patterns'} + onClick={() => setCurrentView('shift-patterns')} + /> } label="Email Templates" @@ -869,6 +876,10 @@ function App() { /> )} + {currentView === 'shift-patterns' && ( + + )} + {currentView === 'roadmap' && ( )} diff --git a/src/components/DetailedTimesheetEntry.tsx b/src/components/DetailedTimesheetEntry.tsx index b784290..5aac9b9 100644 --- a/src/components/DetailedTimesheetEntry.tsx +++ b/src/components/DetailedTimesheetEntry.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useKV } from '@github/spark/hooks' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -6,6 +7,8 @@ import { Label } from '@/components/ui/label' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { ScrollArea } from '@/components/ui/scroll-area' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' import { Plus, Trash, @@ -15,11 +18,12 @@ import { Sun, SunHorizon, Calendar, - CurrencyDollar + CurrencyDollar, + Lightning } from '@phosphor-icons/react' import { toast } from 'sonner' import { ShiftDetailDialog } from './ShiftDetailDialog' -import type { ShiftEntry } from '@/lib/types' +import type { ShiftEntry, ShiftPatternTemplate, DayOfWeek } from '@/lib/types' interface DetailedTimesheetEntryProps { onSubmit: (data: { @@ -42,6 +46,8 @@ export function DetailedTimesheetEntry({ onSubmit }: DetailedTimesheetEntryProps const [weekEnding, setWeekEnding] = useState('') const [baseRate, setBaseRate] = useState('25.00') const [shifts, setShifts] = useState([]) + const [patterns = []] = useKV('shift-patterns', []) + const [selectedPattern, setSelectedPattern] = useState('') const handleAddShift = (shiftData: Omit) => { const newShift: ShiftEntry = { @@ -78,6 +84,82 @@ export function DetailedTimesheetEntry({ onSubmit }: DetailedTimesheetEntryProps setIsShiftDialogOpen(true) } + const applyShiftPattern = () => { + if (!selectedPattern || !weekEnding) { + toast.error('Please select a pattern and set the week ending date') + return + } + + const pattern = patterns.find(p => p.id === selectedPattern) + if (!pattern) return + + const weekEndDate = new Date(weekEnding) + const generatedShifts: ShiftEntry[] = [] + + const calculateHours = (startTime: string, endTime: string, breakMinutes: number): number => { + const [startHour, startMin] = startTime.split(':').map(Number) + const [endHour, endMin] = endTime.split(':').map(Number) + + let totalMinutes = (endHour * 60 + endMin) - (startHour * 60 + startMin) + + if (totalMinutes < 0) { + totalMinutes += 24 * 60 + } + + totalMinutes -= breakMinutes + + return totalMinutes / 60 + } + + const dayMap: Record = { + 'sunday': 0, + 'monday': 1, + 'tuesday': 2, + 'wednesday': 3, + 'thursday': 4, + 'friday': 5, + 'saturday': 6 + } + + pattern.daysOfWeek.forEach(dayOfWeek => { + const targetDayIndex = dayMap[dayOfWeek] + const weekEndDayIndex = weekEndDate.getDay() + + let daysBack = weekEndDayIndex - targetDayIndex + if (daysBack < 0) daysBack += 7 + + const shiftDate = new Date(weekEndDate) + shiftDate.setDate(shiftDate.getDate() - daysBack) + + const hours = calculateHours(pattern.defaultStartTime, pattern.defaultEndTime, pattern.defaultBreakMinutes) + const rate = parseFloat(baseRate) * pattern.rateMultiplier + + const shift: ShiftEntry = { + id: `shift-${Date.now()}-${Math.random()}`, + date: shiftDate.toISOString().split('T')[0], + dayOfWeek, + shiftType: pattern.shiftType, + startTime: pattern.defaultStartTime, + endTime: pattern.defaultEndTime, + breakMinutes: pattern.defaultBreakMinutes, + hours, + rate, + rateMultiplier: pattern.rateMultiplier, + amount: hours * rate, + notes: `Applied from pattern: ${pattern.name}` + } + + generatedShifts.push(shift) + }) + + setShifts(prev => [...prev, ...generatedShifts].sort((a, b) => + new Date(a.date + 'T' + a.startTime).getTime() - new Date(b.date + 'T' + b.startTime).getTime() + )) + + toast.success(`Applied ${generatedShifts.length} shifts from pattern "${pattern.name}"`) + setSelectedPattern('') + } + const handleSubmit = () => { if (!workerName || !clientName || !weekEnding) { toast.error('Please fill in worker, client, and week ending') @@ -195,16 +277,44 @@ export function DetailedTimesheetEntry({ onSubmit }: DetailedTimesheetEntryProps
- +
+ {patterns.length > 0 && ( + <> + + + + + )} + +
diff --git a/src/components/ShiftPatternManager.tsx b/src/components/ShiftPatternManager.tsx new file mode 100644 index 0000000..fbc3144 --- /dev/null +++ b/src/components/ShiftPatternManager.tsx @@ -0,0 +1,537 @@ +import { useState } from 'react' +import { useKV } from '@github/spark/hooks' +import { + Clock, + Plus, + Trash, + Copy, + Moon, + Sun, + CalendarBlank, + CheckCircle, + PencilSimple +} from '@phosphor-icons/react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import type { ShiftPatternTemplate, ShiftType, DayOfWeek, RecurrencePattern } from '@/lib/types' + +const DAYS_OF_WEEK: { value: DayOfWeek; label: string }[] = [ + { value: 'monday', label: 'Monday' }, + { value: 'tuesday', label: 'Tuesday' }, + { value: 'wednesday', label: 'Wednesday' }, + { value: 'thursday', label: 'Thursday' }, + { value: 'friday', label: 'Friday' }, + { value: 'saturday', label: 'Saturday' }, + { value: 'sunday', label: 'Sunday' } +] + +const SHIFT_TYPES: { value: ShiftType; label: string; icon: any; color: string }[] = [ + { value: 'night', label: 'Night Shift', icon: Moon, color: 'bg-purple-500/10 text-purple-500 border-purple-500/20' }, + { value: 'weekend', label: 'Weekend', icon: CalendarBlank, color: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }, + { value: 'evening', label: 'Evening', icon: Sun, color: 'bg-orange-500/10 text-orange-500 border-orange-500/20' }, + { value: 'early-morning', label: 'Early Morning', icon: Sun, color: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20' }, + { value: 'standard', label: 'Standard', icon: Clock, color: 'bg-muted text-muted-foreground border-border' }, + { value: 'overtime', label: 'Overtime', icon: Clock, color: 'bg-amber-500/10 text-amber-500 border-amber-500/20' }, + { value: 'holiday', label: 'Holiday', icon: CalendarBlank, color: 'bg-red-500/10 text-red-500 border-red-500/20' }, + { value: 'split-shift', label: 'Split Shift', icon: Clock, color: 'bg-indigo-500/10 text-indigo-500 border-indigo-500/20' } +] + +export function ShiftPatternManager() { + const [patterns = [], setPatterns] = useKV('shift-patterns', []) + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [editingPattern, setEditingPattern] = useState(null) + const [formData, setFormData] = useState>({ + name: '', + description: '', + shiftType: 'night', + isRecurring: true, + defaultStartTime: '22:00', + defaultEndTime: '06:00', + defaultBreakMinutes: 30, + daysOfWeek: [], + rateMultiplier: 1.0 + }) + + const handleCreatePattern = () => { + if (!formData.name || !formData.shiftType || !formData.daysOfWeek || formData.daysOfWeek.length === 0) { + toast.error('Please fill in all required fields') + return + } + + const newPattern: ShiftPatternTemplate = { + id: `SP-${Date.now()}`, + name: formData.name, + description: formData.description || '', + shiftType: formData.shiftType as ShiftType, + isRecurring: formData.isRecurring ?? true, + defaultStartTime: formData.defaultStartTime || '09:00', + defaultEndTime: formData.defaultEndTime || '17:00', + defaultBreakMinutes: formData.defaultBreakMinutes || 30, + daysOfWeek: formData.daysOfWeek as DayOfWeek[], + rateMultiplier: formData.rateMultiplier || 1.0, + createdDate: new Date().toISOString(), + usageCount: 0, + recurrencePattern: formData.isRecurring ? { + frequency: 'weekly' + } : undefined + } + + setPatterns(current => [...(current || []), newPattern]) + toast.success('Shift pattern template created') + resetForm() + setIsCreateDialogOpen(false) + } + + const handleUpdatePattern = () => { + if (!editingPattern || !formData.name || !formData.shiftType || !formData.daysOfWeek || formData.daysOfWeek.length === 0) { + toast.error('Please fill in all required fields') + return + } + + setPatterns(current => { + if (!current) return [] + return current.map(p => + p.id === editingPattern.id + ? { + ...p, + name: formData.name!, + description: formData.description || '', + shiftType: formData.shiftType as ShiftType, + isRecurring: formData.isRecurring ?? true, + defaultStartTime: formData.defaultStartTime || '09:00', + defaultEndTime: formData.defaultEndTime || '17:00', + defaultBreakMinutes: formData.defaultBreakMinutes || 30, + daysOfWeek: formData.daysOfWeek as DayOfWeek[], + rateMultiplier: formData.rateMultiplier || 1.0, + recurrencePattern: formData.isRecurring ? (p.recurrencePattern || { frequency: 'weekly' }) : undefined + } + : p + ) + }) + toast.success('Shift pattern template updated') + resetForm() + setEditingPattern(null) + } + + const handleDeletePattern = (id: string) => { + setPatterns(current => { + if (!current) return [] + return current.filter(p => p.id !== id) + }) + toast.success('Shift pattern template deleted') + } + + const handleDuplicatePattern = (pattern: ShiftPatternTemplate) => { + const duplicated: ShiftPatternTemplate = { + ...pattern, + id: `SP-${Date.now()}`, + name: `${pattern.name} (Copy)`, + createdDate: new Date().toISOString(), + usageCount: 0 + } + setPatterns(current => [...(current || []), duplicated]) + toast.success('Shift pattern template duplicated') + } + + const handleEditPattern = (pattern: ShiftPatternTemplate) => { + setEditingPattern(pattern) + setFormData({ + name: pattern.name, + description: pattern.description, + shiftType: pattern.shiftType, + isRecurring: pattern.isRecurring, + defaultStartTime: pattern.defaultStartTime, + defaultEndTime: pattern.defaultEndTime, + defaultBreakMinutes: pattern.defaultBreakMinutes, + daysOfWeek: pattern.daysOfWeek, + rateMultiplier: pattern.rateMultiplier + }) + } + + const resetForm = () => { + setFormData({ + name: '', + description: '', + shiftType: 'night', + isRecurring: true, + defaultStartTime: '22:00', + defaultEndTime: '06:00', + defaultBreakMinutes: 30, + daysOfWeek: [], + rateMultiplier: 1.0 + }) + } + + const toggleDayOfWeek = (day: DayOfWeek) => { + setFormData(prev => { + const currentDays = prev.daysOfWeek || [] + const newDays = currentDays.includes(day) + ? currentDays.filter(d => d !== day) + : [...currentDays, day] + return { ...prev, daysOfWeek: newDays } + }) + } + + const getShiftTypeConfig = (type: ShiftType) => { + return SHIFT_TYPES.find(st => st.value === type) || SHIFT_TYPES[0] + } + + const calculateHours = (startTime: string, endTime: string, breakMinutes: number): number => { + const [startHour, startMin] = startTime.split(':').map(Number) + const [endHour, endMin] = endTime.split(':').map(Number) + + let totalMinutes = (endHour * 60 + endMin) - (startHour * 60 + startMin) + + if (totalMinutes < 0) { + totalMinutes += 24 * 60 + } + + totalMinutes -= breakMinutes + + return totalMinutes / 60 + } + + return ( +
+
+
+

Shift Pattern Templates

+

Create reusable templates for recurring shift schedules

+
+ { + if (!open) { + setIsCreateDialogOpen(false) + setEditingPattern(null) + resetForm() + } else { + setIsCreateDialogOpen(true) + } + }} + > + + + + + + {editingPattern ? 'Edit' : 'Create'} Shift Pattern Template + + Define a reusable template for recurring shift schedules + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+ +
+ +