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:
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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;"]
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
*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.*
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6654
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 469 KiB |
+76
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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' });
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user