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
360 lines
11 KiB
JavaScript
360 lines
11 KiB
JavaScript
const express = require('express');
|
|
const { authenticate } = require('../middleware/auth');
|
|
const {
|
|
createEntrySchema,
|
|
updateEntrySchema,
|
|
weekQuerySchema,
|
|
validateBody,
|
|
validateQuery,
|
|
} = require('../utils/validation');
|
|
|
|
const router = express.Router();
|
|
|
|
// All routes require authentication
|
|
router.use(authenticate);
|
|
|
|
/**
|
|
* Get the Monday of the week containing the given date
|
|
*/
|
|
function getWeekMonday(dateStr) {
|
|
const d = dateStr ? new Date(dateStr + 'T00:00:00Z') : new Date();
|
|
const day = d.getUTCDay();
|
|
const diff = day === 0 ? -6 : 1 - day; // Monday = 1, Sunday = 0 → go back 6
|
|
d.setUTCDate(d.getUTCDate() + diff);
|
|
return d.toISOString().split('T')[0];
|
|
}
|
|
|
|
function getWeekSunday(mondayStr) {
|
|
const d = new Date(mondayStr + 'T00:00:00Z');
|
|
d.setUTCDate(d.getUTCDate() + 6);
|
|
return d.toISOString().split('T')[0];
|
|
}
|
|
|
|
// ─────────────── GET /api/entries?week=YYYY-MM-DD ───────────────
|
|
router.get('/', validateQuery(weekQuerySchema), async (req, res) => {
|
|
try {
|
|
const weekParam = req.validatedQuery.week;
|
|
const monday = getWeekMonday(weekParam);
|
|
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'),
|
|
},
|
|
},
|
|
include: {
|
|
homeowner: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
|
|
});
|
|
|
|
// Check if this week's timesheet is locked (submitted/approved)
|
|
const timesheet = await req.prisma.timesheet.findUnique({
|
|
where: {
|
|
userId_weekStart: {
|
|
userId: req.user.id,
|
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
|
},
|
|
},
|
|
select: { id: true, status: true },
|
|
});
|
|
|
|
const isLocked = timesheet
|
|
? ['submitted', 'approved'].includes(timesheet.status)
|
|
: false;
|
|
|
|
res.json({
|
|
entries: entries.map((e) => ({
|
|
id: e.id,
|
|
date: e.date.toISOString().split('T')[0],
|
|
homeownerId: e.homeownerId,
|
|
homeownerName: e.homeowner.name,
|
|
hoursWorked: parseFloat(e.hoursWorked),
|
|
workDescription: e.workDescription,
|
|
createdAt: e.createdAt,
|
|
updatedAt: e.updatedAt,
|
|
})),
|
|
weekStart: monday,
|
|
weekEnd: sunday,
|
|
isLocked,
|
|
timesheetStatus: timesheet?.status || 'draft',
|
|
});
|
|
} catch (err) {
|
|
console.error('Get entries error:', err);
|
|
res.status(500).json({ error: 'Failed to fetch entries' });
|
|
}
|
|
});
|
|
|
|
// ─────────────── POST /api/entries ───────────────
|
|
router.post('/', validateBody(createEntrySchema), async (req, res) => {
|
|
try {
|
|
const { date, homeownerId, hoursWorked, workDescription } = req.validated;
|
|
|
|
// Check if week is locked
|
|
const monday = getWeekMonday(date);
|
|
const timesheet = await req.prisma.timesheet.findUnique({
|
|
where: {
|
|
userId_weekStart: {
|
|
userId: req.user.id,
|
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
|
},
|
|
},
|
|
});
|
|
|
|
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
|
|
return res.status(403).json({
|
|
error: 'Cannot modify entries for a submitted or approved timesheet',
|
|
});
|
|
}
|
|
|
|
// Verify homeowner exists and is active
|
|
const homeowner = await req.prisma.homeowner.findUnique({
|
|
where: { id: homeownerId },
|
|
});
|
|
if (!homeowner || !homeowner.isActive) {
|
|
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
|
|
}
|
|
|
|
const entry = await req.prisma.timeEntry.create({
|
|
data: {
|
|
userId: req.user.id,
|
|
date: new Date(date + 'T00:00:00Z'),
|
|
homeownerId,
|
|
hoursWorked,
|
|
workDescription,
|
|
},
|
|
include: {
|
|
homeowner: { select: { id: true, name: true } },
|
|
},
|
|
});
|
|
|
|
res.status(201).json({
|
|
entry: {
|
|
id: entry.id,
|
|
date: entry.date.toISOString().split('T')[0],
|
|
homeownerId: entry.homeownerId,
|
|
homeownerName: entry.homeowner.name,
|
|
hoursWorked: parseFloat(entry.hoursWorked),
|
|
workDescription: entry.workDescription,
|
|
createdAt: entry.createdAt,
|
|
updatedAt: entry.updatedAt,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error('Create entry error:', err);
|
|
res.status(500).json({ error: 'Failed to create entry' });
|
|
}
|
|
});
|
|
|
|
// ─────────────── PUT /api/entries/:id ───────────────
|
|
router.put('/:id', validateBody(updateEntrySchema), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Verify ownership
|
|
const existing = await req.prisma.timeEntry.findUnique({ where: { id } });
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'Entry not found' });
|
|
}
|
|
if (existing.userId !== req.user.id) {
|
|
return res.status(403).json({ error: 'Not your entry' });
|
|
}
|
|
|
|
// Check if week is locked
|
|
const entryDate = existing.date.toISOString().split('T')[0];
|
|
const monday = getWeekMonday(req.validated.date || entryDate);
|
|
const timesheet = await req.prisma.timesheet.findUnique({
|
|
where: {
|
|
userId_weekStart: {
|
|
userId: req.user.id,
|
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
|
},
|
|
},
|
|
});
|
|
|
|
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
|
|
return res.status(403).json({
|
|
error: 'Cannot modify entries for a submitted or approved timesheet',
|
|
});
|
|
}
|
|
|
|
// Build update data
|
|
const updateData = {};
|
|
if (req.validated.date !== undefined) {
|
|
updateData.date = new Date(req.validated.date + 'T00:00:00Z');
|
|
}
|
|
if (req.validated.homeownerId !== undefined) {
|
|
const homeowner = await req.prisma.homeowner.findUnique({
|
|
where: { id: req.validated.homeownerId },
|
|
});
|
|
if (!homeowner || !homeowner.isActive) {
|
|
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
|
|
}
|
|
updateData.homeownerId = req.validated.homeownerId;
|
|
}
|
|
if (req.validated.hoursWorked !== undefined) {
|
|
updateData.hoursWorked = req.validated.hoursWorked;
|
|
}
|
|
if (req.validated.workDescription !== undefined) {
|
|
updateData.workDescription = req.validated.workDescription;
|
|
}
|
|
|
|
const entry = await req.prisma.timeEntry.update({
|
|
where: { id },
|
|
data: updateData,
|
|
include: {
|
|
homeowner: { select: { id: true, name: true } },
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
entry: {
|
|
id: entry.id,
|
|
date: entry.date.toISOString().split('T')[0],
|
|
homeownerId: entry.homeownerId,
|
|
homeownerName: entry.homeowner.name,
|
|
hoursWorked: parseFloat(entry.hoursWorked),
|
|
workDescription: entry.workDescription,
|
|
createdAt: entry.createdAt,
|
|
updatedAt: entry.updatedAt,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error('Update entry error:', err);
|
|
res.status(500).json({ error: 'Failed to update entry' });
|
|
}
|
|
});
|
|
|
|
// ─────────────── DELETE /api/entries/:id ───────────────
|
|
router.delete('/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const existing = await req.prisma.timeEntry.findUnique({ where: { id } });
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'Entry not found' });
|
|
}
|
|
if (existing.userId !== req.user.id) {
|
|
return res.status(403).json({ error: 'Not your entry' });
|
|
}
|
|
|
|
// Check if week is locked
|
|
const entryDate = existing.date.toISOString().split('T')[0];
|
|
const monday = getWeekMonday(entryDate);
|
|
const timesheet = await req.prisma.timesheet.findUnique({
|
|
where: {
|
|
userId_weekStart: {
|
|
userId: req.user.id,
|
|
weekStart: new Date(monday + 'T00:00:00Z'),
|
|
},
|
|
},
|
|
});
|
|
|
|
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
|
|
return res.status(403).json({
|
|
error: 'Cannot delete entries from a submitted or approved timesheet',
|
|
});
|
|
}
|
|
|
|
await req.prisma.timeEntry.delete({ where: { id } });
|
|
|
|
res.json({ message: 'Entry deleted' });
|
|
} catch (err) {
|
|
console.error('Delete entry error:', err);
|
|
res.status(500).json({ error: 'Failed to delete entry' });
|
|
}
|
|
});
|
|
|
|
// ─────────────── 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;
|