diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f4c792c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts must stay LF or /bin/sh in the container fails on \r +*.sh text eol=lf +prisma/migrations/**/*.sql text eol=lf diff --git a/README.md b/README.md index dbb2553..1c60c81 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ A sleek, modern, and dockerized web application for employees to track and submi - **Multi-Step Reporting**: - **Morning**: Log planned tasks, time estimates, and initial notes. - **Evening**: Review achievements, update statuses, and submit links to completed work. +- **Reliable Autosave**: Task edits are debounced and saved automatically; failed saves are retried and surfaced in the UI so no edit is ever silently lost. - **Smart Admin Logic**: - The first user to log in is automatically granted the **ADMIN** role. - - Exclusive **Admin Panel** to search and review all employee reports. + - Exclusive **Admin Panel** with server-side search and paginated report browsing, user role management, and settings. - **Google Drive Integration**: - Automatically exports completed reports as Google Docs. - Admins can designate a specific folder for all exports. @@ -41,12 +42,40 @@ docker run -p 3000:3000 \ wfh-report ``` +## 🗄️ Database & Migrations + +The schema is managed with **Prisma Migrate**. On every container start, `entrypoint.sh` runs `prisma migrate deploy`, which applies any pending migrations from `prisma/migrations/` — additive and safe, unlike the old `db push --accept-data-loss` approach. + +- **Fresh installs**: all migrations are applied automatically to the new database. +- **Upgrading an existing deployment** (created before migrations were introduced): the first boot logs a one-time `P3005` error, then automatically *baselines* the database (marks `0_init` as applied) and applies the remaining migrations. This is expected — no manual action needed. +- Reports are unique per user per day (`@@unique([userId, date])`); the migration merges any pre-existing duplicate reports, keeping the earliest and moving its tasks. + +To add a schema change during development: +```bash +npx prisma migrate dev --name describe_your_change +``` + +## 🔌 API Overview + +All endpoints require a session; admin endpoints require the `ADMIN` role. Request bodies are validated with zod (invalid input returns `400` with field details). + +| Endpoint | Notes | +| :--- | :--- | +| `GET /api/reports` | Returns `{ reports, nextCursor }`. Query params: `date` (YYYY-MM-DD), `mine=1` (own reports only, matters for admins), `q` (search by employee/manager, admin), `take` (page size, max 100), `cursor` (pagination). | +| `POST /api/reports` | Creates or resumes the day's report (upsert — safe against double-submits). | +| `GET/PATCH /api/reports/[id]` | Owner (or admin for GET) only. A `SUBMITTED` report cannot be reverted. | +| `POST /api/reports/[id]/export` | Exports/re-exports the report to Google Drive. User content is HTML-escaped; only `http(s)` links are rendered. | +| `POST/PATCH/DELETE /api/tasks` | Scoped to tasks on the caller's own reports. | +| `GET/POST /api/admin/settings` | Admin only. Allowed keys: `GOOGLE_DRIVE_FOLDER_ID`. | +| `GET/PATCH /api/admin/users` | Admin only. Role management; self-demotion is blocked. | + ## 🏡 Unraid Installation For specific instructions on installing this on Unraid (including volume mapping and Unraid UI configuration), please refer to our [Unraid Installation Guide](https://git.alwisp.com/jason/wfh/src/branch/master/unraid_install.md). ## 🛠️ Tech Stack - **Framework**: [Next.js](https://nextjs.org/) (App Router) -- **Database**: [SQLite](https://sqlite.org/) via [Prisma ORM](https://www.prisma.io/) +- **Database**: [SQLite](https://sqlite.org/) via [Prisma ORM](https://www.prisma.io/) (Prisma Migrate for schema management) - **Auth**: [NextAuth.js](https://next-auth.js.org/) +- **Validation**: [Zod](https://zod.dev/) on all API request bodies - **Styles**: Vanilla CSS & TailwindCSS (for utility) - **Icons**: [Lucide React](https://lucide.dev/) diff --git a/entrypoint.sh b/entrypoint.sh index 62d2e75..40d3842 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,15 @@ #!/bin/sh set -e -echo "Running prisma db push..." -npx prisma db push --accept-data-loss + +echo "Applying database migrations..." +if ! npx prisma migrate deploy; then + # An existing database from the old `db push` days has tables but no + # migration history (P3005). Baseline it: mark the init migration as + # already applied, then deploy the rest. + echo "Migrate deploy failed - attempting to baseline existing database..." + npx prisma migrate resolve --applied 0_init + npx prisma migrate deploy +fi + echo "Starting server..." node server.js diff --git a/package-lock.json b/package-lock.json index 8176c9b..2311a64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "next-auth": "^4.24.13", "prisma": "^7.5.0", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -8563,10 +8564,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 066261d..4123a2c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "next-auth": "^4.24.13", "prisma": "^7.5.0", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..b94f3f2 --- /dev/null +++ b/prisma/migrations/0_init/migration.sql @@ -0,0 +1,94 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" DATETIME NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT, + "email" TEXT, + "emailVerified" DATETIME, + "image" TEXT, + "role" TEXT NOT NULL DEFAULT 'EMPLOYEE' +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Report" ( + "id" TEXT NOT NULL PRIMARY KEY, + "date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "managerName" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_PROGRESS', + "driveFileId" TEXT, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Report_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "description" TEXT NOT NULL, + "timeEstimate" TEXT, + "notes" TEXT, + "status" TEXT DEFAULT 'PENDING', + "link" TEXT, + "reportId" TEXT NOT NULL, + CONSTRAINT "Task_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Setting" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); + +-- CreateIndex +CREATE UNIQUE INDEX "Setting_key_key" ON "Setting"("key"); + diff --git a/prisma/migrations/20260701000000_report_unique_user_date/migration.sql b/prisma/migrations/20260701000000_report_unique_user_date/migration.sql new file mode 100644 index 0000000..98b2b34 --- /dev/null +++ b/prisma/migrations/20260701000000_report_unique_user_date/migration.sql @@ -0,0 +1,30 @@ +-- Deduplicate reports before adding the unique constraint: +-- keep the earliest report per (userId, date), move tasks from the +-- duplicates onto it, then delete the duplicates. + +UPDATE "Task" +SET "reportId" = ( + SELECT keep."id" + FROM "Report" keep + JOIN "Report" cur ON cur."id" = "Task"."reportId" + WHERE keep."userId" = cur."userId" + AND keep."date" = cur."date" + ORDER BY keep."createdAt" ASC, keep."id" ASC + LIMIT 1 +); + +DELETE FROM "Report" +WHERE "id" IN ( + SELECT "id" FROM ( + SELECT "id", + ROW_NUMBER() OVER ( + PARTITION BY "userId", "date" + ORDER BY "createdAt" ASC, "id" ASC + ) AS rn + FROM "Report" + ) + WHERE rn > 1 +); + +-- CreateIndex +CREATE UNIQUE INDEX "Report_userId_date_key" ON "Report"("userId", "date"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db74fb9..180b434 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,6 +72,8 @@ model Report { tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@unique([userId, date]) } enum ReportStatus { diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 4da8958..e3f763d 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -6,6 +6,7 @@ export const runtime = "nodejs"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { updateSettingSchema, parseBody } from "@/lib/validation"; // GET /api/admin/settings - Fetch global settings export async function GET() { @@ -16,7 +17,7 @@ export async function GET() { } const settings = await prisma.setting.findMany(); - const settingsMap = settings.reduce((acc: any, curr: any) => ({ ...acc, [curr.key]: curr.value }), {}); + const settingsMap = Object.fromEntries(settings.map((s) => [s.key, s.value])); return NextResponse.json(settingsMap); } @@ -29,7 +30,9 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { key, value } = await req.json(); + const { data, error } = await parseBody(req, updateSettingSchema); + if (error) return error; + const { key, value } = data; const setting = await prisma.setting.upsert({ where: { key }, diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index ff32eab..11014ec 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -5,6 +5,7 @@ export const runtime = "nodejs"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { updateUserRoleSchema, parseBody } from "@/lib/validation"; // GET /api/admin/users - List all users export async function GET() { @@ -41,11 +42,9 @@ export async function PATCH(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { userId, role } = await req.json(); - - if (!userId || !["EMPLOYEE", "ADMIN"].includes(role)) { - return NextResponse.json({ error: "Invalid request" }, { status: 400 }); - } + const { data, error } = await parseBody(req, updateUserRoleSchema); + if (error) return error; + const { userId, role } = data; // Prevent admins from demoting themselves if (userId === session.user.id && role === "EMPLOYEE") { diff --git a/src/app/api/reports/[id]/route.ts b/src/app/api/reports/[id]/route.ts index 8ca8a3f..ee9b7d0 100644 --- a/src/app/api/reports/[id]/route.ts +++ b/src/app/api/reports/[id]/route.ts @@ -6,6 +6,7 @@ export const runtime = "nodejs"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { updateReportSchema, parseBody } from "@/lib/validation"; // PATCH /api/reports/[id] - Update report status or manager export async function PATCH( @@ -19,11 +20,29 @@ export async function PATCH( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { status, managerName } = await req.json(); + const { data, error } = await parseBody(req, updateReportSchema); + if (error) return error; + + const existing = await prisma.report.findFirst({ + where: { id, userId: session.user.id }, + select: { status: true }, + }); + + if (!existing) { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + + // A submitted report is final: it can only be re-submitted, not walked back. + if (existing.status === "SUBMITTED" && data.status && data.status !== "SUBMITTED") { + return NextResponse.json( + { error: "A submitted report cannot be reverted" }, + { status: 409 } + ); + } const report = await prisma.report.update({ - where: { id, userId: session.user.id }, - data: { status, managerName }, + where: { id }, + data: { status: data.status, managerName: data.managerName }, }); return NextResponse.json(report); diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts index 0bd9818..d4d1f55 100644 --- a/src/app/api/reports/route.ts +++ b/src/app/api/reports/route.ts @@ -2,28 +2,76 @@ import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; - import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { createReportSchema, parseBody } from "@/lib/validation"; -// GET /api/reports - Fetch reports for the logged-in user (or all for admin) -export async function GET() { +const MAX_PAGE_SIZE = 100; +const DEFAULT_PAGE_SIZE = 50; + +// GET /api/reports - Fetch reports for the logged-in user (or all for admin). +// Query params: +// date - YYYY-MM-DD, return only that day's report(s) +// mine - "1" to return only the caller's own reports (even for admins) +// q - filter by employee or manager name (admin listing) +// take - page size (default 50, max 100) +// cursor - report id to continue after (from previous page's nextCursor) +// Returns { reports, nextCursor }. +export async function GET(req: Request) { const session = await getServerSession(authOptions); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const where = session.user.role === "ADMIN" ? {} : { userId: session.user.id }; + const { searchParams } = new URL(req.url); + const date = searchParams.get("date"); + const q = searchParams.get("q")?.trim(); + const cursor = searchParams.get("cursor"); + const take = Math.min( + Math.max(parseInt(searchParams.get("take") || "", 10) || DEFAULT_PAGE_SIZE, 1), + MAX_PAGE_SIZE + ); + + const where: Record = {}; + + if (session.user.role !== "ADMIN" || searchParams.get("mine") === "1") { + where.userId = session.user.id; + } + + if (date) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return NextResponse.json({ error: "date must be YYYY-MM-DD" }, { status: 400 }); + } + where.date = new Date(`${date}T00:00:00.000Z`); + } + + if (q) { + where.OR = [ + { user: { name: { contains: q } } }, + { managerName: { contains: q } }, + ]; + } const reports = await prisma.report.findMany({ where, - include: { tasks: true, user: true }, - orderBy: { date: "desc" }, + include: { + tasks: true, + user: { select: { id: true, name: true, email: true, image: true } }, + }, + orderBy: [{ date: "desc" }, { id: "desc" }], + take: take + 1, // fetch one extra to know if there is a next page + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), }); - return NextResponse.json(reports); + const hasMore = reports.length > take; + const page = hasMore ? reports.slice(0, take) : reports; + + return NextResponse.json({ + reports: page, + nextCursor: hasMore ? page[page.length - 1].id : null, + }); } // POST /api/reports - Create or resume a report @@ -34,33 +82,29 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const body = await req.json(); - const { managerName, date } = body; + const { data, error } = await parseBody(req, createReportSchema); + if (error) return error; - // Check if a report already exists for this date and user. // Client always sends a YYYY-MM-DD date string in Central US time; // we store it as UTC midnight so the date string is stable across timezones. - const reportDate = date - ? new Date(`${date}T00:00:00.000Z`) + const reportDate = data.date + ? new Date(`${data.date}T00:00:00.000Z`) : new Date(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }) + 'T00:00:00.000Z'); - let report = await prisma.report.findFirst({ + // Upsert against the (userId, date) unique constraint so concurrent + // requests can never create two reports for the same day. + const report = await prisma.report.upsert({ where: { + userId_date: { userId: session.user.id, date: reportDate }, + }, + update: {}, + create: { userId: session.user.id, + managerName: data.managerName, date: reportDate, }, + include: { tasks: true }, }); - if (!report) { - report = await prisma.report.create({ - data: { - userId: session.user.id, - managerName, - date: reportDate, - }, - include: { tasks: true }, - }); - } - return NextResponse.json(report); } diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index c3e8419..65c5a66 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -5,6 +5,7 @@ export const runtime = "nodejs"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { createTaskSchema, updateTaskSchema, parseBody } from "@/lib/validation"; // POST /api/tasks - Add a task to a report export async function POST(req: Request) { @@ -14,11 +15,12 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { reportId, type, description, timeEstimate, notes, link, status } = await req.json(); + const { data, error } = await parseBody(req, createTaskSchema); + if (error) return error; // Verify ownership - const report = await prisma.report.findUnique({ - where: { id: reportId, userId: session.user.id }, + const report = await prisma.report.findFirst({ + where: { id: data.reportId, userId: session.user.id }, }); if (!report) { @@ -27,53 +29,74 @@ export async function POST(req: Request) { const task = await prisma.task.create({ data: { - reportId, - type, - description, - timeEstimate, - notes, - link, - status: status || "PENDING", + reportId: data.reportId, + type: data.type, + description: data.description, + timeEstimate: data.timeEstimate, + notes: data.notes, + link: data.link, + status: data.status || "PENDING", }, }); return NextResponse.json(task); } -// PATCH /api/tasks/[id] - Update a task +// PATCH /api/tasks - Update a task (only on the caller's own report) export async function PATCH(req: Request) { const session = await getServerSession(authOptions); - + if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { id, type, description, timeEstimate, notes, link, status } = await req.json(); + const { data, error } = await parseBody(req, updateTaskSchema); + if (error) return error; + + const { id, ...updates } = data; + + const existing = await prisma.task.findFirst({ + where: { id, report: { userId: session.user.id } }, + select: { id: true }, + }); + + if (!existing) { + return NextResponse.json({ error: "Task not found" }, { status: 404 }); + } const task = await prisma.task.update({ where: { id }, - data: { type, description, timeEstimate, notes, link, status }, + data: updates, }); return NextResponse.json(task); } -// DELETE /api/tasks/[id] - Delete a task +// DELETE /api/tasks?id= - Delete a task (only on the caller's own report) export async function DELETE(req: Request) { - const session = await getServerSession(authOptions); - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { searchParams } = new URL(req.url); - const id = searchParams.get("id"); - - if (!id) return NextResponse.json({ error: "ID required" }, { status: 400 }); + const session = await getServerSession(authOptions); - await prisma.task.delete({ - where: { id }, - }); - - return NextResponse.json({ success: true }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + + if (!id) return NextResponse.json({ error: "ID required" }, { status: 400 }); + + const existing = await prisma.task.findFirst({ + where: { id, report: { userId: session.user.id } }, + select: { id: true }, + }); + + if (!existing) { + return NextResponse.json({ error: "Task not found" }, { status: 404 }); + } + + await prisma.task.delete({ + where: { id }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index b8ca5bd..d680b57 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -2,21 +2,31 @@ import { useState, useEffect } from "react"; import { Search, ChevronDown, ChevronUp, ExternalLink, FileText, User, Users, Calendar, Settings, ListChecks, Save, ShieldCheck, ShieldOff } from "lucide-react"; +import type { AdminUserDTO, ReportDTO, ReportsPageDTO } from "@/types/api"; + +const PAGE_SIZE = 50; export default function AdminDashboard() { - const [reports, setReports] = useState([]); + const [reports, setReports] = useState([]); + const [nextCursor, setNextCursor] = useState(null); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [search, setSearch] = useState(""); const [expandedId, setExpandedId] = useState(null); const [tab, setTab] = useState<"REPORTS" | "USERS" | "SETTINGS">("REPORTS"); const [folderId, setFolderId] = useState(""); const [saving, setSaving] = useState(false); - const [users, setUsers] = useState([]); + const [users, setUsers] = useState([]); const [usersLoading, setUsersLoading] = useState(false); const [togglingId, setTogglingId] = useState(null); + // Initial load + server-side search (debounced so we don't query per keystroke) + useEffect(() => { + const timer = setTimeout(() => fetchReports(search), search ? 400 : 0); + return () => clearTimeout(timer); + }, [search]); + useEffect(() => { - fetchReports(); fetchSettings(); }, []); @@ -35,11 +45,12 @@ export default function AdminDashboard() { const saveSetting = async (key: string, value: string) => { setSaving(true); try { - await fetch("/api/admin/settings", { + const res = await fetch("/api/admin/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }), }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); alert("Setting saved successfully!"); } catch (error) { alert("Failed to save setting"); @@ -52,7 +63,8 @@ export default function AdminDashboard() { setUsersLoading(true); try { const res = await fetch("/api/admin/users"); - const data = await res.json(); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: AdminUserDTO[] = await res.json(); setUsers(data); } catch (error) { console.error("Failed to fetch users"); @@ -83,23 +95,25 @@ export default function AdminDashboard() { } }; - const fetchReports = async () => { + const fetchReports = async (q: string, cursor?: string) => { + if (cursor) setLoadingMore(true); try { - const res = await fetch("/api/reports"); - const data = await res.json(); - setReports(data); + const params = new URLSearchParams({ take: String(PAGE_SIZE) }); + if (q) params.set("q", q); + if (cursor) params.set("cursor", cursor); + const res = await fetch(`/api/reports?${params}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: ReportsPageDTO = await res.json(); + setReports(prev => (cursor ? [...prev, ...data.reports] : data.reports)); + setNextCursor(data.nextCursor); } catch (error) { - console.error("Failed to fetch reports"); + console.error("Failed to fetch reports", error); } finally { setLoading(false); + setLoadingMore(false); } }; - const filteredReports = reports.filter(r => - r.user?.name?.toLowerCase().includes(search.toLowerCase()) || - r.managerName?.toLowerCase().includes(search.toLowerCase()) - ); - if (loading) return
Loading reports...
; return ( @@ -148,7 +162,7 @@ export default function AdminDashboard() { {tab === "REPORTS" ? (
- {filteredReports.map((report) => ( + {reports.map((report) => (

Planned Tasks

- {report.tasks.filter((t: any) => t.type === 'PLANNED').map((task: any) => ( + {report.tasks.filter((t) => t.type === 'PLANNED').map((task) => (

{task.description}

Est: {task.timeEstimate} • {task.notes}

@@ -189,7 +203,7 @@ export default function AdminDashboard() {

Completed Tasks

- {report.tasks.filter((t: any) => t.type === 'COMPLETED').map((task: any) => ( + {report.tasks.filter((t) => t.type === 'COMPLETED').map((task) => (

{task.description}

@@ -208,7 +222,16 @@ export default function AdminDashboard() { )}
))} - {filteredReports.length === 0 &&

No reports found matching your criteria.

} + {reports.length === 0 &&

No reports found matching your criteria.

} + {nextCursor && ( + + )}
) : tab === "USERS" ? (
@@ -229,7 +252,7 @@ export default function AdminDashboard() {
{user.image ? ( - {user.name} + {user.name ) : (
diff --git a/src/components/ReportForm.tsx b/src/components/ReportForm.tsx index f0e2ba9..3242533 100644 --- a/src/components/ReportForm.tsx +++ b/src/components/ReportForm.tsx @@ -2,20 +2,29 @@ import { useSession, signIn, signOut } from "next-auth/react"; import { useState, useEffect, useRef } from "react"; -import { Plus, Trash2, Send, Save, CheckCircle, Clock, Calendar, User as UserIcon, Link as LinkIcon, LogOut, ShieldCheck, ClipboardList } from "lucide-react"; +import { Plus, Trash2, Send, CheckCircle, Clock, Calendar, User as UserIcon, Link as LinkIcon, LogOut, ShieldCheck, ClipboardList, AlertTriangle } from "lucide-react"; import AdminDashboard from "./AdminDashboard"; +import type { ReportDTO, ReportsPageDTO, TaskDTO, TaskStatus, TaskType } from "@/types/api"; + +type TaskUpdate = Partial> & { + type: TaskType; +}; + +const RETRY_DELAY_MS = 5000; export default function ReportForm() { const { data: session, status } = useSession(); - const [report, setReport] = useState(null); + const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [managerName, setManagerName] = useState(""); - const [plannedTasks, setPlannedTasks] = useState([]); - const [completedTasks, setCompletedTasks] = useState([]); + const [plannedTasks, setPlannedTasks] = useState([]); + const [completedTasks, setCompletedTasks] = useState([]); const [saving, setSaving] = useState(false); + const [saveFailed, setSaveFailed] = useState(false); + const [hasUnsaved, setHasUnsaved] = useState(false); const [view, setView] = useState<"REPORT" | "ADMIN">("REPORT"); const debounceTimers = useRef>>({}); - const pendingUpdates = useRef>({}); + const pendingUpdates = useRef>({}); useEffect(() => { if (status === "authenticated") { @@ -31,16 +40,18 @@ export default function ReportForm() { const fetchReport = async () => { try { - const res = await fetch("/api/reports"); - const data = await res.json(); - const today = getCentralToday(); - const todayReport = data.find((r: any) => r.date.split('T')[0] === today); - + // Ask the server for just the caller's own report for today, + // instead of the full history (mine=1 matters for admins) + const res = await fetch(`/api/reports?date=${getCentralToday()}&mine=1`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: ReportsPageDTO = await res.json(); + const todayReport = data.reports[0]; + if (todayReport) { setReport(todayReport); setManagerName(todayReport.managerName); - setPlannedTasks(todayReport.tasks.filter((t: any) => t.type === "PLANNED")); - setCompletedTasks(todayReport.tasks.filter((t: any) => t.type === "COMPLETED")); + setPlannedTasks(todayReport.tasks.filter((t) => t.type === "PLANNED")); + setCompletedTasks(todayReport.tasks.filter((t) => t.type === "COMPLETED")); } } catch (error) { console.error("Failed to fetch report", error); @@ -57,7 +68,8 @@ export default function ReportForm() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ managerName, date: getCentralToday() }), }); - const data = await res.json(); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: ReportDTO = await res.json(); setReport(data); } catch (error) { alert("Failed to start report"); @@ -83,7 +95,8 @@ export default function ReportForm() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(newTask), }); - const data = await res.json(); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: TaskDTO = await res.json(); if (type === "PLANNED") setPlannedTasks([...plannedTasks, data]); else setCompletedTasks([...completedTasks, data]); } catch (error) { @@ -91,7 +104,36 @@ export default function ReportForm() { } }; - const updateTask = (id: string, updates: any) => { + // Send a task's accumulated changes to the server. On failure the payload + // is re-queued (newer keystrokes win) and retried, so edits are never + // silently dropped. + const flushTask = async (id: string) => { + const payload = pendingUpdates.current[id]; + delete pendingUpdates.current[id]; + delete debounceTimers.current[id]; + if (!payload) return; + + try { + const res = await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, ...payload }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setSaveFailed(false); + if (Object.keys(pendingUpdates.current).length === 0) setHasUnsaved(false); + } catch (error) { + console.error("Failed to update task, will retry", error); + pendingUpdates.current[id] = { ...payload, ...pendingUpdates.current[id] }; + setSaveFailed(true); + // Retry unless a newer edit already rescheduled this task's timer + if (!debounceTimers.current[id]) { + debounceTimers.current[id] = setTimeout(() => flushTask(id), RETRY_DELAY_MS); + } + } + }; + + const updateTask = (id: string, updates: TaskUpdate) => { // Update local state immediately so the UI stays responsive if (updates.type === 'PLANNED') { setPlannedTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t)); @@ -101,28 +143,17 @@ export default function ReportForm() { // Accumulate all field changes for this task so a single request carries everything pendingUpdates.current[id] = { ...pendingUpdates.current[id], ...updates }; + setHasUnsaved(true); // Reset the debounce timer — the API call fires 600 ms after the last keystroke clearTimeout(debounceTimers.current[id]); - debounceTimers.current[id] = setTimeout(async () => { - const payload = pendingUpdates.current[id]; - delete pendingUpdates.current[id]; - delete debounceTimers.current[id]; - try { - await fetch("/api/tasks", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id, ...payload }), - }); - } catch (error) { - console.error("Failed to update task"); - } - }, 600); + debounceTimers.current[id] = setTimeout(() => flushTask(id), 600); }; - const deleteTask = async (id: string, type: string) => { + const deleteTask = async (id: string, type: TaskType) => { try { - await fetch(`/api/tasks?id=${id}`, { method: "DELETE" }); + const res = await fetch(`/api/tasks?id=${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); if (type === "PLANNED") setPlannedTasks(plannedTasks.filter(t => t.id !== id)); else setCompletedTasks(completedTasks.filter(t => t.id !== id)); } catch (error) { @@ -303,7 +334,7 @@ export default function ReportForm() {