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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user