v2.0.0: Complete rewrite — React + Express + PostgreSQL + Docker

BREAKING: Full rewrite from static HTML to production-grade stack.

Features:
- React 18 + Vite + Tailwind CSS (mobile-first)
- Express + Prisma + PostgreSQL backend
- JWT authentication with role-based access
- Weekly Mon-Sun timesheets with auto-save
- Multiple homeowner entries per day
- Submit → Approve/Reject workflow
- Server-side PDF generation
- SMTP email integration
- Admin panel with reporting & filters
- Dark mode (system-aware)
- Docker Compose one-command deploy
- Non-root containers, Helmet, bcrypt, Zod validation
This commit is contained in:
BizzleBot
2026-02-15 20:16:46 +00:00
parent 68832bd958
commit a7c138add1
88 changed files with 5522 additions and 9159 deletions
+106
View File
@@ -0,0 +1,106 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
employee
admin
super_admin
}
enum TimesheetStatus {
draft
submitted
approved
rejected
}
model User {
id String @id @default(uuid())
email String @unique
name String
role Role @default(employee)
passwordHash String @map("password_hash")
isActive Boolean @default(true) @map("is_active")
refreshToken String? @map("refresh_token")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
timeEntries TimeEntry[]
timesheets Timesheet[] @relation("UserTimesheets")
approvals Timesheet[] @relation("ApprovedTimesheets")
@@map("users")
}
model Homeowner {
id String @id @default(uuid())
name String @unique
address String?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
timeEntries TimeEntry[]
@@map("homeowners")
}
model TimeEntry {
id String @id @default(uuid())
userId String @map("user_id")
date DateTime @db.Date
homeownerId String @map("homeowner_id")
hoursWorked Decimal @map("hours_worked") @db.Decimal(4, 2)
workDescription String @map("work_description")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
homeowner Homeowner @relation(fields: [homeownerId], references: [id])
timesheetLinks TimesheetEntry[]
@@index([userId, date])
@@index([homeownerId])
@@map("time_entries")
}
model Timesheet {
id String @id @default(uuid())
userId String @map("user_id")
weekStart DateTime @map("week_start") @db.Date
weekEnd DateTime @map("week_end") @db.Date
status TimesheetStatus @default(draft)
submittedAt DateTime? @map("submitted_at")
approvedBy String? @map("approved_by")
approvedAt DateTime? @map("approved_at")
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation("UserTimesheets", fields: [userId], references: [id], onDelete: Cascade)
approver User? @relation("ApprovedTimesheets", fields: [approvedBy], references: [id])
entries TimesheetEntry[]
@@unique([userId, weekStart])
@@index([status])
@@index([userId, weekStart])
@@map("timesheets")
}
model TimesheetEntry {
id String @id @default(uuid())
timesheetId String @map("timesheet_id")
timeEntryId String @map("time_entry_id")
timesheet Timesheet @relation(fields: [timesheetId], references: [id], onDelete: Cascade)
timeEntry TimeEntry @relation(fields: [timeEntryId], references: [id], onDelete: Cascade)
@@unique([timesheetId, timeEntryId])
@@map("timesheet_entries")
}
+100
View File
@@ -0,0 +1,100 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
const DEFAULT_HOMEOWNERS = [
'Anderson, 217',
'Bakos',
'Beckstead, 111',
'Bentley, 310',
'Best, 103',
'Caraway, 132',
'Carmichael, M, 216',
'Casa Blanca',
'Chapin, 106',
'Conner, 309',
'Cook, 118',
'Coyle, 109',
'Davis, 114a',
'Dimmitt, 213',
'Dockery, 502',
'Fassett, 303C',
'Gypsy Wind',
'Hager, 108',
'Hanford, 308',
'Hitchcox – Clarry, 218',
'Hughes, 215',
'Kaufman 129 (Blue View)',
'Kuchman, 104',
'Lockhart, 301A',
'Lokey, 136',
'McColgan, 312',
'Mercurio, 523',
'Moff – Dean Elect',
'Rogers, 501',
'Rusten, 204A',
'Ryan, 301B',
'Salas, 144',
'Sear 128 (Twin Shores)',
'Shimp, 517',
'Sipprelle, 202',
'Trino, 131',
'Useppa Fire',
'Vogt',
'Weinsz, 141',
'Wendorf, 306',
'White (Rogan)',
'Williams, Bob, 140',
'Williams, Dan, 137B',
'Williamson-Whetstone, 102',
'Wilson, George, 516',
'Wright, 137A',
];
async function main() {
console.log('🌱 Seeding database...');
// Create admin user
const passwordHash = await bcrypt.hash('CoastalAdmin2026!', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@coastal.com' },
update: {},
create: {
email: 'admin@coastal.com',
name: 'Admin',
role: 'super_admin',
passwordHash,
},
});
console.log(`✅ Admin user created: ${admin.email}`);
// Create homeowners
let created = 0;
let skipped = 0;
for (const name of DEFAULT_HOMEOWNERS) {
try {
await prisma.homeowner.upsert({
where: { name },
update: {},
create: { name },
});
created++;
} catch (err) {
console.warn(`⚠️ Skipped homeowner "${name}": ${err.message}`);
skipped++;
}
}
console.log(`✅ Homeowners: ${created} created, ${skipped} skipped`);
console.log('🌱 Seed complete!');
}
main()
.catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});