Coastal Timesheet v2 — full feature buildout

Features:
- Copy Previous Week (entries duplication)
- Bulk Approve/Reject (admin workflow)
- Overtime tracking (employee + admin views)
- DayCard component with auto-save
- PDF generation, email notifications
- React + Tailwind frontend, Prisma + PostgreSQL backend
- Docker deployment (3 containers)
This commit is contained in:
BizzleBot
2026-02-16 10:01:39 +00:00
commit f718a76153
65 changed files with 6756 additions and 0 deletions
@@ -0,0 +1,98 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'warehouse',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Client" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT,
"phone" TEXT,
"email" TEXT,
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Package" (
"id" TEXT NOT NULL,
"trackingCode" TEXT NOT NULL,
"description" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'received',
"priority" TEXT NOT NULL DEFAULT 'normal',
"notes" TEXT,
"itemNotes" TEXT,
"deliveryNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Package_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustodyEvent" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT NOT NULL,
"photoUrl" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustodyEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Package_trackingCode_key" ON "Package"("trackingCode");
-- CreateIndex
CREATE INDEX "Package_clientId_idx" ON "Package"("clientId");
-- CreateIndex
CREATE INDEX "Package_status_idx" ON "Package"("status");
-- CreateIndex
CREATE INDEX "Package_trackingCode_idx" ON "Package"("trackingCode");
-- CreateIndex
CREATE INDEX "CustodyEvent_packageId_idx" ON "CustodyEvent"("packageId");
-- CreateIndex
CREATE INDEX "CustodyEvent_userId_idx" ON "CustodyEvent"("userId");
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustodyEvent" ADD CONSTRAINT "CustodyEvent_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustodyEvent" ADD CONSTRAINT "CustodyEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1 @@
{"provider":"postgresql"}
+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();
});