Initial commit from gitea_uploader script

This commit is contained in:
bizzle
2025-08-11 15:52:32 -04:00
commit c913946c08
32 changed files with 8813 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Production build
dist
build
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode
.idea
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker files (to avoid infinite recursion)
Dockerfile*
docker-compose*
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# ESLint cache
.eslintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Temporary folders
tmp/
temp/
+187
View File
@@ -0,0 +1,187 @@
# System files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Environment and secrets
.env
.env.local
.env.*.local
*.key
*.pem
# Logs
*.log
logs/
# Temporary files
*.tmp
*.temp
.cache/
# Python - https://github.com/github/gitignore/blob/main/Python.gitignore
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# poetry
poetry.lock
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Node.js - https://github.com/github/gitignore/blob/main/Node.gitignore
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out/
# Nuxt.js build / generate output
.nuxt
dist/
# Gatsby files
.cache/
public/
# Storybook build outputs
.out
.storybook-out
# Temporary folders
.tmp/
.temp/
# React/Next.js specific
.next/
out/
build/
.vercel
# testing
coverage/
# production
build/
# misc
.eslintcache
# Custom patterns
.git
.env
node_modules
+84
View File
@@ -0,0 +1,84 @@
# Docker Deployment Guide
## Production Deployment
### Quick Start
```bash
# Build and run the production container
docker-compose up -d
# Access the app at http://localhost:8080
```
### Manual Build
```bash
# Build the Docker image
docker build -t coastal-timesheet .
# Run the container
docker run -d -p 8080:80 --name coastal-timesheet coastal-timesheet
```
## Development with Docker
### Development Server
```bash
# Run development environment
docker-compose -f docker-compose.dev.yml up
# Access the app at http://localhost:5173
```
## Docker Commands
### Production
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
# View logs
docker-compose logs -f
# Rebuild after changes
docker-compose up -d --build
```
### Development
```bash
# Start dev environment
docker-compose -f docker-compose.dev.yml up
# Stop dev environment
docker-compose -f docker-compose.dev.yml down
# Rebuild dev container
docker-compose -f docker-compose.dev.yml up --build
```
## Configuration
### Environment Variables
- `NODE_ENV`: Set to "production" for production builds
- Port mapping can be changed in docker-compose.yml
### Nginx Configuration
The production container uses nginx to serve static files with:
- Gzip compression enabled
- Client-side routing support (SPA)
- Static asset caching
- Security headers
### Ports
- **Production**: http://localhost:8080
- **Development**: http://localhost:5173
## Architecture
The production setup uses a multi-stage build:
1. **Build stage**: Compiles the React app using Node.js
2. **Production stage**: Serves static files using nginx
This results in a lightweight production image (~25MB) that only contains the compiled app and nginx.
+32
View File
@@ -0,0 +1,32 @@
# Use Node.js 18 alpine as base image
FROM node:18-alpine as build
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage - use nginx to serve static files
FROM nginx:alpine
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+20
View File
@@ -0,0 +1,20 @@
# Development Dockerfile
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including dev dependencies)
RUN npm install
# Copy source code
COPY . .
# Expose development port
EXPOSE 5173
# Start development server
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+86
View File
@@ -0,0 +1,86 @@
# Coastal Contracting Timesheet App
A modern, responsive timesheet application built with React for tracking work hours and generating professional PDF timesheets.
## Features
- **Weekly Time Tracking**: Track work hours for each day of the week
- **Dynamic Date Navigation**: Navigate between weeks with easy-to-use controls
- **Homeowner Management**: Default homeowners plus ability to add custom names
- **Dark/Light Mode**: Toggle between themes with persistent preference
- **PDF Export**: Generate and download professional timesheet PDFs
- **Local Storage**: Automatically saves timesheet data and custom homeowners
- **Responsive Design**: Works on desktop and mobile devices
## Getting Started
### Prerequisites
- Node.js (version 14 or higher)
- npm or yarn
### Installation
1. Clone the repository
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
npm run preview
```
## Usage
1. **Select Week**: Use the date picker at the top to navigate between weeks
2. **Enter Time Data**: For each day, fill in:
- Work Description: What work was performed
- Hours Worked: Number of hours (supports decimals)
- Homeowner: Select from dropdown or add custom names
3. **Theme Toggle**: Click the sun/moon icon in the top-right to switch themes
4. **Export PDF**: Click "Download PDF" to generate a professional timesheet
## Data Storage
- All timesheet data is stored locally in your browser
- Custom homeowner names are preserved across sessions
- Each week's data is stored separately
## Tech Stack
- **Frontend**: React with Vite
- **Styling**: Tailwind CSS
- **PDF Generation**: @react-pdf/renderer
- **Icons**: Lucide React
- **Storage**: Browser localStorage
## Project Structure
```
src/
├── components/ # React components
│ ├── TimeSheet.jsx # Main timesheet interface
│ ├── DatePicker.jsx # Week navigation
│ ├── TimeEntryRow.jsx # Individual day entry
│ ├── HomeownerDropdown.jsx # Name selection
│ ├── ThemeToggle.jsx # Dark/light mode toggle
│ ├── PDFExport.jsx # PDF download button
│ └── TimesheetPDF.jsx # PDF document structure
├── hooks/ # Custom React hooks
│ ├── useTimeSheet.js # Timesheet state management
│ └── useTheme.js # Theme management
├── utils/ # Utility functions
│ └── dateUtils.js # Date manipulation helpers
└── App.jsx # Main application component
```
BIN
View File
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
version: '3.8'
services:
coastal-timesheet-dev:
build:
context: .
dockerfile: Dockerfile.dev
container_name: coastal-timesheet-dev
ports:
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
stdin_open: true
tty: true
networks:
- timesheet-dev-network
networks:
timesheet-dev-network:
driver: bridge
+38
View File
@@ -0,0 +1,38 @@
version: '3.8'
services:
coastal-timesheet:
build:
context: .
dockerfile: Dockerfile
container_name: coastal-timesheet-app
ports:
- "8080:80"
environment:
- NODE_ENV=production
restart: unless-stopped
networks:
- timesheet-network
# Optional: Add a reverse proxy with SSL if needed
# nginx-proxy:
# image: nginx:alpine
# container_name: coastal-timesheet-proxy
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./proxy.conf:/etc/nginx/nginx.conf
# - ./ssl:/etc/nginx/ssl
# depends_on:
# - coastal-timesheet
# networks:
# - timesheet-network
networks:
timesheet-network:
driver: bridge
# Optional: Add volumes for persistent storage if needed in future
# volumes:
# timesheet-data:
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Coastal Contracting Timesheet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing (SPA)
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
}
}
+6654
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "coastal-timesheet",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@react-pdf/renderer": "^4.3.0",
"autoprefixer": "^10.4.21",
"lucide-react": "^0.539.0",
"postcss": "^8.5.6",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"tailwindcss": "^3.4.17",
"vite": "^5.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react';
import TimeSheet from './components/TimeSheet';
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);
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">
<div className="flex items-start gap-4">
{/* Desktop calendar - always visible on large screens */}
<div className="hidden lg:block">
<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"
aria-label="Toggle calendar"
>
<Calendar className="h-5 w-5 text-gray-700 dark:text-gray-300" />
</button>
<ThemeToggle isDark={isDark} toggleTheme={toggleTheme} />
</div>
{/* Mobile calendar dropdown */}
{showMobileCalendar && (
<div className="lg:hidden mt-2">
<MiniCalendar
selectedDate={timesheetData.selectedDate}
onDateChange={(date) => {
timesheetData.setSelectedDate(date);
setShowMobileCalendar(false);
}}
/>
</div>
)}
</div>
<div className="container mx-auto py-8 lg:pr-80 px-4">
<TimeSheet timesheetData={timesheetData} />
</div>
</div>
);
}
export default App;
+56
View File
@@ -0,0 +1,56 @@
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
import { formatWeekRange } from '../utils/dateUtils';
const DatePicker = ({ selectedDate, onDateChange, weekDays }) => {
const navigateWeek = (direction) => {
const newDate = new Date(selectedDate);
newDate.setDate(newDate.getDate() + (direction * 7));
onDateChange(newDate);
};
const goToToday = () => {
onDateChange(new Date());
};
return (
<div className="flex items-center gap-4 bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md">
<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">
<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 min-w-[200px] text-center">
{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;
+130
View File
@@ -0,0 +1,130 @@
import { Plus, Trash2 } from 'lucide-react';
import HomeownerDropdown from './HomeownerDropdown';
import { getDayName } from '../utils/dateUtils';
const SingleEntry = ({
entry,
onUpdate,
onRemove,
homeowners,
onAddCustomHomeowner,
canRemove
}) => {
const handleChange = (field, value) => {
onUpdate(field, value);
};
return (
<div className="grid grid-cols-4 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">
Work Description
</label>
<input
type="text"
value={entry.workDescription}
onChange={(e) => handleChange('workDescription', e.target.value)}
placeholder="Describe work performed..."
className="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"
/>
</div>
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Hours Worked
</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="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"
/>
</div>
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Homeowner
</label>
<HomeownerDropdown
value={entry.homeowner}
onChange={(value) => handleChange('homeowner', value)}
homeowners={homeowners}
onAddCustom={onAddCustomHomeowner}
/>
</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
}) => {
const isToday = date.toDateString() === new Date().toDateString();
const dayHours = entries.reduce((total, entry) => total + (parseFloat(entry.hoursWorked) || 0), 0);
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>
{/* 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}
/>
))}
</div>
</div>
);
};
export default DayEntries;
+135
View File
@@ -0,0 +1,135 @@
import { useState } from 'react';
import { ChevronDown, Plus, Search } from 'lucide-react';
const HomeownerDropdown = ({ value, onChange, homeowners, onAddCustom }) => {
const [isOpen, setIsOpen] = useState(false);
const [customName, setCustomName] = useState('');
const [showCustomInput, setShowCustomInput] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const handleSelect = (homeowner) => {
onChange(homeowner);
setIsOpen(false);
setSearchTerm('');
};
const filteredHomeowners = homeowners.filter(homeowner =>
homeowner.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleOpen = () => {
setIsOpen(!isOpen);
if (!isOpen) {
setSearchTerm('');
}
};
const handleAddCustom = () => {
if (customName.trim()) {
onAddCustom(customName.trim());
onChange(customName.trim());
setCustomName('');
setShowCustomInput(false);
setIsOpen(false);
}
};
const toggleCustomInput = () => {
setShowCustomInput(!showCustomInput);
setCustomName('');
};
return (
<div className="relative">
<button
type="button"
onClick={handleOpen}
className="w-full px-3 py-2 text-left bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-white"
>
<span className="block truncate">
{value || 'Select homeowner...'}
</span>
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
</button>
{isOpen && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg">
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search homeowners..."
className="w-full pl-10 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-800 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-auto">
{filteredHomeowners.length > 0 ? (
filteredHomeowners.map((homeowner, index) => (
<button
key={index}
type="button"
onClick={() => handleSelect(homeowner)}
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-600 dark:text-white"
>
{homeowner}
</button>
))
) : (
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-sm">
No homeowners found
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600">
{!showCustomInput ? (
<button
type="button"
onClick={toggleCustomInput}
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-600 text-blue-600 dark:text-blue-400 flex items-center"
>
<Plus className="h-4 w-4 mr-2" />
Add Custom Name
</button>
) : (
<div className="p-3">
<input
type="text"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddCustom()}
placeholder="Enter custom name..."
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-800 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
autoFocus
/>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={handleAddCustom}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
Add
</button>
<button
type="button"
onClick={toggleCustomInput}
className="px-3 py-1 text-sm bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-400 dark:hover:bg-gray-500"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default HomeownerDropdown;
+164
View File
@@ -0,0 +1,164 @@
import { useRef } from 'react';
import { Download, Upload, Save, FolderOpen } from 'lucide-react';
import { formatWeekRange } from '../utils/dateUtils';
const ImportExport = ({ weekDays, timeEntries, customHomeowners, onImport }) => {
const fileInputRef = useRef(null);
const handleExport = () => {
try {
const exportData = {
version: '1.0',
exportDate: new Date().toISOString(),
weekRange: formatWeekRange(weekDays),
weekStart: weekDays[0].toISOString().split('T')[0],
weekEnd: weekDays[6].toISOString().split('T')[0],
timeEntries,
customHomeowners
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `Coastal_Timesheet_${formatWeekRange(weekDays).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_')}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
// Show success message
const notification = document.createElement('div');
notification.className = 'fixed top-4 left-1/2 transform -translate-x-1/2 bg-green-600 text-white px-4 py-2 rounded-md shadow-lg z-50';
notification.textContent = 'Timesheet exported successfully!';
document.body.appendChild(notification);
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, 3000);
} catch (error) {
console.error('Error exporting timesheet:', error);
alert('Error exporting timesheet. Please try again.');
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.type !== 'application/json') {
alert('Please select a valid JSON file.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const importData = JSON.parse(e.target.result);
// Validate the imported data structure
if (!importData.version || !importData.timeEntries) {
alert('Invalid timesheet file format.');
return;
}
// Validate data structure
const isValidStructure = Object.values(importData.timeEntries).every(dayEntries =>
Array.isArray(dayEntries) && dayEntries.every(entry =>
typeof entry === 'object' &&
'id' in entry &&
'workDescription' in entry &&
'hoursWorked' in entry &&
'homeowner' in entry
)
);
if (!isValidStructure) {
alert('Invalid timesheet data structure.');
return;
}
// Confirm import
const confirmImport = confirm(
`Import timesheet data from ${importData.weekRange || 'unknown week'}?\n\nThis will replace your current timesheet data for this week.`
);
if (confirmImport) {
onImport(importData);
// Show success message
const notification = document.createElement('div');
notification.className = 'fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-4 py-2 rounded-md shadow-lg z-50';
notification.textContent = 'Timesheet imported successfully!';
document.body.appendChild(notification);
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, 3000);
}
} catch (error) {
console.error('Error importing timesheet:', error);
alert('Error reading timesheet file. Please check the file format.');
}
};
reader.readAsText(file);
// Reset file input
event.target.value = '';
};
const hasData = Object.values(timeEntries).some(dayEntries =>
dayEntries.some(entry => entry.workDescription || entry.hoursWorked || entry.homeowner)
);
return (
<div className="flex items-center gap-3">
{/* Export Button */}
<button
onClick={handleExport}
disabled={!hasData}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
hasData
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
title={hasData ? 'Export timesheet data' : '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 timesheet data"
>
<FolderOpen className="h-4 w-4" />
Import
</button>
{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default ImportExport;
+162
View File
@@ -0,0 +1,162 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const MiniCalendar = ({ selectedDate, onDateChange }) => {
const [viewDate, setViewDate] = useState(new Date());
const today = new Date();
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const getDaysInMonth = (date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
const days = [];
// Add previous month's trailing days
const prevMonth = new Date(year, month - 1, 0);
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
days.push({
day: prevMonth.getDate() - i,
isCurrentMonth: false,
date: new Date(prevMonth.getFullYear(), prevMonth.getMonth(), prevMonth.getDate() - i)
});
}
// Add current month days
for (let day = 1; day <= daysInMonth; day++) {
days.push({
day,
isCurrentMonth: true,
date: new Date(year, month, day)
});
}
// Add next month's leading days
const remainingSlots = 42 - days.length; // 6 rows × 7 days = 42
for (let day = 1; day <= remainingSlots; day++) {
days.push({
day,
isCurrentMonth: false,
date: new Date(year, month + 1, day)
});
}
return days;
};
const days = getDaysInMonth(viewDate);
const navigateMonth = (direction) => {
const newDate = new Date(viewDate);
newDate.setMonth(newDate.getMonth() + direction);
setViewDate(newDate);
};
const goToToday = () => {
setViewDate(new Date());
};
const handleDateClick = (date) => {
onDateChange(date);
};
const isToday = (date) => {
return date.toDateString() === today.toDateString();
};
const isSelected = (date) => {
return date.toDateString() === selectedDate.toDateString();
};
return (
<div className="bg-gray-800 text-white rounded-lg p-4 shadow-lg min-w-[320px]">
{/* 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;
+47
View File
@@ -0,0 +1,47 @@
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 }) => {
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)
);
return (
<button
onClick={handleDownload}
disabled={!hasEntries}
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-colors ${
hasEntries
? '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'
}`}
>
<Download className="h-5 w-5" />
Download PDF
</button>
);
};
export default PDFExport;
+19
View File
@@ -0,0 +1,19 @@
import { Sun, Moon } from 'lucide-react';
const ThemeToggle = ({ isDark, toggleTheme }) => {
return (
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="h-5 w-5 text-yellow-500" />
) : (
<Moon className="h-5 w-5 text-gray-700" />
)}
</button>
);
};
export default ThemeToggle;
+76
View File
@@ -0,0 +1,76 @@
import HomeownerDropdown from './HomeownerDropdown';
import { getDayName } from '../utils/dateUtils';
const TimeEntryRow = ({
date,
entry,
onUpdate,
homeowners,
onAddCustomHomeowner
}) => {
const handleChange = (field, value) => {
onUpdate(date, field, value);
};
const isToday = date.toDateString() === new Date().toDateString();
return (
<div className={`grid grid-cols-4 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">
Work Description
</label>
<input
type="text"
value={entry.workDescription}
onChange={(e) => handleChange('workDescription', e.target.value)}
placeholder="Describe work performed..."
className="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"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hours Worked
</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="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"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Homeowner
</label>
<HomeownerDropdown
value={entry.homeowner}
onChange={(value) => handleChange('homeowner', value)}
homeowners={homeowners}
onAddCustom={onAddCustomHomeowner}
/>
</div>
</div>
);
};
export default TimeEntryRow;
+117
View File
@@ -0,0 +1,117 @@
import DatePicker from './DatePicker';
import DayEntries from './DayEntries';
import PDFExport from './PDFExport';
import ImportExport from './ImportExport';
const TimeSheet = ({ timesheetData }) => {
const {
selectedDate,
setSelectedDate,
weekDays,
timeEntries,
updateTimeEntry,
addTimeEntry,
removeTimeEntry,
allHomeowners,
addCustomHomeowner,
customHomeowners,
importTimesheet,
userName,
setUserName
} = 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
</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 border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
/>
</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}
/>
);
})}
</div>
<div className="flex justify-center items-center gap-4 pt-6">
<ImportExport
weekDays={weekDays}
timeEntries={timeEntries}
customHomeowners={customHomeowners}
onImport={importTimesheet}
/>
<PDFExport
weekDays={weekDays}
timeEntries={timeEntries}
userName={userName}
/>
</div>
</div>
);
};
export default TimeSheet;
+268
View File
@@ -0,0 +1,268 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
import { formatWeekRange, getDayName } from '../utils/dateUtils';
const styles = StyleSheet.create({
page: {
fontFamily: 'Helvetica',
fontSize: 10,
paddingTop: 25,
paddingBottom: 40,
paddingHorizontal: 30,
backgroundColor: '#ffffff',
},
headerSection: {
marginBottom: 20,
paddingBottom: 10,
borderBottomWidth: 2,
borderBottomColor: '#e5e7eb',
},
header: {
fontSize: 20,
marginBottom: 6,
textAlign: 'center',
fontWeight: 'bold',
color: '#1f2937',
letterSpacing: 1.0,
},
brandLine: {
width: 60,
height: 3,
backgroundColor: '#3b82f6',
alignSelf: 'center',
marginBottom: 5,
},
weekInfo: {
fontSize: 14,
marginBottom: 18,
textAlign: 'center',
fontWeight: 'bold',
color: '#374151',
backgroundColor: '#f8fafc',
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 4,
},
daySection: {
marginBottom: 8,
borderRadius: 4,
overflow: 'hidden',
border: '1px solid #e5e7eb',
},
dayHeader: {
backgroundColor: '#3b82f6',
paddingVertical: 6,
paddingHorizontal: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
dayHeaderEmpty: {
backgroundColor: '#9ca3af',
},
dayName: {
fontSize: 11,
fontWeight: 'bold',
color: '#ffffff',
},
dayDate: {
fontSize: 9,
color: '#dbeafe',
},
dayTotal: {
fontSize: 9,
color: '#dbeafe',
fontWeight: 'bold',
},
entryRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
minHeight: 28,
},
entryRowLast: {
borderBottomWidth: 0,
},
entryRowAlternate: {
backgroundColor: '#f9fafb',
},
entryCell: {
paddingVertical: 6,
paddingHorizontal: 8,
fontSize: 9,
color: '#374151',
justifyContent: 'center',
},
workDescCell: {
width: '45%',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
},
hoursCell: {
width: '15%',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
alignItems: 'center',
fontWeight: 'bold',
color: '#059669',
},
homeownerCell: {
width: '40%',
fontWeight: 'bold',
color: '#1f2937',
},
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.workDescCell]}>
<Text>{entry.workDescription || '-'}</Text>
</View>
<View style={[styles.entryCell, styles.hoursCell]}>
<Text>{entry.hoursWorked || '0'}</Text>
</View>
<View style={[styles.entryCell, styles.homeownerCell]}>
<Text>{entry.homeowner || '-'}</Text>
</View>
</View>
);
})}
</View>
);
})}
<View style={styles.summarySection}>
<Text style={styles.summaryText}>
Weekly Total
</Text>
<Text style={styles.totalHours}>
{totalHours.toFixed(1)} Hours
</Text>
</View>
<Text style={styles.footer}>
Generated on {new Date().toLocaleDateString()} • Coastal Contracting of FL{'\n'}Built by the warehouse guy
</Text>
</Page>
</Document>
);
};
export default TimesheetPDF;
+21
View File
@@ -0,0 +1,21 @@
import { useState, useEffect } from 'react';
export const useTheme = () => {
const [isDark, setIsDark] = useState(() => {
const saved = localStorage.getItem('theme');
return saved ? saved === 'dark' : false;
});
useEffect(() => {
localStorage.setItem('theme', isDark ? 'dark' : 'light');
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDark]);
const toggleTheme = () => setIsDark(!isDark);
return { isDark, toggleTheme };
};
+203
View File
@@ -0,0 +1,203 @@
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;
});
}
};
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
};
};
+13
View File
@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+33
View File
@@ -0,0 +1,33 @@
export const getWeekDays = (date = new Date()) => {
const startOfWeek = new Date(date);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); // Adjust for Monday start
startOfWeek.setDate(diff);
const weekDays = [];
for (let i = 0; i < 7; i++) {
const day = new Date(startOfWeek);
day.setDate(startOfWeek.getDate() + i);
weekDays.push(day);
}
return weekDays;
};
export const formatDate = (date) => {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
};
export const formatWeekRange = (weekDays) => {
if (!weekDays.length) return '';
const start = weekDays[0];
const end = weekDays[6];
return `${formatDate(start)} - ${formatDate(end)}, ${end.getFullYear()}`;
};
export const getDayName = (date) => {
return date.toLocaleDateString('en-US', { weekday: 'long' });
};
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})