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