Initial commit: Coastal Timesheet App with comprehensive features

- Employee name validation for all submissions
- Complete entry validation (all fields required when any field has input)
- Dynamic auto-resizing work description fields
- Optimized field layout (Homeowner → Hours → Work Description)
- Professional PDF generation with validation
- Email/share functionality with validation
- Data import/export capabilities
- Dark/light theme support
- Mobile-responsive design
- Local data persistence
- Multiple entries per day support
- Custom homeowner management
- Comprehensive README with deployment instructions
- Production build ready for web hosting
This commit is contained in:
Bizzle
2025-08-19 08:21:20 -04:00
commit b5e7c8796f
35 changed files with 9207 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Production build
dist
build
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode
.idea
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker files (to avoid infinite recursion)
Dockerfile*
docker-compose*
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# ESLint cache
.eslintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Temporary folders
tmp/
temp/
+88
View File
@@ -0,0 +1,88 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Production build
dist/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# Temporary files
*.tmp
*.temp
# Production zip file (optional - you might want to include this)
coastal-timesheet-production.zip
+84
View File
@@ -0,0 +1,84 @@
# Docker Deployment Guide
## Production Deployment
### Quick Start
```bash
# Build and run the production container
docker-compose up -d
# Access the app at http://localhost:8080
```
### Manual Build
```bash
# Build the Docker image
docker build -t coastal-timesheet .
# Run the container
docker run -d -p 8080:80 --name coastal-timesheet coastal-timesheet
```
## Development with Docker
### Development Server
```bash
# Run development environment
docker-compose -f docker-compose.dev.yml up
# Access the app at http://localhost:5173
```
## Docker Commands
### Production
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
# View logs
docker-compose logs -f
# Rebuild after changes
docker-compose up -d --build
```
### Development
```bash
# Start dev environment
docker-compose -f docker-compose.dev.yml up
# Stop dev environment
docker-compose -f docker-compose.dev.yml down
# Rebuild dev container
docker-compose -f docker-compose.dev.yml up --build
```
## Configuration
### Environment Variables
- `NODE_ENV`: Set to "production" for production builds
- Port mapping can be changed in docker-compose.yml
### Nginx Configuration
The production container uses nginx to serve static files with:
- Gzip compression enabled
- Client-side routing support (SPA)
- Static asset caching
- Security headers
### Ports
- **Production**: http://localhost:8080
- **Development**: http://localhost:5173
## Architecture
The production setup uses a multi-stage build:
1. **Build stage**: Compiles the React app using Node.js
2. **Production stage**: Serves static files using nginx
This results in a lightweight production image (~25MB) that only contains the compiled app and nginx.
+32
View File
@@ -0,0 +1,32 @@
# Use Node.js 18 alpine as base image
FROM node:18-alpine as build
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage - use nginx to serve static files
FROM nginx:alpine
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+20
View File
@@ -0,0 +1,20 @@
# Development Dockerfile
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including dev dependencies)
RUN npm install
# Copy source code
COPY . .
# Expose development port
EXPOSE 5173
# Start development server
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+189
View File
@@ -0,0 +1,189 @@
# Coastal Contracting Timesheet App
A modern, responsive timesheet application built with React for tracking work hours and generating professional PDF timesheets. Features comprehensive validation, dynamic field layouts, and seamless data management.
![Coastal Timesheet App Screenshot](./screenshot.png)
*Screenshot showing the timesheet interface with validation, dynamic fields, and professional layout*
## ✨ Features
### 📊 **Time Tracking & Management**
- **Weekly Time Tracking**: Track work hours for each day of the week with multiple entries per day
- **Dynamic Date Navigation**: Navigate between weeks with intuitive date picker controls
- **Flexible Entry Management**: Add, remove, and modify time entries as needed
- **Auto-saving**: Automatically saves all data to browser localStorage
### 🏠 **Homeowner Management**
- **Pre-loaded Homeowners**: Comprehensive list of default homeowner names and addresses
- **Custom Homeowners**: Add and save custom homeowner names that persist across sessions
- **Smart Dropdown**: Searchable dropdown with autocomplete functionality
### ✅ **Smart Validation System**
- **Required Employee Name**: Employee name must be entered before exporting timesheets
- **Complete Entry Validation**: When any field is filled, all three fields (homeowner, hours, work description) become required
- **Visual Feedback**: Red borders, asterisks, and warning messages guide users to complete entries
- **Export Protection**: PDF generation disabled until all validation requirements are met
### 📝 **Enhanced User Interface**
- **Dynamic Work Description Fields**: Auto-resizing text areas that expand to show all content without scrolling
- **Optimized Field Layout**: Homeowner → Hours → Work Description order with maximum space for descriptions
- **Today Highlighting**: Current day is visually highlighted for easy identification
- **Dark/Light Mode**: Toggle between themes with persistent user preference
### 📄 **Professional PDF Export**
- **Download PDF**: Generate and download professional timesheet PDFs
- **Email Integration**: Share PDFs via device share sheet or download with pre-filled email subject
- **Optimized Layout**: PDF layout matches UI with proper field sizing and professional formatting
- **Validation Integration**: Only complete, valid timesheets can be exported
### 💾 **Data Management**
- **Import/Export**: Backup and restore timesheet data with JSON export/import
- **Week-based Storage**: Each week's data stored separately for better organization
- **Custom Homeowner Persistence**: Added homeowners saved across all sessions
- **Cross-device Compatibility**: Works on desktop, tablet, and mobile devices
### 📱 **Responsive Design**
- **Mobile-first**: Optimized for touch interfaces and small screens
- **Adaptive Layouts**: Fields reorganize appropriately for different screen sizes
- **Touch-friendly**: Large buttons and touch targets for mobile users
## 🚀 Getting Started
### Prerequisites
- Node.js (version 16 or higher)
- npm or yarn
### Installation
1. Clone or download the project files
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
4. Open [http://localhost:5173](http://localhost:5173) in your browser
### Building for Production
```bash
npm run build
```
The built files will be in the `dist/` directory, ready for web hosting.
### Preview Production Build
```bash
npm run preview
```
## 📖 Usage Guide
### 1. **Setup**
- Enter your **Employee Name** (required for all exports)
- Select the week you want to track using the date picker
### 2. **Adding Time Entries**
- Click "Add Entry" for any day to create multiple entries
- Fill in all three fields for each entry:
- **Homeowner**: Select from dropdown or add custom names
- **Hours Worked**: Enter decimal hours (e.g., 2.5 for 2 hours 30 minutes)
- **Work Description**: Detailed description of work performed (auto-expanding field)
### 3. **Validation & Completion**
- **Red asterisks (*)** indicate required fields
- **Warning messages** appear for incomplete entries
- **Export buttons** are disabled until all requirements are met
### 4. **Export Options**
- **Download PDF**: Save a printable PDF to your device
- **Share/Email PDF**: Use device share functionality or download with email setup
- **Import/Export Data**: Backup/restore your timesheet data
### 5. **Additional Features**
- **Theme Toggle**: Switch between light and dark modes
- **Office Contact**: Download contact card for easy email setup
## 🗂️ Data Storage
- **Local Browser Storage**: All data stored locally, no external servers
- **Weekly Organization**: Each week stored as separate dataset
- **Persistent Settings**: Theme preferences and custom homeowners preserved
- **Privacy-focused**: Your data never leaves your device
## 🛠️ Tech Stack
- **Frontend Framework**: React 18 with Vite
- **Styling**: Tailwind CSS with responsive design
- **PDF Generation**: @react-pdf/renderer for professional documents
- **Icons**: Lucide React icon library
- **Storage**: Browser localStorage API
- **Build Tool**: Vite for fast development and optimized builds
## 📁 Project Structure
```
src/
├── components/ # React components
│ ├── TimeSheet.jsx # Main timesheet interface
│ ├── DayEntries.jsx # Day-specific entry management
│ ├── TimeEntryRow.jsx # Individual entry row (legacy)
│ ├── DatePicker.jsx # Week navigation
│ ├── HomeownerDropdown.jsx # Homeowner selection
│ ├── ThemeToggle.jsx # Dark/light mode toggle
│ ├── PDFExport.jsx # PDF download functionality
│ ├── EmailPDF.jsx # PDF sharing functionality
│ ├── TimesheetPDF.jsx # PDF document structure
│ ├── ImportExport.jsx # Data backup/restore
│ └── MiniCalendar.jsx # Calendar widget
├── hooks/ # Custom React hooks
│ ├── useTimeSheet.js # Timesheet state & validation
│ └── useTheme.js # Theme management
├── utils/ # Utility functions
│ └── dateUtils.js # Date manipulation helpers
├── App.jsx # Main application component
├── main.jsx # Application entry point
└── index.css # Global styles
```
## 🔧 Configuration Files
- `vite.config.js` - Vite build configuration
- `tailwind.config.js` - Tailwind CSS customization
- `postcss.config.js` - PostCSS processing
- `package.json` - Dependencies and scripts
## 🌐 Deployment
The built application is a static site that can be deployed to any web hosting service:
1. Run `npm run build`
2. Upload the contents of the `dist/` directory to your web server
3. Ensure your server serves `index.html` for all routes (SPA configuration)
Compatible with: Netlify, Vercel, GitHub Pages, traditional web hosting, and more.
## 📋 Features Summary
✅ **Employee name validation**
✅ **Complete entry validation (all fields required when any field has input)**
✅ **Dynamic auto-resizing work description fields**
✅ **Optimized field order (Homeowner → Hours → Work Description)**
✅ **Professional PDF generation with validation**
✅ **Email/share functionality**
✅ **Data import/export**
✅ **Dark/light theme support**
✅ **Mobile-responsive design**
✅ **Local data persistence**
✅ **Multiple entries per day**
✅ **Custom homeowner management**
---
*Built for Coastal Contracting of FL - Making timesheet management simple and professional.*
+23
View File
@@ -0,0 +1,23 @@
version: '3.8'
services:
coastal-timesheet-dev:
build:
context: .
dockerfile: Dockerfile.dev
container_name: coastal-timesheet-dev
ports:
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
stdin_open: true
tty: true
networks:
- timesheet-dev-network
networks:
timesheet-dev-network:
driver: bridge
+38
View File
@@ -0,0 +1,38 @@
version: '3.8'
services:
coastal-timesheet:
build:
context: .
dockerfile: Dockerfile
container_name: coastal-timesheet-app
ports:
- "8080:80"
environment:
- NODE_ENV=production
restart: unless-stopped
networks:
- timesheet-network
# Optional: Add a reverse proxy with SSL if needed
# nginx-proxy:
# image: nginx:alpine
# container_name: coastal-timesheet-proxy
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./proxy.conf:/etc/nginx/nginx.conf
# - ./ssl:/etc/nginx/ssl
# depends_on:
# - coastal-timesheet
# networks:
# - timesheet-network
networks:
timesheet-network:
driver: bridge
# Optional: Add volumes for persistent storage if needed in future
# volumes:
# timesheet-data:
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Coastal Contracting Timesheet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing (SPA)
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
}
}
+6654
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "coastal-timesheet",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@react-pdf/renderer": "^4.3.0",
"autoprefixer": "^10.4.21",
"lucide-react": "^0.539.0",
"postcss": "^8.5.6",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"tailwindcss": "^3.4.17",
"vite": "^5.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react';
import TimeSheet from './components/TimeSheet';
import Help from './Help';
import ThemeToggle from './components/ThemeToggle';
import MiniCalendar from './components/MiniCalendar';
import { useTheme } from './hooks/useTheme';
import { useTimeSheet } from './hooks/useTimeSheet';
import { Calendar } from 'lucide-react';
function App() {
const { isDark, toggleTheme } = useTheme();
const timesheetData = useTimeSheet();
const [showMobileCalendar, setShowMobileCalendar] = useState(false);
const [showHelp, setShowHelp] = useState(false);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
{/* Top right controls */}
<div className="fixed top-4 right-4 z-20 flex flex-col items-end gap-4 pointer-events-none">
<div className="flex items-start gap-4">
{/* Desktop calendar - always visible on large screens */}
<div className="hidden lg:block pointer-events-auto">
<MiniCalendar
selectedDate={timesheetData.selectedDate}
onDateChange={timesheetData.setSelectedDate}
/>
</div>
{/* Mobile calendar toggle */}
<button
onClick={() => setShowMobileCalendar(!showMobileCalendar)}
className="lg:hidden p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors pointer-events-auto"
aria-label="Toggle calendar"
>
<Calendar className="h-5 w-5 text-gray-700 dark:text-gray-300" />
</button>
<div className="pointer-events-auto">
<ThemeToggle isDark={isDark} toggleTheme={toggleTheme} />
</div>
<button
onClick={() => setShowHelp(true)}
className="pointer-events-auto px-3 py-2 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-medium"
>
Help
</button>
</div>
{/* Mobile calendar dropdown moved to content flow */}
</div>
<div className="container mx-auto py-8 lg:pr-80 px-4">
{showHelp ? (
<Help onBack={() => setShowHelp(false)} />
) : (
<>
{showMobileCalendar && (
<div className="lg:hidden mb-4">
<MiniCalendar
selectedDate={timesheetData.selectedDate}
onDateChange={(date) => {
timesheetData.setSelectedDate(date);
setShowMobileCalendar(false);
}}
/>
</div>
)}
<TimeSheet timesheetData={timesheetData} />
</>
)}
</div>
</div>
);
}
export default App;
+90
View File
@@ -0,0 +1,90 @@
import { ArrowLeft, Smartphone, Monitor, FileText, Share2, Upload } from 'lucide-react';
const Section = ({ title, children }) => (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 space-y-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
<div className="text-sm text-gray-700 dark:text-gray-300 space-y-2">{children}</div>
</div>
);
export default function Help({ onBack }) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Troubleshooting & How-To</h1>
<button onClick={onBack} className="flex items-center gap-2 px-3 py-2 rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 hover:bg-gray-300 dark:hover:bg-gray-600">
<ArrowLeft className="h-4 w-4" /> Back
</button>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<div className="flex items-center gap-2 font-medium text-blue-800 dark:text-blue-200"><Smartphone className="h-4 w-4"/> iPhone/iPad</div>
<ul className="list-disc ml-5 mt-2 text-sm text-blue-900 dark:text-blue-100 space-y-1">
<li>Use Share/Email PDF to attach via Share Sheet</li>
<li>If To isn’t filled, import the Office contact first</li>
<li>Find downloads in Files → Downloads</li>
</ul>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg">
<div className="flex items-center gap-2 font-medium text-green-800 dark:text-green-200">Android</div>
<ul className="list-disc ml-5 mt-2 text-sm text-green-900 dark:text-green-100 space-y-1">
<li>Pick Gmail or Email from the Share Sheet</li>
<li>Downloads are in Files/Downloads app</li>
<li>Add the Office contact to speed up addressing</li>
</ul>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
<div className="flex items-center gap-2 font-medium text-purple-800 dark:text-purple-200"><Monitor className="h-4 w-4"/> Windows/Mac</div>
<ul className="list-disc ml-5 mt-2 text-sm text-purple-900 dark:text-purple-100 space-y-1">
<li>Use Download PDF then attach in Outlook/Mail</li>
<li>Export JSON to save/restore your timesheet</li>
<li>Subject should be: “Name - WeekRange Timesheet”</li>
</ul>
</div>
</div>
<Section title="Common actions">
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium"><FileText className="h-4 w-4"/> Download PDF</div>
<ol className="list-decimal ml-5 space-y-1">
<li>Tap Download PDF</li>
<li>Open your Downloads folder</li>
<li>Attach PDF to email, send to Office@CoastalContractingFL.com</li>
</ol>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium"><Share2 className="h-4 w-4"/> Share/Email PDF (mobile)</div>
<ol className="list-decimal ml-5 space-y-1">
<li>Tap Share/Email PDF</li>
<li>Choose Mail/Gmail from the sheet</li>
<li>Subject prefilled; select Office contact if needed</li>
</ol>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium"><Upload className="h-4 w-4"/> Export / Import</div>
<ol className="list-decimal ml-5 space-y-1">
<li>Export saves a JSON backup</li>
<li>Import restores a saved JSON file</li>
<li>Useful when switching devices or browsers</li>
</ol>
</div>
</div>
</Section>
<Section title="If something doesn’t work">
<ul className="list-disc ml-5 space-y-1">
<li>Try another browser (Safari/Chrome/Edge)</li>
<li>Ensure pop-ups/downloads are allowed</li>
<li>Check your device storage isn’t full</li>
<li>Manually email to Office@CoastalContractingFL.com with subject “Name - WeekRange Timesheet”</li>
</ul>
</Section>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
import { formatWeekRange } from '../utils/dateUtils';
const DatePicker = ({ selectedDate, onDateChange, weekDays }) => {
const navigateWeek = (direction) => {
const newDate = new Date(selectedDate);
newDate.setDate(newDate.getDate() + (direction * 7));
onDateChange(newDate);
};
const goToToday = () => {
onDateChange(new Date());
};
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 bg-white dark:bg-gray-800 p-3 sm:p-4 rounded-lg shadow-md w-full">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
Week of:
</span>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<button
onClick={() => navigateWeek(-1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300"
aria-label="Previous week"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="font-semibold text-gray-900 dark:text-white text-sm sm:text-base text-center sm:text-left break-words">
{formatWeekRange(weekDays)}
</span>
<button
onClick={() => navigateWeek(1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300"
aria-label="Next week"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<button
onClick={goToToday}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Today
</button>
</div>
);
};
export default DatePicker;
+182
View File
@@ -0,0 +1,182 @@
import { Plus, Trash2 } from 'lucide-react';
import HomeownerDropdown from './HomeownerDropdown';
import { getDayName } from '../utils/dateUtils';
import { useEffect, useRef } from 'react';
const SingleEntry = ({
entry,
onUpdate,
onRemove,
homeowners,
onAddCustomHomeowner,
canRemove,
getEntryValidation
}) => {
const textareaRef = useRef(null);
const handleChange = (field, value) => {
onUpdate(field, value);
};
const adjustTextareaHeight = (textarea) => {
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.max(34, textarea.scrollHeight) + 'px';
}
};
useEffect(() => {
adjustTextareaHeight(textareaRef.current);
}, [entry.workDescription]);
const validation = getEntryValidation ? getEntryValidation(entry) : { isValid: true, isPartial: false, missingFields: {} };
const getFieldClassName = (baseClassName, fieldName) => {
if (!validation.isPartial) return baseClassName;
const isMissing = validation.missingFields[fieldName];
if (isMissing) {
return `${baseClassName} border-red-500 bg-red-50 dark:bg-red-900/20`;
}
return baseClassName;
};
return (
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 p-3 bg-white dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Homeowner {validation.isPartial && validation.missingFields.homeowner && <span className="text-red-500">*</span>}
</label>
<div className={validation.isPartial && validation.missingFields.homeowner ? 'border-2 border-red-500 rounded' : ''}>
<HomeownerDropdown
value={entry.homeowner}
onChange={(value) => handleChange('homeowner', value)}
homeowners={homeowners}
onAddCustom={onAddCustomHomeowner}
/>
</div>
</div>
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Hours Worked {validation.isPartial && validation.missingFields.hoursWorked && <span className="text-red-500">*</span>}
</label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={entry.hoursWorked}
onChange={(e) => handleChange('hoursWorked', e.target.value)}
placeholder="0.0"
className={getFieldClassName("px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white placeholder-gray-400", "hoursWorked")}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Work Description {validation.isPartial && validation.missingFields.workDescription && <span className="text-red-500">*</span>}
</label>
<textarea
ref={textareaRef}
value={entry.workDescription}
onChange={(e) => handleChange('workDescription', e.target.value)}
placeholder="Describe work performed..."
rows={1}
className={getFieldClassName("px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white placeholder-gray-400 resize-none overflow-hidden", "workDescription")}
style={{
minHeight: '34px',
height: 'auto'
}}
onInput={(e) => adjustTextareaHeight(e.target)}
/>
</div>
<div className="flex flex-col justify-end">
{canRemove && (
<button
onClick={onRemove}
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors self-start"
aria-label="Remove entry"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
);
};
const DayEntries = ({
date,
entries,
onUpdate,
onAdd,
onRemove,
homeowners,
onAddCustomHomeowner,
getEntryValidation
}) => {
const isToday = date.toDateString() === new Date().toDateString();
const dayHours = entries.reduce((total, entry) => total + (parseFloat(entry.hoursWorked) || 0), 0);
// Check if any entries are incomplete
const hasIncompleteEntries = getEntryValidation && entries.some(entry => {
const validation = getEntryValidation(entry);
return validation.isPartial;
});
return (
<div className={`p-4 rounded-lg border-2 transition-colors ${
isToday
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-400'
: 'border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800'
}`}>
{/* Day Header */}
<div className="flex justify-between items-center mb-3">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{getDayName(date)}
</h3>
<span className="text-sm text-gray-500 dark:text-gray-400">
{date.toLocaleDateString()} • {dayHours.toFixed(1)} hours
</span>
</div>
<button
onClick={() => onAdd(date)}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4" />
Add Entry
</button>
</div>
{/* Validation Warning */}
{hasIncompleteEntries && (
<div className="mb-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md">
<p className="text-sm text-amber-800 dark:text-amber-200">
<span className="font-medium">⚠️ Incomplete entries:</span> All fields (homeowner, hours, and work description) are required when any field has input.
</p>
</div>
)}
{/* Entries */}
<div className="space-y-2">
{entries.map((entry, index) => (
<SingleEntry
key={entry.id}
entry={entry}
onUpdate={(field, value) => onUpdate(date, entry.id, field, value)}
onRemove={() => onRemove(date, entry.id)}
homeowners={homeowners}
onAddCustomHomeowner={onAddCustomHomeowner}
canRemove={entries.length > 1}
getEntryValidation={getEntryValidation}
/>
))}
</div>
</div>
);
};
export default DayEntries;
+90
View File
@@ -0,0 +1,90 @@
import { pdf } from '@react-pdf/renderer';
import { Mail } from 'lucide-react';
import { formatWeekRange } from '../utils/dateUtils';
import TimesheetPDF from './TimesheetPDF';
const EmailPDF = ({ weekDays, timeEntries, userName, getEntryValidation }) => {
const handleEmail = async () => {
try {
const weekRange = formatWeekRange(weekDays);
const subject = `${userName || 'Employee'} - ${weekRange} Timesheet`;
// Generate PDF
const blob = await pdf(
<TimesheetPDF weekDays={weekDays} timeEntries={timeEntries} userName={userName} />
).toBlob();
const sanitizedRange = weekRange.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_');
const fileName = `Coastal_Timesheet_${sanitizedRange}.pdf`;
const file = new File([blob], fileName, { type: 'application/pdf' });
// Preferred: native share with attachment; some clients use 'title' as subject
if (navigator.canShare && navigator.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: subject, text: 'Timesheet attached.' });
return;
} catch (err) {
// fall through to fallback
}
}
// Fallback: download PDF and open mailto with subject only
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
const mailto = `mailto:?subject=${encodeURIComponent(subject)}`;
window.location.href = mailto;
} catch (error) {
console.error('Error preparing email with PDF:', error);
alert('Error preparing email. Please try again.');
}
};
const hasEntries = Object.values(timeEntries).some((dayEntries) =>
dayEntries.some((entry) => entry.workDescription || entry.hoursWorked || entry.homeowner)
);
// Check if there are any incomplete entries
const hasIncompleteEntries = getEntryValidation && Object.values(timeEntries).some(dayEntries =>
dayEntries.some(entry => {
const validation = getEntryValidation(entry);
return validation.isPartial;
})
);
const hasUserName = userName && userName.trim() !== '';
const canExport = hasEntries && !hasIncompleteEntries && hasUserName;
return (
<button
onClick={handleEmail}
disabled={!canExport}
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-colors ${
canExport
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
title={
!hasEntries
? 'No data to email'
: !hasUserName
? 'Employee name is required to email PDF'
: hasIncompleteEntries
? 'Complete all partially filled entries before emailing PDF'
: 'Attach PDF via share (if supported) or download it, and open email with subject pre-filled'
}
>
<Mail className="h-5 w-5" />
Share/Email PDF
</button>
);
};
export default EmailPDF;
+135
View File
@@ -0,0 +1,135 @@
import { useState } from 'react';
import { ChevronDown, Plus, Search } from 'lucide-react';
const HomeownerDropdown = ({ value, onChange, homeowners, onAddCustom }) => {
const [isOpen, setIsOpen] = useState(false);
const [customName, setCustomName] = useState('');
const [showCustomInput, setShowCustomInput] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const handleSelect = (homeowner) => {
onChange(homeowner);
setIsOpen(false);
setSearchTerm('');
};
const filteredHomeowners = homeowners.filter(homeowner =>
homeowner.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleOpen = () => {
setIsOpen(!isOpen);
if (!isOpen) {
setSearchTerm('');
}
};
const handleAddCustom = () => {
if (customName.trim()) {
onAddCustom(customName.trim());
onChange(customName.trim());
setCustomName('');
setShowCustomInput(false);
setIsOpen(false);
}
};
const toggleCustomInput = () => {
setShowCustomInput(!showCustomInput);
setCustomName('');
};
return (
<div className="relative">
<button
type="button"
onClick={handleOpen}
className="w-full px-3 py-2 text-left bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-white"
>
<span className="block truncate">
{value || 'Select homeowner...'}
</span>
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
</button>
{isOpen && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg">
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search homeowners..."
className="w-full pl-10 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-800 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-auto">
{filteredHomeowners.length > 0 ? (
filteredHomeowners.map((homeowner, index) => (
<button
key={index}
type="button"
onClick={() => handleSelect(homeowner)}
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-600 dark:text-white"
>
{homeowner}
</button>
))
) : (
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-sm">
No homeowners found
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600">
{!showCustomInput ? (
<button
type="button"
onClick={toggleCustomInput}
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-600 text-blue-600 dark:text-blue-400 flex items-center"
>
<Plus className="h-4 w-4 mr-2" />
Add Custom Name
</button>
) : (
<div className="p-3">
<input
type="text"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddCustom()}
placeholder="Enter custom name..."
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-800 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
autoFocus
/>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={handleAddCustom}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
Add
</button>
<button
type="button"
onClick={toggleCustomInput}
className="px-3 py-1 text-sm bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-400 dark:hover:bg-gray-500"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default HomeownerDropdown;
+164
View File
@@ -0,0 +1,164 @@
import { useRef } from 'react';
import { Download, Upload, Save, FolderOpen } from 'lucide-react';
import { formatWeekRange } from '../utils/dateUtils';
const ImportExport = ({ weekDays, timeEntries, customHomeowners, onImport }) => {
const fileInputRef = useRef(null);
const handleExport = () => {
try {
const exportData = {
version: '1.0',
exportDate: new Date().toISOString(),
weekRange: formatWeekRange(weekDays),
weekStart: weekDays[0].toISOString().split('T')[0],
weekEnd: weekDays[6].toISOString().split('T')[0],
timeEntries,
customHomeowners
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `Coastal_Timesheet_${formatWeekRange(weekDays).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_')}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
// Show success message
const notification = document.createElement('div');
notification.className = 'fixed top-4 left-1/2 transform -translate-x-1/2 bg-green-600 text-white px-4 py-2 rounded-md shadow-lg z-50';
notification.textContent = 'Timesheet exported successfully!';
document.body.appendChild(notification);
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, 3000);
} catch (error) {
console.error('Error exporting timesheet:', error);
alert('Error exporting timesheet. Please try again.');
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.type !== 'application/json') {
alert('Please select a valid JSON file.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const importData = JSON.parse(e.target.result);
// Validate the imported data structure
if (!importData.version || !importData.timeEntries) {
alert('Invalid timesheet file format.');
return;
}
// Validate data structure
const isValidStructure = Object.values(importData.timeEntries).every(dayEntries =>
Array.isArray(dayEntries) && dayEntries.every(entry =>
typeof entry === 'object' &&
'id' in entry &&
'workDescription' in entry &&
'hoursWorked' in entry &&
'homeowner' in entry
)
);
if (!isValidStructure) {
alert('Invalid timesheet data structure.');
return;
}
// Confirm import
const confirmImport = confirm(
`Import timesheet data from ${importData.weekRange || 'unknown week'}?\n\nThis will replace your current timesheet data for this week.`
);
if (confirmImport) {
onImport(importData);
// Show success message
const notification = document.createElement('div');
notification.className = 'fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-4 py-2 rounded-md shadow-lg z-50';
notification.textContent = 'Timesheet imported successfully!';
document.body.appendChild(notification);
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, 3000);
}
} catch (error) {
console.error('Error importing timesheet:', error);
alert('Error reading timesheet file. Please check the file format.');
}
};
reader.readAsText(file);
// Reset file input
event.target.value = '';
};
const hasData = Object.values(timeEntries).some(dayEntries =>
dayEntries.some(entry => entry.workDescription || entry.hoursWorked || entry.homeowner)
);
return (
<div className="flex items-center gap-3">
{/* Export Button */}
<button
onClick={handleExport}
disabled={!hasData}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
hasData
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
title={hasData ? 'Export a JSON backup of your timesheet (for re-import later)' : 'No data to export'}
>
<Save className="h-4 w-4" />
Export
</button>
{/* Import Button */}
<button
onClick={handleImportClick}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium transition-colors"
title="Import a previously exported JSON timesheet"
>
<FolderOpen className="h-4 w-4" />
Import
</button>
{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default ImportExport;
+162
View File
@@ -0,0 +1,162 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const MiniCalendar = ({ selectedDate, onDateChange }) => {
const [viewDate, setViewDate] = useState(new Date());
const today = new Date();
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const getDaysInMonth = (date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
const days = [];
// Add previous month's trailing days
const prevMonth = new Date(year, month - 1, 0);
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
days.push({
day: prevMonth.getDate() - i,
isCurrentMonth: false,
date: new Date(prevMonth.getFullYear(), prevMonth.getMonth(), prevMonth.getDate() - i)
});
}
// Add current month days
for (let day = 1; day <= daysInMonth; day++) {
days.push({
day,
isCurrentMonth: true,
date: new Date(year, month, day)
});
}
// Add next month's leading days
const remainingSlots = 42 - days.length; // 6 rows × 7 days = 42
for (let day = 1; day <= remainingSlots; day++) {
days.push({
day,
isCurrentMonth: false,
date: new Date(year, month + 1, day)
});
}
return days;
};
const days = getDaysInMonth(viewDate);
const navigateMonth = (direction) => {
const newDate = new Date(viewDate);
newDate.setMonth(newDate.getMonth() + direction);
setViewDate(newDate);
};
const goToToday = () => {
setViewDate(new Date());
};
const handleDateClick = (date) => {
onDateChange(date);
};
const isToday = (date) => {
return date.toDateString() === today.toDateString();
};
const isSelected = (date) => {
return date.toDateString() === selectedDate.toDateString();
};
return (
<div className="bg-gray-800 text-white rounded-lg p-4 shadow-lg w-full max-w-sm">
{/* Header */}
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">
{monthNames[viewDate.getMonth()]} {viewDate.getFullYear()}
</h3>
<div className="flex items-center gap-1">
<button
onClick={() => navigateMonth(-1)}
className="p-1 rounded hover:bg-gray-700 transition-colors"
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={goToToday}
className="px-3 py-1 text-sm bg-gray-600 hover:bg-gray-500 rounded transition-colors"
>
Today
</button>
<button
onClick={() => navigateMonth(1)}
className="p-1 rounded hover:bg-gray-700 transition-colors"
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{dayNames.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-gray-400 py-1"
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{days.map((dayObj, index) => {
const { day, isCurrentMonth, date } = dayObj;
const todayHighlight = isToday(date);
const selectedHighlight = isSelected(date);
return (
<button
key={index}
onClick={() => handleDateClick(date)}
className={`
relative h-8 text-sm font-medium rounded transition-colors
${isCurrentMonth
? 'text-white hover:bg-gray-700'
: 'text-gray-500 hover:bg-gray-700'
}
${selectedHighlight
? 'bg-blue-600 hover:bg-blue-500'
: ''
}
`}
>
{day}
{todayHighlight && !selectedHighlight && (
<div className="absolute inset-0 bg-red-500 rounded-full w-6 h-6 mx-auto my-1 flex items-center justify-center">
<span className="text-white text-xs font-bold">{day}</span>
</div>
)}
</button>
);
})}
</div>
</div>
);
};
export default MiniCalendar;
+9
View File
@@ -0,0 +1,9 @@
BEGIN:VCARD
VERSION:3.0
FN:Coastal Contracting Office
N:Office;Coastal Contracting;;;
EMAIL;TYPE=INTERNET;TYPE=WORK:Office@CoastalContractingFL.com
ORG:Coastal Contracting of FL
END:VCARD
+67
View File
@@ -0,0 +1,67 @@
import { pdf } from '@react-pdf/renderer';
import { Download } from 'lucide-react';
import { formatWeekRange } from '../utils/dateUtils';
import TimesheetPDF from './TimesheetPDF';
const PDFExport = ({ weekDays, timeEntries, userName, getEntryValidation }) => {
const handleDownload = async () => {
try {
const blob = await pdf(
<TimesheetPDF weekDays={weekDays} timeEntries={timeEntries} userName={userName} />
).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `Coastal_Timesheet_${formatWeekRange(weekDays).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Error generating PDF. Please try again.');
}
};
const hasEntries = Object.values(timeEntries).some(dayEntries =>
dayEntries.some(entry => entry.workDescription || entry.hoursWorked || entry.homeowner)
);
// Check if there are any incomplete entries
const hasIncompleteEntries = getEntryValidation && Object.values(timeEntries).some(dayEntries =>
dayEntries.some(entry => {
const validation = getEntryValidation(entry);
return validation.isPartial;
})
);
const hasUserName = userName && userName.trim() !== '';
const canExport = hasEntries && !hasIncompleteEntries && hasUserName;
return (
<button
onClick={handleDownload}
disabled={!canExport}
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-colors ${
canExport
? 'bg-green-600 hover:bg-green-700 text-white'
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
title={
!hasEntries
? 'No data to export as PDF'
: !hasUserName
? 'Employee name is required to export PDF'
: hasIncompleteEntries
? 'Complete all partially filled entries before exporting PDF'
: 'Download a printable PDF of your timesheet'
}
>
<Download className="h-5 w-5" />
Download PDF
</button>
);
};
export default PDFExport;
+19
View File
@@ -0,0 +1,19 @@
import { Sun, Moon } from 'lucide-react';
const ThemeToggle = ({ isDark, toggleTheme }) => {
return (
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="h-5 w-5 text-yellow-500" />
) : (
<Moon className="h-5 w-5 text-gray-700" />
)}
</button>
);
};
export default ThemeToggle;
+110
View File
@@ -0,0 +1,110 @@
import HomeownerDropdown from './HomeownerDropdown';
import { getDayName } from '../utils/dateUtils';
import { useEffect, useRef } from 'react';
const TimeEntryRow = ({
date,
entry,
onUpdate,
homeowners,
onAddCustomHomeowner,
getEntryValidation
}) => {
const textareaRef = useRef(null);
const handleChange = (field, value) => {
onUpdate(date, field, value);
};
const adjustTextareaHeight = (textarea) => {
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.max(38, textarea.scrollHeight) + 'px';
}
};
useEffect(() => {
adjustTextareaHeight(textareaRef.current);
}, [entry.workDescription]);
const isToday = date.toDateString() === new Date().toDateString();
const validation = getEntryValidation ? getEntryValidation(entry) : { isValid: true, isPartial: false, missingFields: {} };
const getFieldClassName = (baseClassName, fieldName) => {
if (!validation.isPartial) return baseClassName;
const isMissing = validation.missingFields[fieldName];
if (isMissing) {
return `${baseClassName} border-red-500 bg-red-50 dark:bg-red-900/20`;
}
return baseClassName;
};
return (
<div className={`grid grid-cols-1 md:grid-cols-5 gap-4 p-4 rounded-lg border-2 transition-colors ${
isToday
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-400'
: 'border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800'
}`}>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{getDayName(date)}
</label>
<span className="text-xs text-gray-500 dark:text-gray-400">
{date.toLocaleDateString()}
</span>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Homeowner {validation.isPartial && validation.missingFields.homeowner && <span className="text-red-500">*</span>}
</label>
<div className={validation.isPartial && validation.missingFields.homeowner ? 'border-2 border-red-500 rounded-md' : ''}>
<HomeownerDropdown
value={entry.homeowner}
onChange={(value) => handleChange('homeowner', value)}
homeowners={homeowners}
onAddCustom={onAddCustomHomeowner}
/>
</div>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hours Worked {validation.isPartial && validation.missingFields.hoursWorked && <span className="text-red-500">*</span>}
</label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={entry.hoursWorked}
onChange={(e) => handleChange('hoursWorked', e.target.value)}
placeholder="0.0"
className={getFieldClassName("px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white placeholder-gray-400", "hoursWorked")}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Work Description {validation.isPartial && validation.missingFields.workDescription && <span className="text-red-500">*</span>}
</label>
<textarea
ref={textareaRef}
value={entry.workDescription}
onChange={(e) => handleChange('workDescription', e.target.value)}
placeholder="Describe work performed..."
rows={1}
className={getFieldClassName("px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white placeholder-gray-400 resize-none overflow-hidden", "workDescription")}
style={{
minHeight: '38px',
height: 'auto'
}}
onInput={(e) => adjustTextareaHeight(e.target)}
/>
</div>
</div>
);
};
export default TimeEntryRow;
+159
View File
@@ -0,0 +1,159 @@
import DatePicker from './DatePicker';
import DayEntries from './DayEntries';
import PDFExport from './PDFExport';
import EmailPDF from './EmailPDF';
import officeContact from './OfficeContact.vcf?url';
import ImportExport from './ImportExport';
const TimeSheet = ({ timesheetData }) => {
const {
selectedDate,
setSelectedDate,
weekDays,
timeEntries,
updateTimeEntry,
addTimeEntry,
removeTimeEntry,
allHomeowners,
addCustomHomeowner,
customHomeowners,
importTimesheet,
userName,
setUserName,
getEntryValidation
} = timesheetData;
const totalHours = Object.values(timeEntries).reduce((total, dayEntries) => {
const dayTotal = dayEntries.reduce((daySum, entry) => {
return daySum + (parseFloat(entry.hoursWorked) || 0);
}, 0);
return total + dayTotal;
}, 0);
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
COASTAL CONTRACTING OF FL TIME SHEET
</h1>
<p className="text-gray-600 dark:text-gray-300">
Track your daily work hours and generate timesheets Sherry can actually read
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-end">
<div className="flex-1">
<label htmlFor="userName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Employee Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="userName"
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="Enter your name"
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white ${
userName.trim() === ''
? 'border-red-500 bg-red-50 dark:bg-red-900/20 dark:border-red-400'
: 'border-gray-300 dark:border-gray-600'
}`}
/>
{userName.trim() === '' && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
Employee name is required for timesheet submission
</p>
)}
</div>
<div className="flex-1">
<DatePicker
selectedDate={selectedDate}
onDateChange={setSelectedDate}
weekDays={weekDays}
/>
</div>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Time Entries
</h2>
<div className="text-lg font-medium text-gray-900 dark:text-white">
Total: {totalHours.toFixed(1)} hours
</div>
</div>
{weekDays.map((day) => {
const dayKey = day.toISOString().split('T')[0];
const dayEntries = timeEntries[dayKey] || [{
id: `${dayKey}-0`,
workDescription: '',
hoursWorked: '',
homeowner: ''
}];
return (
<DayEntries
key={dayKey}
date={day}
entries={dayEntries}
onUpdate={updateTimeEntry}
onAdd={addTimeEntry}
onRemove={removeTimeEntry}
homeowners={allHomeowners}
onAddCustomHomeowner={addCustomHomeowner}
getEntryValidation={getEntryValidation}
/>
);
})}
</div>
<div className="flex flex-col items-center gap-2 pt-6">
<div className="flex flex-wrap justify-center items-center gap-3">
<ImportExport
weekDays={weekDays}
timeEntries={timeEntries}
customHomeowners={customHomeowners}
onImport={importTimesheet}
/>
<PDFExport
weekDays={weekDays}
timeEntries={timeEntries}
userName={userName}
getEntryValidation={getEntryValidation}
/>
<EmailPDF
weekDays={weekDays}
timeEntries={timeEntries}
userName={userName}
getEntryValidation={getEntryValidation}
/>
<a
href={officeContact}
download="Coastal_Office_Contact.vcf"
className="flex items-center gap-2 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100 rounded-lg font-medium transition-colors"
title="Download and import the office contact to your phone so the To field is one tap away"
>
Import Office Contact
</a>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 text-center max-w-xl">
<p className="mb-1">
Export: Save a JSON backup that you can import later. Download PDF: Save a printable copy. Share/Email PDF: Attach the PDF via your device share sheet or download and open email with the subject pre-filled.
</p>
<p>
If all else fails: email your timesheet to <span className="font-medium">Office@CoastalContractingFL.com</span> with subject
<span className="font-medium"> "{userName || 'Employee'} - {weekDays && weekDays.length ? weekDays[0].toLocaleDateString() + ' - ' + weekDays[6].toLocaleDateString() : 'Timesheet'}"</span>.
</p>
<p className="mt-1">
Tip: You can add a contact for Office@CoastalContractingFL.com on your device so the To field is one tap away.
</p>
</div>
</div>
</div>
);
};
export default TimeSheet;
+268
View File
@@ -0,0 +1,268 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
import { formatWeekRange, getDayName } from '../utils/dateUtils';
const styles = StyleSheet.create({
page: {
fontFamily: 'Helvetica',
fontSize: 10,
paddingTop: 25,
paddingBottom: 40,
paddingHorizontal: 30,
backgroundColor: '#ffffff',
},
headerSection: {
marginBottom: 20,
paddingBottom: 10,
borderBottomWidth: 2,
borderBottomColor: '#e5e7eb',
},
header: {
fontSize: 20,
marginBottom: 6,
textAlign: 'center',
fontWeight: 'bold',
color: '#1f2937',
letterSpacing: 1.0,
},
brandLine: {
width: 60,
height: 3,
backgroundColor: '#3b82f6',
alignSelf: 'center',
marginBottom: 5,
},
weekInfo: {
fontSize: 14,
marginBottom: 18,
textAlign: 'center',
fontWeight: 'bold',
color: '#374151',
backgroundColor: '#f8fafc',
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 4,
},
daySection: {
marginBottom: 8,
borderRadius: 4,
overflow: 'hidden',
border: '1px solid #e5e7eb',
},
dayHeader: {
backgroundColor: '#3b82f6',
paddingVertical: 6,
paddingHorizontal: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
dayHeaderEmpty: {
backgroundColor: '#9ca3af',
},
dayName: {
fontSize: 11,
fontWeight: 'bold',
color: '#ffffff',
},
dayDate: {
fontSize: 9,
color: '#dbeafe',
},
dayTotal: {
fontSize: 9,
color: '#dbeafe',
fontWeight: 'bold',
},
entryRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
minHeight: 28,
},
entryRowLast: {
borderBottomWidth: 0,
},
entryRowAlternate: {
backgroundColor: '#f9fafb',
},
entryCell: {
paddingVertical: 6,
paddingHorizontal: 8,
fontSize: 9,
color: '#374151',
justifyContent: 'center',
},
homeownerCell: {
width: '25%',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
fontWeight: 'bold',
color: '#1f2937',
},
hoursCell: {
width: '15%',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
alignItems: 'center',
fontWeight: 'bold',
color: '#059669',
},
workDescCell: {
width: '60%',
},
emptyDay: {
backgroundColor: '#f9fafb',
paddingVertical: 8,
paddingHorizontal: 10,
alignItems: 'center',
},
emptyDayText: {
fontSize: 9,
color: '#9ca3af',
fontStyle: 'italic',
},
summarySection: {
marginTop: 18,
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: '#3b82f6',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
summaryText: {
fontSize: 12,
fontWeight: 'bold',
color: '#1f2937',
},
totalHours: {
fontSize: 16,
fontWeight: 'bold',
color: '#059669',
},
footer: {
position: 'absolute',
fontSize: 8,
bottom: 25,
left: 0,
right: 0,
textAlign: 'center',
color: '#9ca3af',
},
});
const TimesheetPDF = ({ weekDays, timeEntries, userName }) => {
const totalHours = Object.values(timeEntries).reduce((total, dayEntries) => {
const dayTotal = dayEntries.reduce((daySum, entry) => {
return daySum + (parseFloat(entry.hoursWorked) || 0);
}, 0);
return total + dayTotal;
}, 0);
const getDayTotal = (dayKey) => {
const dayEntries = timeEntries[dayKey] || [];
return dayEntries.reduce((total, entry) => total + (parseFloat(entry.hoursWorked) || 0), 0);
};
const formatDate = (date) => {
return date.toLocaleDateString('en-US', {
month: 'numeric',
day: 'numeric',
year: 'numeric'
});
};
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.headerSection}>
<View style={styles.brandLine}></View>
<Text style={styles.header}>
COASTAL CONTRACTING OF FL
</Text>
</View>
<View style={styles.weekInfo}>
<Text>Week of: {formatWeekRange(weekDays)}</Text>
</View>
{userName && (
<View style={{ ...styles.weekInfo, marginBottom: 12, backgroundColor: '#f0f9ff' }}>
<Text>Employee: {userName}</Text>
</View>
)}
{weekDays.map((day, dayIndex) => {
const dayKey = day.toISOString().split('T')[0];
const dayEntries = timeEntries[dayKey] || [];
const dayTotal = getDayTotal(dayKey);
const hasEntries = dayEntries.length > 0 && dayEntries.some(entry =>
entry.workDescription || entry.hoursWorked || entry.homeowner
);
// Skip days with no work
if (!hasEntries) {
return null;
}
return (
<View style={styles.daySection} key={dayIndex}>
{/* Day Header */}
<View style={styles.dayHeader}>
<View>
<Text style={styles.dayName}>{getDayName(day)}</Text>
<Text style={styles.dayDate}>{formatDate(day)}</Text>
</View>
<Text style={styles.dayTotal}>
{dayTotal.toFixed(1)} hours
</Text>
</View>
{/* Day Content */}
{dayEntries.map((entry, entryIndex) => {
const isLastEntry = entryIndex === dayEntries.length - 1;
const isAlternate = entryIndex % 2 === 1;
return (
<View
style={[
styles.entryRow,
isLastEntry && styles.entryRowLast,
isAlternate && styles.entryRowAlternate
]}
key={entryIndex}
>
<View style={[styles.entryCell, styles.homeownerCell]}>
<Text>{entry.homeowner || '-'}</Text>
</View>
<View style={[styles.entryCell, styles.hoursCell]}>
<Text>{entry.hoursWorked || '0'}</Text>
</View>
<View style={[styles.entryCell, styles.workDescCell]}>
<Text>{entry.workDescription || '-'}</Text>
</View>
</View>
);
})}
</View>
);
})}
<View style={styles.summarySection}>
<Text style={styles.summaryText}>
Weekly Total
</Text>
<Text style={styles.totalHours}>
{totalHours.toFixed(1)} Hours
</Text>
</View>
<Text style={styles.footer}>
Generated on {new Date().toLocaleDateString()} • Coastal Contracting of FL{'\n'}Built by the warehouse guy
</Text>
</Page>
</Document>
);
};
export default TimesheetPDF;
+21
View File
@@ -0,0 +1,21 @@
import { useState, useEffect } from 'react';
export const useTheme = () => {
const [isDark, setIsDark] = useState(() => {
const saved = localStorage.getItem('theme');
return saved ? saved === 'dark' : false;
});
useEffect(() => {
localStorage.setItem('theme', isDark ? 'dark' : 'light');
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDark]);
const toggleTheme = () => setIsDark(!isDark);
return { isDark, toggleTheme };
};
+240
View File
@@ -0,0 +1,240 @@
import { useState, useEffect } from 'react';
import { getWeekDays } from '../utils/dateUtils';
export const useTimeSheet = () => {
const [selectedDate, setSelectedDate] = useState(new Date());
const [timeEntries, setTimeEntries] = useState({});
const [userName, setUserName] = useState(() => {
const saved = localStorage.getItem('userName');
return saved || '';
});
const [customHomeowners, setCustomHomeowners] = useState(() => {
const saved = localStorage.getItem('customHomeowners');
return saved ? JSON.parse(saved) : [];
});
const weekDays = getWeekDays(selectedDate);
const weekKey = `${weekDays[0].toISOString().split('T')[0]}_${weekDays[6].toISOString().split('T')[0]}`;
// Load timesheet data for current week
useEffect(() => {
const saved = localStorage.getItem(`timesheet_${weekKey}`);
if (saved) {
const savedData = JSON.parse(saved);
// Migrate old single-entry format to new array format
const migratedData = {};
Object.keys(savedData).forEach(dayKey => {
if (Array.isArray(savedData[dayKey])) {
migratedData[dayKey] = savedData[dayKey];
} else {
// Convert old format to new format
migratedData[dayKey] = [savedData[dayKey]];
}
});
setTimeEntries(migratedData);
} else {
// Initialize empty entries for the week
const initialEntries = {};
weekDays.forEach(day => {
const dayKey = day.toISOString().split('T')[0];
initialEntries[dayKey] = [{
id: `${dayKey}-0`,
workDescription: '',
hoursWorked: '',
homeowner: ''
}];
});
setTimeEntries(initialEntries);
}
}, [weekKey]);
// Save timesheet data when it changes
useEffect(() => {
if (Object.keys(timeEntries).length > 0) {
localStorage.setItem(`timesheet_${weekKey}`, JSON.stringify(timeEntries));
}
}, [timeEntries, weekKey]);
// Save custom homeowners when they change
useEffect(() => {
localStorage.setItem('customHomeowners', JSON.stringify(customHomeowners));
}, [customHomeowners]);
// Save user name when it changes
useEffect(() => {
localStorage.setItem('userName', userName);
}, [userName]);
const updateTimeEntry = (date, entryId, field, value) => {
const dayKey = date.toISOString().split('T')[0];
setTimeEntries(prev => ({
...prev,
[dayKey]: prev[dayKey].map(entry =>
entry.id === entryId
? { ...entry, [field]: value }
: entry
)
}));
};
const addTimeEntry = (date) => {
const dayKey = date.toISOString().split('T')[0];
const newId = `${dayKey}-${Date.now()}`;
setTimeEntries(prev => ({
...prev,
[dayKey]: [
...prev[dayKey],
{
id: newId,
workDescription: '',
hoursWorked: '',
homeowner: ''
}
]
}));
};
const removeTimeEntry = (date, entryId) => {
const dayKey = date.toISOString().split('T')[0];
setTimeEntries(prev => {
const dayEntries = prev[dayKey];
// Don't allow removing the last entry
if (dayEntries.length <= 1) {
return prev;
}
return {
...prev,
[dayKey]: dayEntries.filter(entry => entry.id !== entryId)
};
});
};
const addCustomHomeowner = (name) => {
if (name && !customHomeowners.includes(name)) {
setCustomHomeowners(prev => [...prev, name]);
}
};
const importTimesheet = (importData) => {
// Import time entries
if (importData.timeEntries) {
setTimeEntries(importData.timeEntries);
}
// Import custom homeowners (merge with existing ones)
if (importData.customHomeowners && Array.isArray(importData.customHomeowners)) {
setCustomHomeowners(prev => {
const merged = [...prev];
importData.customHomeowners.forEach(name => {
if (!merged.includes(name)) {
merged.push(name);
}
});
return merged;
});
}
};
// Validation function to check if an entry is complete
const isEntryComplete = (entry) => {
return entry.homeowner.trim() !== '' &&
entry.hoursWorked.trim() !== '' &&
entry.workDescription.trim() !== '';
};
// Check if an entry has any input (partial entry)
const hasAnyInput = (entry) => {
return entry.homeowner.trim() !== '' ||
entry.hoursWorked.trim() !== '' ||
entry.workDescription.trim() !== '';
};
// Get validation state for an entry
const getEntryValidation = (entry) => {
const hasInput = hasAnyInput(entry);
const isComplete = isEntryComplete(entry);
if (!hasInput) {
return { isValid: true, isPartial: false }; // Empty entry is valid
}
return {
isValid: isComplete,
isPartial: hasInput && !isComplete,
missingFields: {
homeowner: entry.homeowner.trim() === '',
hoursWorked: entry.hoursWorked.trim() === '',
workDescription: entry.workDescription.trim() === ''
}
};
};
const defaultHomeowners = [
'Anderson, 217',
'Bakos',
'Beckstead, 111',
'Bentley, 310',
'Best, 103',
'Caraway, 132',
'Carmichael, M, 216',
'Casa Blanca',
'Chapin, 106',
'Conner, 309',
'Cook, 118',
'Coyle, 109',
'Davis, 114a',
'Dimmitt, 213',
'Dockery, 502',
'Fassett, 303C',
'Gypsy Wind',
'Hager, 108',
'Hanford, 308',
'Hitchcox – Clarry, 218',
'Hughes, 215',
'Kaufman 129 (Blue View)',
'Kuchman, 104',
'Lockhart, 301A',
'Lokey, 136',
'McColgan, 312',
'Mercurio, 523',
'Moff – Dean Elect',
'Rogers, 501',
'Rusten, 204A',
'Ryan, 301B',
'Salas, 144',
'Sear 128 (Twin Shores)',
'Shimp, 517',
'Sipprelle, 202',
'Trino, 131',
'Useppa Fire',
'Vogt',
'Weinsz, 141',
'Wendorf, 306',
'White (Rogan)',
'Williams, Bob, 140',
'Williams, Dan, 137B',
'Williamson-Whetstone, 102',
'Wilson, George, 516',
'Wright, 137A'
];
const allHomeowners = [...defaultHomeowners, ...customHomeowners];
return {
selectedDate,
setSelectedDate,
weekDays,
timeEntries,
updateTimeEntry,
addTimeEntry,
removeTimeEntry,
allHomeowners,
addCustomHomeowner,
customHomeowners,
importTimesheet,
userName,
setUserName,
isEntryComplete,
hasAnyInput,
getEntryValidation
};
};
+13
View File
@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+33
View File
@@ -0,0 +1,33 @@
export const getWeekDays = (date = new Date()) => {
const startOfWeek = new Date(date);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); // Adjust for Monday start
startOfWeek.setDate(diff);
const weekDays = [];
for (let i = 0; i < 7; i++) {
const day = new Date(startOfWeek);
day.setDate(startOfWeek.getDate() + i);
weekDays.push(day);
}
return weekDays;
};
export const formatDate = (date) => {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
};
export const formatWeekRange = (weekDays) => {
if (!weekDays.length) return '';
const start = weekDays[0];
const end = weekDays[6];
return `${formatDate(start)} - ${formatDate(end)}, ${end.getFullYear()}`;
};
export const getDayName = (date) => {
return date.toLocaleDateString('en-US', { weekday: 'long' });
};
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})