v2.1.0: Copy Previous Week, Bulk Approve, Overtime Tracking
Features: - Copy Previous Week: one-tap to duplicate last week's entries - Bulk Approve/Reject: multi-select + batch actions for admin reviews - Overtime Tracking: admin report tab + employee real-time OT warnings - Homeowner filter on reports - Homeowner edit/search/address fields - Employee role management + deactivate/reactivate - Reopen locked timesheets Fixes: - Timezone bug (UTC vs local date parsing) - CORS, approve 400, PDF download, auto-save errors - History page crash, rate limiting
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Coastal Timesheet will be documented in this file.
|
||||
|
||||
## [2.1.0] - 2026-02-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Copy Previous Week** — One-tap button to duplicate last week's entries into the current week. Shifts dates automatically, skips deactivated homeowners. Shows on empty weeks only.
|
||||
- **Bulk Approve / Reject** — Admin Reviews tab now supports multi-select with "Select All" checkbox and bulk action buttons. Process 40+ timesheets in one click instead of one-by-one.
|
||||
- **Overtime Tracking** — New admin "Overtime" tab with per-employee weekly breakdown, total/regular/OT summary cards, and date range filtering. Employee timesheet page shows real-time amber warning banner when hours exceed 40h/week (FLSA threshold).
|
||||
- **Homeowner Filter on Reports** — Admin Reports tab now includes a homeowner dropdown filter to see all time logged at a specific property.
|
||||
- **Homeowner Address Field** — Homeowners now have a separate address field. House numbers split from names into dedicated address column.
|
||||
- **Homeowner Edit/Search** — Full inline editing for homeowner name and address, search bar, toggle active/inactive with "Show inactive" filter.
|
||||
- **Employee Edit/Delete** — Admin Users tab now has role dropdown (employee/admin), deactivate button (soft-delete preserving history), and reactivate option.
|
||||
- **Reopen Timesheet** — `PUT /api/admin/timesheets/:id/reopen` endpoint allows admins to unlock approved/rejected timesheets for corrections.
|
||||
- **Timesheet History** — `GET /api/timesheets/history` endpoint returns all user timesheets for the History page.
|
||||
- **Public Homeowners API** — `GET /api/homeowners` endpoint for authenticated users (used by timesheet entry form).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Timezone bug** — `new Date("YYYY-MM-DD")` parsed as UTC, causing wrong day names in US timezones. All date parsing now uses local time.
|
||||
- **CORS policy violation** — Added Tailscale IP to allowed CORS origins.
|
||||
- **Approve button 400 error** — Frontend now sends `{}` body on approve (validation required an object).
|
||||
- **PDF download silent failure** — Frontend was using `timesheet.id` but API returns `timesheetId`. Fixed field mapping.
|
||||
- **Auto-save errors on incomplete entries** — Auto-save now only fires when all required fields (homeowner, hours, description) are filled. Incomplete entries show amber border indicator.
|
||||
- **History page crash** — `.map is not a function` when API returned object instead of array. Added dedicated history endpoint.
|
||||
- **Rate limit too aggressive** — Auth endpoint bumped from 5 to 50 attempts per 15 min for development.
|
||||
|
||||
### API Endpoints Added
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/entries/copy-week` | Copy entries from one week to another |
|
||||
| POST | `/api/admin/timesheets/bulk-approve` | Approve multiple timesheets |
|
||||
| POST | `/api/admin/timesheets/bulk-reject` | Reject multiple timesheets |
|
||||
| GET | `/api/admin/overtime` | Overtime report with filters |
|
||||
| GET | `/api/timesheets/overtime` | Employee's own overtime for a week |
|
||||
| PUT | `/api/admin/timesheets/:id/reopen` | Reopen locked timesheet |
|
||||
| DELETE | `/api/admin/users/:id` | Deactivate employee (soft-delete) |
|
||||
| GET | `/api/timesheets/history` | All user timesheets |
|
||||
| GET | `/api/homeowners` | Public homeowner list |
|
||||
|
||||
## [2.0.0] - 2026-02-15
|
||||
|
||||
### Added
|
||||
|
||||
- Complete rewrite from static HTML to production-grade stack
|
||||
- React 18 + Vite + Tailwind CSS (mobile-first design)
|
||||
- Express + Prisma + PostgreSQL backend
|
||||
- JWT authentication with role-based access (employee, admin, super_admin)
|
||||
- Weekly Mon–Sun timesheets with auto-save (800ms debounce)
|
||||
- Multiple homeowner entries per day
|
||||
- Submit → Approve/Reject workflow
|
||||
- Server-side PDF generation (@react-pdf/renderer)
|
||||
- SMTP email integration with branded HTML templates
|
||||
- Admin panel with employee/homeowner management
|
||||
- Reporting with employee, status, and date range filters
|
||||
- Dark mode (system-aware + manual toggle)
|
||||
- Docker Compose single-command deployment
|
||||
- Non-root containers, Helmet security headers, bcrypt, Zod validation
|
||||
- Rate limiting on auth endpoints
|
||||
- 46 homeowners seeded from v1 data
|
||||
@@ -25,11 +25,15 @@
|
||||
- **🔐 JWT authentication** — Secure login with access/refresh tokens + rate limiting
|
||||
- **📅 Weekly timesheets** — Monday–Sunday pay period with auto-save
|
||||
- **🏠 Multiple homeowners per day** — Track work at different job sites
|
||||
- **📋 Copy Previous Week** — One tap to duplicate last week's entries as a template
|
||||
- **✅ Submit → Approve workflow** — Employees submit, admins approve or reject
|
||||
- **⚡ Bulk Approve/Reject** — Select all + approve 40 timesheets in one click
|
||||
- **⏱️ Overtime Tracking** — Real-time OT warnings for employees, admin reports with per-employee weekly breakdown
|
||||
- **📄 PDF generation** — Professional server-side PDF export
|
||||
- **📧 Email integration** — Send timesheets via email with SMTP
|
||||
- **👥 Admin panel** — Manage employees, homeowners, review timesheets
|
||||
- **📊 Reporting** — Filter by employee, date range, status with summary stats
|
||||
- **📊 Reporting** — Filter by employee, homeowner, date range, status
|
||||
- **🏡 Homeowner management** — Add, edit, search, activate/deactivate with address fields
|
||||
- **🌙 Dark mode** — System-aware with manual toggle
|
||||
- **🐳 One-command deploy** — Single `docker compose up` for the entire stack
|
||||
|
||||
@@ -40,26 +44,39 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>Timesheet Entry</strong></td>
|
||||
<td align="center"><strong>Entry Form</strong></td>
|
||||
<td align="center"><strong>Copy Previous Week</strong></td>
|
||||
<td align="center"><strong>Dark Mode</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/02-timesheet-mobile.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/03-entry-form-mobile.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/08-timesheet-copy-week.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/07-dark-mode-mobile.png" width="250" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>Admin Panel</strong></td>
|
||||
<td align="center"><strong>Bulk Approve</strong></td>
|
||||
<td align="center"><strong>Overtime Tracking</strong></td>
|
||||
<td align="center"><strong>Reports & Filters</strong></td>
|
||||
<td align="center"><strong>History</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/05-admin-mobile.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/06-admin-reports-mobile.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/09-admin-bulk-approve.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/10-admin-overtime.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/12-admin-reports-filters.png" width="250" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>Homeowner Management</strong></td>
|
||||
<td align="center"><strong>History</strong></td>
|
||||
<td align="center"><strong>Entry Form</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/11-admin-homeowners.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/04-history-mobile.png" width="250" /></td>
|
||||
<td><img src="docs/screenshots/03-entry-form-mobile.png" width="250" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -141,6 +141,65 @@ router.get('/timesheets/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/admin/timesheets/bulk-approve ───────────────
|
||||
router.post('/timesheets/bulk-approve', async (req, res) => {
|
||||
try {
|
||||
const { timesheetIds, notes } = req.body;
|
||||
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
|
||||
return res.status(400).json({ error: 'timesheetIds array is required' });
|
||||
}
|
||||
if (timesheetIds.length > 100) {
|
||||
return res.status(400).json({ error: 'Maximum 100 timesheets per batch' });
|
||||
}
|
||||
|
||||
const results = { approved: 0, failed: [] };
|
||||
for (const id of timesheetIds) {
|
||||
try {
|
||||
const ts = await req.prisma.timesheet.findUnique({ where: { id } });
|
||||
if (!ts) { results.failed.push({ id, reason: 'Not found' }); continue; }
|
||||
if (ts.status !== 'submitted') { results.failed.push({ id, reason: `Status is "${ts.status}", expected "submitted"` }); continue; }
|
||||
await req.prisma.timesheet.update({
|
||||
where: { id },
|
||||
data: { status: 'approved', approvedBy: req.user.id, approvedAt: new Date(), notes: notes || null },
|
||||
});
|
||||
results.approved++;
|
||||
} catch (e) { results.failed.push({ id, reason: e.message }); }
|
||||
}
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
console.error('Bulk approve error:', err);
|
||||
res.status(500).json({ error: 'Failed to bulk approve' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/admin/timesheets/bulk-reject ───────────────
|
||||
router.post('/timesheets/bulk-reject', async (req, res) => {
|
||||
try {
|
||||
const { timesheetIds, notes } = req.body;
|
||||
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
|
||||
return res.status(400).json({ error: 'timesheetIds array is required' });
|
||||
}
|
||||
|
||||
const results = { rejected: 0, failed: [] };
|
||||
for (const id of timesheetIds) {
|
||||
try {
|
||||
const ts = await req.prisma.timesheet.findUnique({ where: { id } });
|
||||
if (!ts) { results.failed.push({ id, reason: 'Not found' }); continue; }
|
||||
if (ts.status !== 'submitted') { results.failed.push({ id, reason: `Status is "${ts.status}"` }); continue; }
|
||||
await req.prisma.timesheet.update({
|
||||
where: { id },
|
||||
data: { status: 'rejected', approvedBy: req.user.id, approvedAt: new Date(), notes: notes || 'Rejected' },
|
||||
});
|
||||
results.rejected++;
|
||||
} catch (e) { results.failed.push({ id, reason: e.message }); }
|
||||
}
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
console.error('Bulk reject error:', err);
|
||||
res.status(500).json({ error: 'Failed to bulk reject' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
|
||||
router.put(
|
||||
'/timesheets/:id/approve',
|
||||
@@ -379,6 +438,25 @@ router.put('/users/:id', async (req, res) => {
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ─────────────── DELETE /api/admin/users/:id (deactivate) ───────────────
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (id === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
const user = await req.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Soft-delete: deactivate instead of hard delete (preserves timesheet history)
|
||||
await req.prisma.user.update({ where: { id }, data: { isActive: false } });
|
||||
res.json({ message: 'User deactivated', id });
|
||||
} catch (err) {
|
||||
console.error('Delete user error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
}
|
||||
});
|
||||
|
||||
// HOMEOWNERS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -681,4 +759,105 @@ router.get('/reports/pdf', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OVERTIME
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ─────────────── GET /api/admin/overtime ───────────────
|
||||
router.get('/overtime', async (req, res) => {
|
||||
try {
|
||||
const { from, to, userId } = req.query;
|
||||
const weeklyThreshold = parseFloat(req.query.threshold || '40');
|
||||
const overtimeRate = parseFloat(req.query.rate || '1.5');
|
||||
|
||||
const where = {};
|
||||
if (userId) where.userId = userId;
|
||||
if (from || to) {
|
||||
where.date = {};
|
||||
if (from) where.date.gte = new Date(from + 'T00:00:00Z');
|
||||
if (to) where.date.lte = new Date(to + 'T23:59:59Z');
|
||||
}
|
||||
|
||||
const entries = await req.prisma.timeEntry.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: [{ userId: 'asc' }, { date: 'asc' }],
|
||||
});
|
||||
|
||||
// Group by user → week
|
||||
const userWeeks = {};
|
||||
for (const e of entries) {
|
||||
const uid = e.userId;
|
||||
const d = new Date(e.date);
|
||||
const day = d.getUTCDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
const monday = new Date(d);
|
||||
monday.setUTCDate(d.getUTCDate() + mondayOffset);
|
||||
const weekKey = monday.toISOString().split('T')[0];
|
||||
|
||||
if (!userWeeks[uid]) userWeeks[uid] = { user: e.user, weeks: {} };
|
||||
if (!userWeeks[uid].weeks[weekKey]) userWeeks[uid].weeks[weekKey] = { totalHours: 0, entries: 0 };
|
||||
userWeeks[uid].weeks[weekKey].totalHours += parseFloat(e.hoursWorked) || 0;
|
||||
userWeeks[uid].weeks[weekKey].entries++;
|
||||
}
|
||||
|
||||
// Compute overtime per user
|
||||
const results = Object.values(userWeeks).map((uw) => {
|
||||
let totalRegular = 0;
|
||||
let totalOvertime = 0;
|
||||
let totalHours = 0;
|
||||
const weekBreakdown = [];
|
||||
|
||||
for (const [week, data] of Object.entries(uw.weeks)) {
|
||||
const regular = Math.min(data.totalHours, weeklyThreshold);
|
||||
const overtime = Math.max(0, data.totalHours - weeklyThreshold);
|
||||
totalRegular += regular;
|
||||
totalOvertime += overtime;
|
||||
totalHours += data.totalHours;
|
||||
weekBreakdown.push({
|
||||
week,
|
||||
totalHours: parseFloat(data.totalHours.toFixed(2)),
|
||||
regularHours: parseFloat(regular.toFixed(2)),
|
||||
overtimeHours: parseFloat(overtime.toFixed(2)),
|
||||
entries: data.entries,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user: uw.user,
|
||||
summary: {
|
||||
totalHours: parseFloat(totalHours.toFixed(2)),
|
||||
regularHours: parseFloat(totalRegular.toFixed(2)),
|
||||
overtimeHours: parseFloat(totalOvertime.toFixed(2)),
|
||||
overtimeCost: parseFloat((totalOvertime * overtimeRate).toFixed(2)),
|
||||
weeksWithOvertime: weekBreakdown.filter((w) => w.overtimeHours > 0).length,
|
||||
},
|
||||
weeks: weekBreakdown,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by most overtime first
|
||||
results.sort((a, b) => b.summary.overtimeHours - a.summary.overtimeHours);
|
||||
|
||||
res.json({
|
||||
config: { weeklyThreshold, overtimeRate },
|
||||
employees: results,
|
||||
totals: {
|
||||
totalHours: parseFloat(results.reduce((s, r) => s + r.summary.totalHours, 0).toFixed(2)),
|
||||
regularHours: parseFloat(results.reduce((s, r) => s + r.summary.regularHours, 0).toFixed(2)),
|
||||
overtimeHours: parseFloat(results.reduce((s, r) => s + r.summary.overtimeHours, 0).toFixed(2)),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Overtime report error:', err);
|
||||
res.status(500).json({ error: 'Failed to generate overtime report' });
|
||||
}
|
||||
});
|
||||
|
||||
// Employee sees their own overtime
|
||||
// ─────────────── GET /api/timesheets/overtime ───────────────
|
||||
// (mounted at /api/timesheets/overtime in timesheets router)
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -268,4 +268,92 @@ router.delete('/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/entries/copy-week ───────────────
|
||||
router.post('/copy-week', async (req, res) => {
|
||||
try {
|
||||
const { fromWeek, toWeek } = req.body;
|
||||
if (!fromWeek || !toWeek) {
|
||||
return res.status(400).json({ error: 'fromWeek and toWeek are required (YYYY-MM-DD Monday)' });
|
||||
}
|
||||
|
||||
const fromMonday = getWeekMonday(fromWeek);
|
||||
const toMonday = getWeekMonday(toWeek);
|
||||
const fromSunday = getWeekSunday(fromMonday);
|
||||
|
||||
// Check target week isn't locked
|
||||
const targetTs = await req.prisma.timesheet.findUnique({
|
||||
where: { userId_weekStart: { userId: req.user.id, weekStart: new Date(toMonday + 'T00:00:00Z') } },
|
||||
});
|
||||
if (targetTs && ['submitted', 'approved'].includes(targetTs.status)) {
|
||||
return res.status(403).json({ error: 'Target week is locked (submitted or approved)' });
|
||||
}
|
||||
|
||||
// Get source week entries
|
||||
const sourceEntries = await req.prisma.timeEntry.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
date: {
|
||||
gte: new Date(fromMonday + 'T00:00:00Z'),
|
||||
lte: new Date(fromSunday + 'T00:00:00Z'),
|
||||
},
|
||||
},
|
||||
include: { homeowner: { select: { id: true, isActive: true } } },
|
||||
});
|
||||
|
||||
if (sourceEntries.length === 0) {
|
||||
return res.status(404).json({ error: 'No entries found in source week' });
|
||||
}
|
||||
|
||||
// Calculate day offset (Mon=0 ... Sun=6)
|
||||
const fromStart = new Date(fromMonday + 'T00:00:00Z');
|
||||
const toStart = new Date(toMonday + 'T00:00:00Z');
|
||||
const dayOffset = Math.round((toStart - fromStart) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Delete existing entries in target week (that are in draft)
|
||||
const toSunday = getWeekSunday(toMonday);
|
||||
await req.prisma.timeEntry.deleteMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
date: {
|
||||
gte: new Date(toMonday + 'T00:00:00Z'),
|
||||
lte: new Date(toSunday + 'T00:00:00Z'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Copy entries with shifted dates, only for active homeowners
|
||||
const created = [];
|
||||
for (const entry of sourceEntries) {
|
||||
if (!entry.homeowner.isActive) continue;
|
||||
const oldDate = new Date(entry.date);
|
||||
const newDate = new Date(oldDate);
|
||||
newDate.setUTCDate(newDate.getUTCDate() + dayOffset);
|
||||
|
||||
const newEntry = await req.prisma.timeEntry.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
date: newDate,
|
||||
homeownerId: entry.homeownerId,
|
||||
hoursWorked: entry.hoursWorked,
|
||||
workDescription: entry.workDescription,
|
||||
},
|
||||
include: { homeowner: { select: { name: true } } },
|
||||
});
|
||||
created.push({
|
||||
id: newEntry.id,
|
||||
date: newEntry.date.toISOString().split('T')[0],
|
||||
homeownerId: newEntry.homeownerId,
|
||||
homeownerName: newEntry.homeowner.name,
|
||||
hoursWorked: newEntry.hoursWorked,
|
||||
workDescription: newEntry.workDescription,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({ copied: created.length, entries: created });
|
||||
} catch (err) {
|
||||
console.error('Copy week error:', err);
|
||||
res.status(500).json({ error: 'Failed to copy week' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -306,4 +306,36 @@ router.post('/:id/email', validateBody(emailTimesheetSchema), async (req, res) =
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── GET /api/timesheets/overtime ───────────────
|
||||
router.get('/overtime', async (req, res) => {
|
||||
try {
|
||||
const weekParam = req.query.week;
|
||||
const monday = weekParam ? getWeekMonday(weekParam) : getWeekMonday(new Date().toISOString().split('T')[0]);
|
||||
const sunday = getWeekSunday(monday);
|
||||
|
||||
const entries = await req.prisma.timeEntry.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
date: { gte: new Date(monday + 'T00:00:00Z'), lte: new Date(sunday + 'T00:00:00Z') },
|
||||
},
|
||||
});
|
||||
|
||||
const totalHours = entries.reduce((s, e) => s + (parseFloat(e.hoursWorked) || 0), 0);
|
||||
const threshold = 40;
|
||||
const regular = Math.min(totalHours, threshold);
|
||||
const overtime = Math.max(0, totalHours - threshold);
|
||||
|
||||
res.json({
|
||||
week: monday,
|
||||
totalHours: parseFloat(totalHours.toFixed(2)),
|
||||
regularHours: parseFloat(regular.toFixed(2)),
|
||||
overtimeHours: parseFloat(overtime.toFixed(2)),
|
||||
threshold,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Employee overtime error:', err);
|
||||
res.status(500).json({ error: 'Failed to get overtime' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 781 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
+313
-36
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../api/client';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3 } from 'lucide-react';
|
||||
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle } from 'lucide-react';
|
||||
|
||||
function TabButton({ active, onClick, icon: Icon, label, count }) {
|
||||
return (
|
||||
@@ -28,6 +28,8 @@ function PendingReviews() {
|
||||
const [timesheets, setTimesheets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionId, setActionId] = useState(null);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [bulkAction, setBulkAction] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadPending();
|
||||
@@ -44,10 +46,52 @@ function PendingReviews() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selected.size === timesheets.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(timesheets.map((t) => t.id)));
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkApprove() {
|
||||
if (selected.size === 0) return;
|
||||
setBulkAction(true);
|
||||
try {
|
||||
const res = await api.post('/admin/timesheets/bulk-approve', { timesheetIds: [...selected] });
|
||||
alert(`Approved: ${res.data.approved}${res.data.failed?.length ? `, Failed: ${res.data.failed.length}` : ''}`);
|
||||
setSelected(new Set());
|
||||
loadPending();
|
||||
} catch (err) { alert('Bulk approve failed'); }
|
||||
finally { setBulkAction(false); }
|
||||
}
|
||||
|
||||
async function bulkReject() {
|
||||
if (selected.size === 0) return;
|
||||
const notes = prompt('Rejection reason:');
|
||||
if (notes === null) return;
|
||||
setBulkAction(true);
|
||||
try {
|
||||
const res = await api.post('/admin/timesheets/bulk-reject', { timesheetIds: [...selected], notes });
|
||||
alert(`Rejected: ${res.data.rejected}`);
|
||||
setSelected(new Set());
|
||||
loadPending();
|
||||
} catch (err) { alert('Bulk reject failed'); }
|
||||
finally { setBulkAction(false); }
|
||||
}
|
||||
|
||||
async function approve(id) {
|
||||
setActionId(id);
|
||||
try {
|
||||
await api.put(`/admin/timesheets/${id}/approve`);
|
||||
await api.put(`/admin/timesheets/${id}/approve`, {});
|
||||
setTimesheets((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to approve');
|
||||
@@ -83,19 +127,44 @@ function PendingReviews() {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Bulk action bar */}
|
||||
{timesheets.length > 1 && (
|
||||
<div className="flex items-center justify-between bg-gray-50 dark:bg-gray-800/50 rounded-xl px-4 py-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" checked={selected.size === timesheets.length && timesheets.length > 0} onChange={toggleSelectAll} className="rounded" />
|
||||
{selected.size > 0 ? `${selected.size} selected` : 'Select all'}
|
||||
</label>
|
||||
{selected.size > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={bulkApprove} disabled={bulkAction} className="px-3 py-1.5 rounded-lg bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600 disabled:opacity-50 flex items-center gap-1">
|
||||
<Check size={14} /> Approve All
|
||||
</button>
|
||||
<button onClick={bulkReject} disabled={bulkAction} className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-xs font-medium hover:bg-red-600 disabled:opacity-50 flex items-center gap-1">
|
||||
<X size={14} /> Reject All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timesheets.map((ts) => {
|
||||
const start = new Date(ts.weekStart);
|
||||
const end = new Date(ts.weekEnd);
|
||||
const [sy, sm, sd] = (ts.weekStart || '').split('-').map(Number);
|
||||
const [ey, em, ed] = (ts.weekEnd || '').split('-').map(Number);
|
||||
const start = new Date(sy, sm - 1, sd);
|
||||
const end = new Date(ey, em - 1, ed);
|
||||
const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return (
|
||||
<div key={ts.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" checked={selected.has(ts.id)} onChange={() => toggleSelect(ts.id)} className="rounded" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{ts.userName || ts.user?.name || 'Employee'}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => approve(ts.id)}
|
||||
@@ -188,18 +257,51 @@ function ManageUsers() {
|
||||
)}
|
||||
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between">
|
||||
<div key={u.id} className={`bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 ${u.isActive === false ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{u.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{u.email}</div>
|
||||
</div>
|
||||
<span className={`text-xs font-medium px-2.5 py-1 rounded-full ${
|
||||
u.role === 'super_admin' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400' :
|
||||
u.role === 'admin' ? 'bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400' :
|
||||
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${u.id}`, { role: e.target.value });
|
||||
loadUsers();
|
||||
} catch (err) { alert(err.response?.data?.error || 'Failed'); }
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="employee">Employee</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{u.isActive === false ? (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try { await api.put(`/admin/users/${u.id}`, { isActive: true }); loadUsers(); }
|
||||
catch (err) { alert('Failed to reactivate'); }
|
||||
}}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 font-medium"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Deactivate ${u.name}? They won't be able to log in.`)) return;
|
||||
try { await api.delete(`/admin/users/${u.id}`); loadUsers(); }
|
||||
catch (err) { alert(err.response?.data?.error || 'Failed'); }
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
|
||||
title="Deactivate"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -209,14 +311,19 @@ function ManageUsers() {
|
||||
function ManageHomeowners() {
|
||||
const [homeowners, setHomeowners] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ name: '', address: '' });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', address: '' });
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => { loadHomeowners(); }, []);
|
||||
useEffect(() => { loadHomeowners(); }, [showInactive]);
|
||||
|
||||
async function loadHomeowners() {
|
||||
try {
|
||||
const res = await api.get('/admin/homeowners');
|
||||
const res = await api.get(`/admin/homeowners?includeInactive=${showInactive}`);
|
||||
setHomeowners(res.data.homeowners || res.data || []);
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setLoading(false); }
|
||||
@@ -224,42 +331,113 @@ function ManageHomeowners() {
|
||||
|
||||
async function addHomeowner(e) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
if (!newForm.name.trim()) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await api.post('/admin/homeowners', { name: newName.trim() });
|
||||
setNewName('');
|
||||
await api.post('/admin/homeowners', { name: newForm.name.trim(), address: newForm.address.trim() || undefined });
|
||||
setNewForm({ name: '', address: '' });
|
||||
setShowAdd(false);
|
||||
loadHomeowners();
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to add');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
} finally { setAdding(false); }
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
try {
|
||||
await api.put(`/admin/homeowners/${id}`, { name: editForm.name.trim(), address: editForm.address.trim() || null });
|
||||
setEditId(null);
|
||||
loadHomeowners();
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to update');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(h) {
|
||||
try {
|
||||
await api.put(`/admin/homeowners/${h.id}`, { isActive: !h.isActive });
|
||||
loadHomeowners();
|
||||
} catch (err) { alert('Failed'); }
|
||||
}
|
||||
|
||||
const filtered = homeowners.filter((h) =>
|
||||
h.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(h.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<form onSubmit={addHomeowner} className="flex gap-2">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="New homeowner name..."
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button type="submit" disabled={adding || !newName.trim()} className="px-4 py-2.5 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50 flex items-center gap-1.5">
|
||||
{/* Search + Controls */}
|
||||
<div className="flex gap-2">
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search homeowners..." className="flex-1 px-4 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="px-3 py-2.5 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 flex items-center gap-1.5">
|
||||
<Plus size={16} /> Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<input type="checkbox" checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} className="rounded" />
|
||||
Show inactive homeowners
|
||||
</label>
|
||||
|
||||
{/* Add Form */}
|
||||
{showAdd && (
|
||||
<form onSubmit={addHomeowner} className="bg-sky-50/50 dark:bg-sky-500/5 rounded-2xl border border-sky-200 dark:border-sky-500/20 p-4 space-y-3">
|
||||
<input value={newForm.name} onChange={(e) => setNewForm({ ...newForm, name: e.target.value })} required placeholder="Homeowner name (e.g. Smith, 142)" className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<input value={newForm.address} onChange={(e) => setNewForm({ ...newForm, address: e.target.value })} placeholder="Address (optional)" className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 rounded-xl text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
|
||||
<button type="submit" disabled={adding} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50">{adding ? 'Adding...' : 'Add Homeowner'}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{filtered.length} homeowners</div>
|
||||
|
||||
{/* Homeowner List */}
|
||||
<div className="space-y-1">
|
||||
{homeowners.map((h) => (
|
||||
<div key={h.id} className="flex items-center justify-between px-4 py-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm text-gray-900 dark:text-white">{h.name}</span>
|
||||
<span className={`text-xs ${h.isActive !== false ? 'text-emerald-500' : 'text-gray-400'}`}>
|
||||
{filtered.map((h) => (
|
||||
<div key={h.id} className={`rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 ${h.isActive === false ? 'opacity-50' : ''}`}>
|
||||
{editId === h.id ? (
|
||||
/* Edit Mode */
|
||||
<div className="p-3 space-y-2">
|
||||
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} className="w-full px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Name" />
|
||||
<input value={editForm.address} onChange={(e) => setEditForm({ ...editForm, address: e.target.value })} className="w-full px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Address" />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setEditId(null)} className="px-3 py-1.5 rounded-lg text-xs text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
|
||||
<button onClick={() => saveEdit(h.id)} className="px-3 py-1.5 rounded-lg bg-sky-500 text-white text-xs font-medium hover:bg-sky-600">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View Mode */
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{h.name}</div>
|
||||
{h.address && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">#{h.address}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<button
|
||||
onClick={() => { setEditId(h.id); setEditForm({ name: h.name, address: h.address || '' }); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-all"
|
||||
title="Edit"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleActive(h)}
|
||||
className={`text-[10px] px-2 py-1 rounded-lg font-medium transition-all ${
|
||||
h.isActive !== false
|
||||
? 'text-emerald-600 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10'
|
||||
: 'text-gray-400 bg-gray-100 dark:bg-gray-800 hover:bg-emerald-50 hover:text-emerald-500 dark:hover:bg-emerald-500/10'
|
||||
}`}
|
||||
>
|
||||
{h.isActive !== false ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -271,7 +449,8 @@ function Reports() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [timesheets, setTimesheets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({ userId: '', status: '', startDate: '', endDate: '' });
|
||||
const [homeowners, setHomeowners] = useState([]);
|
||||
const [filters, setFilters] = useState({ userId: '', status: '', startDate: '', endDate: '', homeownerId: '' });
|
||||
const [selectedTs, setSelectedTs] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
@@ -280,9 +459,11 @@ function Reports() {
|
||||
Promise.all([
|
||||
api.get('/admin/users'),
|
||||
api.get('/admin/timesheets'),
|
||||
]).then(([usersRes, tsRes]) => {
|
||||
api.get('/admin/homeowners?includeInactive=true'),
|
||||
]).then(([usersRes, tsRes, hoRes]) => {
|
||||
setUsers(usersRes.data.users || usersRes.data || []);
|
||||
setTimesheets(tsRes.data.timesheets || tsRes.data || []);
|
||||
setHomeowners(hoRes.data.homeowners || hoRes.data || []);
|
||||
}).catch(console.error).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -292,6 +473,9 @@ function Reports() {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.userId) params.set('userId', filters.userId);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.homeownerId) params.set('homeownerId', filters.homeownerId);
|
||||
if (filters.startDate) params.set('from', filters.startDate);
|
||||
if (filters.endDate) params.set('to', filters.endDate);
|
||||
const res = await api.get(`/admin/timesheets?${params}`);
|
||||
setTimesheets(res.data.timesheets || res.data || []);
|
||||
} catch (err) { console.error(err); }
|
||||
@@ -360,6 +544,10 @@ function Reports() {
|
||||
<option value="">All Employees</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
<select value={filters.homeownerId} onChange={(e) => setFilters({ ...filters, homeownerId: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||
<option value="">All Homeowners</option>
|
||||
{homeowners.map((h) => <option key={h.id} value={h.id}>{h.name}{h.address ? ` #${h.address}` : ''}</option>)}
|
||||
</select>
|
||||
<select value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
|
||||
<option value="">All Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
@@ -367,6 +555,7 @@ function Reports() {
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<div></div>
|
||||
<input type="date" value={filters.startDate} onChange={(e) => setFilters({ ...filters, startDate: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Start" />
|
||||
<input type="date" value={filters.endDate} onChange={(e) => setFilters({ ...filters, endDate: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="End" />
|
||||
</div>
|
||||
@@ -450,6 +639,92 @@ function Reports() {
|
||||
);
|
||||
}
|
||||
|
||||
function OvertimeReport() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
|
||||
useEffect(() => { loadOvertime(); }, []);
|
||||
|
||||
async function loadOvertime() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (dateRange.from) params.set('from', dateRange.from);
|
||||
if (dateRange.to) params.set('to', dateRange.to);
|
||||
const res = await api.get(`/admin/overtime?${params}`);
|
||||
setData(res.data);
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||
if (!data) return <p className="text-center py-10 text-gray-400">Failed to load</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Date filters */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||
<input type="date" value={dateRange.from} onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })} className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400">To</label>
|
||||
<input type="date" value={dateRange.to} onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })} className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<button onClick={loadOvertime} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600">Filter</button>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-sky-50 dark:bg-sky-500/10 rounded-xl p-3 text-center">
|
||||
<div className="text-xl font-bold text-sky-600 dark:text-sky-400">{data.totals.totalHours}</div>
|
||||
<div className="text-[10px] text-sky-500/70 font-medium">TOTAL HOURS</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 dark:bg-emerald-500/10 rounded-xl p-3 text-center">
|
||||
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{data.totals.regularHours}</div>
|
||||
<div className="text-[10px] text-emerald-500/70 font-medium">REGULAR</div>
|
||||
</div>
|
||||
<div className={`rounded-xl p-3 text-center ${data.totals.overtimeHours > 0 ? 'bg-amber-50 dark:bg-amber-500/10' : 'bg-gray-50 dark:bg-gray-800'}`}>
|
||||
<div className={`text-xl font-bold ${data.totals.overtimeHours > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400'}`}>{data.totals.overtimeHours}</div>
|
||||
<div className="text-[10px] text-amber-500/70 font-medium">OVERTIME</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employee list */}
|
||||
{data.employees.length === 0 && <p className="text-center py-6 text-gray-400 text-sm">No data for selected period</p>}
|
||||
{data.employees.map((emp) => (
|
||||
<div key={emp.user.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{emp.user.name}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{emp.summary.overtimeHours > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 px-2 py-1 rounded-lg">
|
||||
<AlertTriangle size={12} /> {emp.summary.overtimeHours}h OT
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">{emp.summary.totalHours}h</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Week breakdown */}
|
||||
<div className="space-y-1">
|
||||
{emp.weeks.map((w) => (
|
||||
<div key={w.week} className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Week of {w.week}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{w.regularHours}h reg</span>
|
||||
{w.overtimeHours > 0 && <span className="text-amber-500 font-medium">+{w.overtimeHours}h OT</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const [tab, setTab] = useState('reviews');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
@@ -467,12 +742,14 @@ export default function Admin() {
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
<TabButton active={tab === 'reviews'} onClick={() => setTab('reviews')} icon={Clock} label="Reviews" count={pendingCount} />
|
||||
<TabButton active={tab === 'reports'} onClick={() => setTab('reports')} icon={Download} label="Reports" count={0} />
|
||||
<TabButton active={tab === 'overtime'} onClick={() => setTab('overtime')} icon={TrendingUp} label="Overtime" count={0} />
|
||||
<TabButton active={tab === 'users'} onClick={() => setTab('users')} icon={Users} label="Users" count={0} />
|
||||
<TabButton active={tab === 'homeowners'} onClick={() => setTab('homeowners')} icon={Home} label="Homeowners" count={0} />
|
||||
</div>
|
||||
|
||||
{tab === 'reviews' && <PendingReviews />}
|
||||
{tab === 'reports' && <Reports />}
|
||||
{tab === 'overtime' && <OvertimeReport />}
|
||||
{tab === 'users' && <ManageUsers />}
|
||||
{tab === 'homeowners' && <ManageHomeowners />}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import WeekNavigator, { getWeekRange } from '../components/WeekNavigator';
|
||||
import DayCard from '../components/DayCard';
|
||||
import SaveIndicator from '../components/SaveIndicator';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { Send, Download, Mail, Loader2 } from 'lucide-react';
|
||||
import { Send, Download, Mail, Loader2, Copy, TrendingUp } from 'lucide-react';
|
||||
|
||||
function toLocalDateStr(d) {
|
||||
// Format as YYYY-MM-DD in local timezone (avoid UTC shift)
|
||||
@@ -41,6 +41,8 @@ export default function Timesheet() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [overtime, setOvertime] = useState(null);
|
||||
const saveTimer = useRef(null);
|
||||
const pendingChanges = useRef({});
|
||||
|
||||
@@ -179,6 +181,32 @@ export default function Timesheet() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch overtime for current week
|
||||
useEffect(() => {
|
||||
if (!weekParam) return;
|
||||
api.get(`/timesheets/overtime?week=${weekParam}`)
|
||||
.then((res) => setOvertime(res.data))
|
||||
.catch(() => setOvertime(null));
|
||||
}, [weekParam]);
|
||||
|
||||
async function handleCopyPrevWeek() {
|
||||
const prevMonday = new Date(weekDates[0] + 'T00:00:00');
|
||||
prevMonday.setDate(prevMonday.getDate() - 7);
|
||||
const fromWeek = toLocalDateStr(prevMonday);
|
||||
|
||||
if (!confirm(`Copy entries from week of ${fromWeek} to this week? This will replace any existing entries.`)) return;
|
||||
setCopying(true);
|
||||
try {
|
||||
const res = await api.post('/entries/copy-week', { fromWeek, toWeek: weekParam });
|
||||
alert(`Copied ${res.data.copied} entries!`);
|
||||
loadWeek();
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Failed to copy week');
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
@@ -245,6 +273,28 @@ export default function Timesheet() {
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">hours this week</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overtime indicator */}
|
||||
{overtime && overtime.overtimeHours > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
|
||||
<TrendingUp size={16} className="text-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
{overtime.overtimeHours.toFixed(1)}h overtime (over {overtime.threshold}h)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy previous week button */}
|
||||
{!isLocked && totalHours === 0 && (
|
||||
<button
|
||||
onClick={handleCopyPrevWeek}
|
||||
disabled={copying}
|
||||
className="w-full py-2.5 rounded-xl border-2 border-dashed border-sky-200 dark:border-sky-500/30 text-sky-600 dark:text-sky-400 text-sm font-medium flex items-center justify-center gap-2 hover:bg-sky-50 dark:hover:bg-sky-500/5 transition-all active:scale-[0.98]"
|
||||
>
|
||||
{copying ? <Loader2 size={16} className="animate-spin" /> : <Copy size={16} />}
|
||||
{copying ? 'Copying...' : 'Copy Previous Week'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day Cards */}
|
||||
|
||||
Reference in New Issue
Block a user