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
|
- **🔐 JWT authentication** — Secure login with access/refresh tokens + rate limiting
|
||||||
- **📅 Weekly timesheets** — Monday–Sunday pay period with auto-save
|
- **📅 Weekly timesheets** — Monday–Sunday pay period with auto-save
|
||||||
- **🏠 Multiple homeowners per day** — Track work at different job sites
|
- **🏠 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
|
- **✅ 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
|
- **📄 PDF generation** — Professional server-side PDF export
|
||||||
- **📧 Email integration** — Send timesheets via email with SMTP
|
- **📧 Email integration** — Send timesheets via email with SMTP
|
||||||
- **👥 Admin panel** — Manage employees, homeowners, review timesheets
|
- **👥 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
|
- **🌙 Dark mode** — System-aware with manual toggle
|
||||||
- **🐳 One-command deploy** — Single `docker compose up` for the entire stack
|
- **🐳 One-command deploy** — Single `docker compose up` for the entire stack
|
||||||
|
|
||||||
@@ -40,26 +44,39 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><strong>Timesheet Entry</strong></td>
|
<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>
|
<td align="center"><strong>Dark Mode</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="docs/screenshots/02-timesheet-mobile.png" width="250" /></td>
|
<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>
|
<td><img src="docs/screenshots/07-dark-mode-mobile.png" width="250" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<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>Reports & Filters</strong></td>
|
||||||
<td align="center"><strong>History</strong></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="docs/screenshots/05-admin-mobile.png" width="250" /></td>
|
<td><img src="docs/screenshots/09-admin-bulk-approve.png" width="250" /></td>
|
||||||
<td><img src="docs/screenshots/06-admin-reports-mobile.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/04-history-mobile.png" width="250" /></td>
|
||||||
|
<td><img src="docs/screenshots/03-entry-form-mobile.png" width="250" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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 ───────────────
|
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
|
||||||
router.put(
|
router.put(
|
||||||
'/timesheets/:id/approve',
|
'/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
|
// 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;
|
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;
|
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;
|
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 { useState, useEffect } from 'react';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
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 }) {
|
function TabButton({ active, onClick, icon: Icon, label, count }) {
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +28,8 @@ function PendingReviews() {
|
|||||||
const [timesheets, setTimesheets] = useState([]);
|
const [timesheets, setTimesheets] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionId, setActionId] = useState(null);
|
const [actionId, setActionId] = useState(null);
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [bulkAction, setBulkAction] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPending();
|
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) {
|
async function approve(id) {
|
||||||
setActionId(id);
|
setActionId(id);
|
||||||
try {
|
try {
|
||||||
await api.put(`/admin/timesheets/${id}/approve`);
|
await api.put(`/admin/timesheets/${id}/approve`, {});
|
||||||
setTimesheets((prev) => prev.filter((t) => t.id !== id));
|
setTimesheets((prev) => prev.filter((t) => t.id !== id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.response?.data?.error || 'Failed to approve');
|
alert(err.response?.data?.error || 'Failed to approve');
|
||||||
@@ -83,19 +127,44 @@ function PendingReviews() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<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) => {
|
{timesheets.map((ts) => {
|
||||||
const start = new Date(ts.weekStart);
|
const [sy, sm, sd] = (ts.weekStart || '').split('-').map(Number);
|
||||||
const end = new Date(ts.weekEnd);
|
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' });
|
const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
return (
|
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 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 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>
|
||||||
<div className="font-semibold text-gray-900 dark:text-white">{ts.userName || ts.user?.name || 'Employee'}</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">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''}
|
{fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => approve(ts.id)}
|
onClick={() => approve(ts.id)}
|
||||||
@@ -188,18 +257,51 @@ function ManageUsers() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{users.map((u) => (
|
{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>
|
||||||
<div className="font-medium text-gray-900 dark:text-white">{u.name}</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 className="text-sm text-gray-500 dark:text-gray-400">{u.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-medium px-2.5 py-1 rounded-full ${
|
<div className="flex items-center gap-2">
|
||||||
u.role === 'super_admin' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400' :
|
<select
|
||||||
u.role === 'admin' ? 'bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400' :
|
value={u.role}
|
||||||
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
|
onChange={async (e) => {
|
||||||
}`}>
|
try {
|
||||||
{u.role}
|
await api.put(`/admin/users/${u.id}`, { role: e.target.value });
|
||||||
</span>
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,14 +311,19 @@ function ManageUsers() {
|
|||||||
function ManageHomeowners() {
|
function ManageHomeowners() {
|
||||||
const [homeowners, setHomeowners] = useState([]);
|
const [homeowners, setHomeowners] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [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() {
|
async function loadHomeowners() {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/admin/homeowners');
|
const res = await api.get(`/admin/homeowners?includeInactive=${showInactive}`);
|
||||||
setHomeowners(res.data.homeowners || res.data || []);
|
setHomeowners(res.data.homeowners || res.data || []);
|
||||||
} catch (err) { console.error(err); }
|
} catch (err) { console.error(err); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
@@ -224,42 +331,113 @@ function ManageHomeowners() {
|
|||||||
|
|
||||||
async function addHomeowner(e) {
|
async function addHomeowner(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newName.trim()) return;
|
if (!newForm.name.trim()) return;
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/admin/homeowners', { name: newName.trim() });
|
await api.post('/admin/homeowners', { name: newForm.name.trim(), address: newForm.address.trim() || undefined });
|
||||||
setNewName('');
|
setNewForm({ name: '', address: '' });
|
||||||
|
setShowAdd(false);
|
||||||
loadHomeowners();
|
loadHomeowners();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.response?.data?.error || 'Failed to add');
|
alert(err.response?.data?.error || 'Failed to add');
|
||||||
} finally {
|
} finally { setAdding(false); }
|
||||||
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>;
|
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<form onSubmit={addHomeowner} className="flex gap-2">
|
{/* Search + Controls */}
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
value={newName}
|
<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" />
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
<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">
|
||||||
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">
|
|
||||||
<Plus size={16} /> Add
|
<Plus size={16} /> Add
|
||||||
</button>
|
</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">
|
<div className="space-y-1">
|
||||||
{homeowners.map((h) => (
|
{filtered.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">
|
<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' : ''}`}>
|
||||||
<span className="text-sm text-gray-900 dark:text-white">{h.name}</span>
|
{editId === h.id ? (
|
||||||
<span className={`text-xs ${h.isActive !== false ? 'text-emerald-500' : 'text-gray-400'}`}>
|
/* 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'}
|
{h.isActive !== false ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,7 +449,8 @@ function Reports() {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [timesheets, setTimesheets] = useState([]);
|
const [timesheets, setTimesheets] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [selectedTs, setSelectedTs] = useState(null);
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
@@ -280,9 +459,11 @@ function Reports() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
api.get('/admin/users'),
|
api.get('/admin/users'),
|
||||||
api.get('/admin/timesheets'),
|
api.get('/admin/timesheets'),
|
||||||
]).then(([usersRes, tsRes]) => {
|
api.get('/admin/homeowners?includeInactive=true'),
|
||||||
|
]).then(([usersRes, tsRes, hoRes]) => {
|
||||||
setUsers(usersRes.data.users || usersRes.data || []);
|
setUsers(usersRes.data.users || usersRes.data || []);
|
||||||
setTimesheets(tsRes.data.timesheets || tsRes.data || []);
|
setTimesheets(tsRes.data.timesheets || tsRes.data || []);
|
||||||
|
setHomeowners(hoRes.data.homeowners || hoRes.data || []);
|
||||||
}).catch(console.error).finally(() => setLoading(false));
|
}).catch(console.error).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -292,6 +473,9 @@ function Reports() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters.userId) params.set('userId', filters.userId);
|
if (filters.userId) params.set('userId', filters.userId);
|
||||||
if (filters.status) params.set('status', filters.status);
|
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}`);
|
const res = await api.get(`/admin/timesheets?${params}`);
|
||||||
setTimesheets(res.data.timesheets || res.data || []);
|
setTimesheets(res.data.timesheets || res.data || []);
|
||||||
} catch (err) { console.error(err); }
|
} catch (err) { console.error(err); }
|
||||||
@@ -360,6 +544,10 @@ function Reports() {
|
|||||||
<option value="">All Employees</option>
|
<option value="">All Employees</option>
|
||||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
</select>
|
</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">
|
<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="">All Status</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft</option>
|
||||||
@@ -367,6 +555,7 @@ function Reports() {
|
|||||||
<option value="approved">Approved</option>
|
<option value="approved">Approved</option>
|
||||||
<option value="rejected">Rejected</option>
|
<option value="rejected">Rejected</option>
|
||||||
</select>
|
</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.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" />
|
<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>
|
</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() {
|
export default function Admin() {
|
||||||
const [tab, setTab] = useState('reviews');
|
const [tab, setTab] = useState('reviews');
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
@@ -467,12 +742,14 @@ export default function Admin() {
|
|||||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
<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 === '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 === '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 === 'users'} onClick={() => setTab('users')} icon={Users} label="Users" count={0} />
|
||||||
<TabButton active={tab === 'homeowners'} onClick={() => setTab('homeowners')} icon={Home} label="Homeowners" count={0} />
|
<TabButton active={tab === 'homeowners'} onClick={() => setTab('homeowners')} icon={Home} label="Homeowners" count={0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'reviews' && <PendingReviews />}
|
{tab === 'reviews' && <PendingReviews />}
|
||||||
{tab === 'reports' && <Reports />}
|
{tab === 'reports' && <Reports />}
|
||||||
|
{tab === 'overtime' && <OvertimeReport />}
|
||||||
{tab === 'users' && <ManageUsers />}
|
{tab === 'users' && <ManageUsers />}
|
||||||
{tab === 'homeowners' && <ManageHomeowners />}
|
{tab === 'homeowners' && <ManageHomeowners />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import WeekNavigator, { getWeekRange } from '../components/WeekNavigator';
|
|||||||
import DayCard from '../components/DayCard';
|
import DayCard from '../components/DayCard';
|
||||||
import SaveIndicator from '../components/SaveIndicator';
|
import SaveIndicator from '../components/SaveIndicator';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
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) {
|
function toLocalDateStr(d) {
|
||||||
// Format as YYYY-MM-DD in local timezone (avoid UTC shift)
|
// 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 [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [pdfLoading, setPdfLoading] = useState(false);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const [copying, setCopying] = useState(false);
|
||||||
|
const [overtime, setOvertime] = useState(null);
|
||||||
const saveTimer = useRef(null);
|
const saveTimer = useRef(null);
|
||||||
const pendingChanges = useRef({});
|
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() {
|
async function handleSubmit() {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
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 className="text-xs text-gray-500 dark:text-gray-400">hours this week</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Day Cards */}
|
{/* Day Cards */}
|
||||||
|
|||||||
Reference in New Issue
Block a user