Compare commits
23 Commits
5e3ca19c83
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af8d5ef45e | |||
| 217858ce83 | |||
| 8b7a364f98 | |||
| f315ddabc6 | |||
| 497d54de3c | |||
| abb4c740e6 | |||
| b35574241a | |||
| 3be1b261ec | |||
| 80772fe352 | |||
| 7c6ccc6b8a | |||
| a9df9c0cf4 | |||
| d8efa71992 | |||
| 35ec71aa35 | |||
| c0198df6d9 | |||
| f1a3a31a94 | |||
| e08a2375ae | |||
| 707f632d34 | |||
| 6813602b6c | |||
| 65a4f79131 | |||
| 9046370b64 | |||
| 0e2dc27779 | |||
| 9e735b00f2 | |||
| b2df27cfc5 |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx prisma migrate dev --name add-drive-file-id)",
|
||||
"Bash(npx prisma db push)"
|
||||
]
|
||||
}
|
||||
}
|
||||
Submodule .claude/worktrees/quirky-golick deleted from 04ebc91e9f
Submodule .claude/worktrees/reverent-proskuriakova deleted from 19b1f26254
Submodule
+1
Submodule .claude/worktrees/suspicious-wilson added at 707f632d34
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Runs on the forgerunner host: bundled Docker CLI + mounted /var/run/docker.sock.
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so `git rev-list --count HEAD` yields the true commit count
|
||||
# driving both the app version (v1.NNN) and the org.alwisp.version label.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.alwisp.com
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
run: |
|
||||
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
|
||||
GIT_SHA="$(git rev-parse --short HEAD)"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
COMMIT_COUNT="$(git rev-list --count HEAD)"
|
||||
docker build \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--build-arg BUILD_TIME="${BUILD_TIME}" \
|
||||
--build-arg COMMIT_COUNT="${COMMIT_COUNT}" \
|
||||
--label org.alwisp.git-sha="${{ gitea.sha }}" \
|
||||
--label org.alwisp.version="v1.$((COMMIT_COUNT-1))" \
|
||||
--label org.alwisp.repo="${{ gitea.repository }}" \
|
||||
-t "${IMAGE}:latest" .
|
||||
docker push "${IMAGE}:latest"
|
||||
|
||||
# Dangling-only prune: removes untagged leftovers from previous builds. Never
|
||||
# removes the tagged :latest or any image referenced by a running container.
|
||||
- name: Push per-SHA tag
|
||||
if: success()
|
||||
run: |
|
||||
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
|
||||
GIT_SHA="$(git rev-parse --short=7 HEAD)"
|
||||
docker tag "${IMAGE}:latest" "${IMAGE}:sha-${GIT_SHA}"
|
||||
docker push "${IMAGE}:sha-${GIT_SHA}"
|
||||
|
||||
- name: Prune dangling images on host
|
||||
if: always()
|
||||
run: docker image prune -f 2>/dev/null || true
|
||||
|
||||
- name: Trigger PORT redeploy
|
||||
if: success()
|
||||
run: |
|
||||
NAME="${{ gitea.repository }}"; NAME="${NAME##*/}"
|
||||
curl -fsS -X POST https://port.alwisp.com/hooks/gitea \
|
||||
-H "X-Deploy-Token: ${{ secrets.WEBHOOK_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"container\":\"${NAME}\"}" || echo "PORT redeploy trigger failed (non-fatal)"
|
||||
@@ -56,3 +56,7 @@ next-env.d.ts
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code local state
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
+1
-8
@@ -57,14 +57,7 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
# script to run migrations before starting
|
||||
COPY --chown=nextjs:nodejs <<EOF /app/entrypoint.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "Running prisma db push..."
|
||||
npx prisma db push --accept-data-loss
|
||||
echo "Starting server..."
|
||||
node server.js
|
||||
EOF
|
||||
COPY --chown=nextjs:nodejs entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
|
||||
@@ -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/)
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
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
|
||||
Generated
+5
-5
@@ -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"
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
@@ -66,11 +66,14 @@ model Report {
|
||||
date DateTime @default(now())
|
||||
managerName String
|
||||
status ReportStatus @default(IN_PROGRESS)
|
||||
driveFileId String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
tasks Task[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, date])
|
||||
}
|
||||
|
||||
enum ReportStatus {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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 { updateUserRoleSchema, parseBody } from "@/lib/validation";
|
||||
|
||||
// GET /api/admin/users - List all users
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
reports: {
|
||||
orderBy: { date: "desc" },
|
||||
take: 1,
|
||||
select: { date: true, status: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(users);
|
||||
}
|
||||
|
||||
// PATCH /api/admin/users - Update a user's role
|
||||
export async function PATCH(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
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") {
|
||||
return NextResponse.json({ error: "You cannot remove your own admin privileges" }, { status: 403 });
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role },
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export const runtime = "nodejs";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { uploadToDrive, generateReportMarkdown } from "@/lib/google-drive";
|
||||
import { uploadToDrive, updateDriveFile, generateReportHTML, getGoogleAuth } from "@/lib/google-drive";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
@@ -18,13 +18,11 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// With database sessions (not JWT), the Google access token lives in the
|
||||
// Account table — getToken() returns null in this strategy.
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId: session.user.id, provider: "google" },
|
||||
});
|
||||
|
||||
if (!account?.access_token) {
|
||||
let auth;
|
||||
try {
|
||||
auth = await getGoogleAuth(session.user.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to get Google Auth:", error);
|
||||
return NextResponse.json({ error: "Unauthorized or missing Google token" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -37,7 +35,7 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Report not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const markdown = generateReportMarkdown(report);
|
||||
const htmlContent = generateReportHTML(report);
|
||||
const fileName = `WFH_Report_${new Date(report.date).toISOString().split('T')[0]}_${report.user.name}`;
|
||||
|
||||
// Fetch designated folder ID from settings
|
||||
@@ -46,17 +44,23 @@ export async function POST(
|
||||
});
|
||||
|
||||
try {
|
||||
const driveFile = await uploadToDrive(
|
||||
account.access_token,
|
||||
fileName,
|
||||
markdown,
|
||||
folderSetting?.value
|
||||
);
|
||||
|
||||
// Update report status to SUBMITTED
|
||||
let driveFile;
|
||||
|
||||
if (report.driveFileId) {
|
||||
// Update the existing Drive file in place
|
||||
driveFile = await updateDriveFile(auth, report.driveFileId, htmlContent);
|
||||
} else {
|
||||
// First export — create a new Drive file and store its ID
|
||||
driveFile = await uploadToDrive(auth, fileName, htmlContent, folderSetting?.value);
|
||||
await prisma.report.update({
|
||||
where: { id },
|
||||
data: { driveFileId: driveFile.id },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.report.update({
|
||||
where: { id },
|
||||
data: { status: 'SUBMITTED' }
|
||||
data: { status: 'SUBMITTED' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, link: driveFile.webViewLink });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
|
||||
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,30 +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
|
||||
const reportDate = date ? new Date(date) : new Date();
|
||||
reportDate.setHours(0, 0, 0, 0);
|
||||
// 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 = 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);
|
||||
}
|
||||
|
||||
+53
-30
@@ -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 });
|
||||
}
|
||||
|
||||
+53
-18
@@ -1,46 +1,81 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Message Point Media brand palette.
|
||||
* Gold system (Middle / Light / Dark Gold) on a Dark Shade charcoal ground,
|
||||
* with Light Shade text. Montserrat headings, Open Sans body.
|
||||
*/
|
||||
:root {
|
||||
--bg-dark: #0f172a;
|
||||
--bg-card: rgba(30, 41, 59, 0.7);
|
||||
--accent-primary: #38bdf8;
|
||||
--accent-secondary: #818cf8;
|
||||
--text-main: #f1f5f9;
|
||||
--text-dim: #94a3b8;
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.05);
|
||||
/* Brand core */
|
||||
--mpm-gold-mid: #dcbb4f; /* Middle Gold — default accent */
|
||||
--mpm-gold-light: #f5cd15; /* Light Gold — accents on dark backgrounds */
|
||||
--mpm-gold-dark: #998643; /* Dark Gold — titles on light backgrounds */
|
||||
--mpm-shade-dark: #232022; /* Dark Shade — near-black ground */
|
||||
--mpm-shade-light: #f5f1ec;/* Light Shade — off-white text */
|
||||
--mpm-accent: #849698; /* Light Accent — steel gray, secondary text */
|
||||
|
||||
/* Semantic tokens used across the app */
|
||||
--bg-dark: var(--mpm-shade-dark);
|
||||
--bg-card: rgba(40, 37, 39, 0.72);
|
||||
--accent-primary: var(--mpm-gold-mid);
|
||||
--accent-secondary: var(--mpm-gold-light);
|
||||
--text-main: var(--mpm-shade-light);
|
||||
--text-dim: var(--mpm-accent);
|
||||
--glass-border: rgba(220, 187, 79, 0.18);
|
||||
--glass-highlight: rgba(220, 187, 79, 0.08);
|
||||
}
|
||||
|
||||
/* Expose brand tokens as Tailwind v4 theme colors so utilities like
|
||||
text-accent-primary / text-text-dim / from-accent-primary resolve. */
|
||||
@theme inline {
|
||||
--color-bg-dark: var(--bg-dark);
|
||||
--color-accent-primary: var(--accent-primary);
|
||||
--color-accent-secondary: var(--accent-secondary);
|
||||
--color-text-main: var(--text-main);
|
||||
--color-text-dim: var(--text-dim);
|
||||
--color-gold-dark: var(--mpm-gold-dark);
|
||||
--font-sans: var(--font-open-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-heading: var(--font-montserrat), var(--font-open-sans), sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: radial-gradient(circle at top left, #1e293b, #0f172a);
|
||||
background: radial-gradient(circle at top left, #2f2b2d, var(--mpm-shade-dark));
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-open-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Montserrat carries all headings — weight, not just size, sets hierarchy. */
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-montserrat), var(--font-open-sans), sans-serif;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.45);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
background: rgba(35, 32, 34, 0.5);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: white;
|
||||
color: var(--text-main);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
|
||||
box-shadow: 0 0 0 2px rgba(220, 187, 79, 0.25);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Gold gradient echoes the MPM logo mark (light gold → dark gold).
|
||||
Dark Shade text keeps the label readable on the gold fill. */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--mpm-gold-light), var(--mpm-gold-dark));
|
||||
color: var(--mpm-shade-dark);
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
@@ -49,20 +84,20 @@ body {
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
opacity: 0.9;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--glass-highlight);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: white;
|
||||
color: var(--text-main);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(220, 187, 79, 0.14);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
||||
+15
-5
@@ -1,13 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Montserrat, Open_Sans } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/components/providers/AuthProvider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
// MPM brand typography: Montserrat for headings, Open Sans for body.
|
||||
const montserrat = Montserrat({
|
||||
subsets: ["latin"],
|
||||
weight: ["500", "600", "700", "800"],
|
||||
variable: "--font-montserrat",
|
||||
});
|
||||
|
||||
const openSans = Open_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-open-sans",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WFH Daily Report",
|
||||
description: "Sleek and modern work from home reporting tool",
|
||||
title: "WFH Daily Report | Message Point Media",
|
||||
description: "Daily work-from-home reporting for Message Point Media. Born to Innovate. Built to Last.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -17,7 +27,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<body className={`${montserrat.variable} ${openSans.variable}`}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, ChevronDown, ChevronUp, ExternalLink, FileText, User, Calendar, Settings, ListChecks, Save } from "lucide-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<any[]>([]);
|
||||
const [reports, setReports] = useState<ReportDTO[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"REPORTS" | "SETTINGS">("REPORTS");
|
||||
const [tab, setTab] = useState<"REPORTS" | "USERS" | "SETTINGS">("REPORTS");
|
||||
const [folderId, setFolderId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [users, setUsers] = useState<AdminUserDTO[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(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();
|
||||
}, []);
|
||||
|
||||
@@ -32,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");
|
||||
@@ -45,22 +59,60 @@ export default function AdminDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReports = async () => {
|
||||
const fetchUsers = async () => {
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/reports");
|
||||
const data = await res.json();
|
||||
setReports(data);
|
||||
const res = await fetch("/api/admin/users");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: AdminUserDTO[] = await res.json();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch reports");
|
||||
console.error("Failed to fetch users");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setUsersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredReports = reports.filter(r =>
|
||||
r.user?.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.managerName?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const toggleRole = async (userId: string, currentRole: string) => {
|
||||
setTogglingId(userId);
|
||||
const newRole = currentRole === "ADMIN" ? "EMPLOYEE" : "ADMIN";
|
||||
try {
|
||||
const res = await fetch("/api/admin/users", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, role: newRole }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
setUsers(users.map(u => u.id === userId ? { ...u, role: newRole } : u));
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Failed to update role");
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReports = async (q: string, cursor?: string) => {
|
||||
if (cursor) setLoadingMore(true);
|
||||
try {
|
||||
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", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-20 animate-pulse">Loading reports...</div>;
|
||||
|
||||
@@ -76,7 +128,15 @@ export default function AdminDashboard() {
|
||||
>
|
||||
<ListChecks size={18} /> Reports
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={() => { setTab("USERS"); if (!users.length) fetchUsers(); }}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm font-medium ${
|
||||
tab === "USERS" ? "bg-accent-primary text-white" : "hover:bg-white/5 text-text-dim"
|
||||
}`}
|
||||
>
|
||||
<Users size={18} /> Users
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("SETTINGS")}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm font-medium ${
|
||||
tab === "SETTINGS" ? "bg-accent-primary text-white" : "hover:bg-white/5 text-text-dim"
|
||||
@@ -102,7 +162,7 @@ export default function AdminDashboard() {
|
||||
|
||||
{tab === "REPORTS" ? (
|
||||
<div className="grid gap-4">
|
||||
{filteredReports.map((report) => (
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="glass-card overflow-hidden transition-all duration-300">
|
||||
<div
|
||||
className="p-4 flex items-center justify-between cursor-pointer hover:bg-white/5"
|
||||
@@ -134,7 +194,7 @@ export default function AdminDashboard() {
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase text-accent-primary tracking-widest">Planned Tasks</h4>
|
||||
{report.tasks.filter((t: any) => t.type === 'PLANNED').map((task: any) => (
|
||||
{report.tasks.filter((t) => t.type === 'PLANNED').map((task) => (
|
||||
<div key={task.id} className="text-sm border-l-2 border-accent-primary/30 pl-3 py-1">
|
||||
<p className="font-medium">{task.description}</p>
|
||||
<p className="text-xs text-text-dim">Est: {task.timeEstimate} • {task.notes}</p>
|
||||
@@ -143,7 +203,7 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase text-accent-secondary tracking-widest">Completed Tasks</h4>
|
||||
{report.tasks.filter((t: any) => t.type === 'COMPLETED').map((task: any) => (
|
||||
{report.tasks.filter((t) => t.type === 'COMPLETED').map((task) => (
|
||||
<div key={task.id} className="text-sm border-l-2 border-accent-secondary/30 pl-3 py-1">
|
||||
<p className="font-medium">{task.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -162,7 +222,91 @@ export default function AdminDashboard() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredReports.length === 0 && <p className="text-center py-10 text-text-dim">No reports found matching your criteria.</p>}
|
||||
{reports.length === 0 && <p className="text-center py-10 text-text-dim">No reports found matching your criteria.</p>}
|
||||
{nextCursor && (
|
||||
<button
|
||||
onClick={() => fetchReports(search, nextCursor)}
|
||||
disabled={loadingMore}
|
||||
className="btn-secondary py-2 px-4 text-sm mx-auto disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more reports"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : tab === "USERS" ? (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Users size={20} className="text-accent-primary" /> Employee List
|
||||
</h3>
|
||||
<p className="text-sm text-text-dim">Manage admin privileges for your team.</p>
|
||||
</div>
|
||||
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-10 animate-pulse text-text-dim">Loading users...</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{users.map((user) => {
|
||||
const lastReport = user.reports?.[0];
|
||||
return (
|
||||
<div key={user.id} className="glass-card p-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{user.image ? (
|
||||
<img src={user.image} alt={user.name ?? "User avatar"} className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-accent-primary/20 flex items-center justify-center text-accent-primary flex-shrink-0">
|
||||
<User size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-semibold truncate">{user.name || "Unnamed"}</p>
|
||||
<span className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full font-bold flex-shrink-0 ${
|
||||
user.role === "ADMIN"
|
||||
? "bg-accent-primary/20 text-accent-primary"
|
||||
: "bg-white/10 text-text-dim"
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-dim truncate">{user.email}</p>
|
||||
{lastReport && (
|
||||
<p className="text-[10px] text-text-dim mt-0.5">
|
||||
Last report: {new Date(lastReport.date).toLocaleDateString()} •{" "}
|
||||
<span className={lastReport.status === "SUBMITTED" ? "text-green-400" : "text-yellow-400"}>
|
||||
{lastReport.status}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => toggleRole(user.id, user.role)}
|
||||
disabled={togglingId === user.id}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex-shrink-0 disabled:opacity-50 ${
|
||||
user.role === "ADMIN"
|
||||
? "bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20"
|
||||
: "bg-accent-primary/10 hover:bg-accent-primary/20 text-accent-primary border border-accent-primary/20"
|
||||
}`}
|
||||
title={user.role === "ADMIN" ? "Remove admin privileges" : "Grant admin privileges"}
|
||||
>
|
||||
{togglingId === user.id ? (
|
||||
"..."
|
||||
) : user.role === "ADMIN" ? (
|
||||
<><ShieldOff size={16} /> Remove Admin</>
|
||||
) : (
|
||||
<><ShieldCheck size={16} /> Make Admin</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{users.length === 0 && (
|
||||
<p className="text-center py-10 text-text-dim">No users found.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card p-8 space-y-8 animate-fade-in">
|
||||
|
||||
+108
-45
@@ -1,19 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useSession, signIn, signOut } from "next-auth/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Trash2, Send, Save, CheckCircle, Clock, Calendar, User as UserIcon, Link as LinkIcon, LogOut, ShieldCheck, ClipboardList } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "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<Pick<TaskDTO, "description" | "timeEstimate" | "notes" | "link" | "status">> & {
|
||||
type: TaskType;
|
||||
};
|
||||
|
||||
const RETRY_DELAY_MS = 5000;
|
||||
|
||||
export default function ReportForm() {
|
||||
const { data: session, status } = useSession();
|
||||
const [report, setReport] = useState<any>(null);
|
||||
const [report, setReport] = useState<ReportDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [managerName, setManagerName] = useState("");
|
||||
const [plannedTasks, setPlannedTasks] = useState<any[]>([]);
|
||||
const [completedTasks, setCompletedTasks] = useState<any[]>([]);
|
||||
const [plannedTasks, setPlannedTasks] = useState<TaskDTO[]>([]);
|
||||
const [completedTasks, setCompletedTasks] = useState<TaskDTO[]>([]);
|
||||
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<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const pendingUpdates = useRef<Record<string, TaskUpdate>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
@@ -23,18 +34,24 @@ export default function ReportForm() {
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// Returns today's date as YYYY-MM-DD in Central US time
|
||||
const getCentralToday = () =>
|
||||
new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' });
|
||||
|
||||
const fetchReport = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/reports");
|
||||
const data = await res.json();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
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);
|
||||
@@ -49,9 +66,10 @@ export default function ReportForm() {
|
||||
const res = await fetch("/api/reports", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ managerName }),
|
||||
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");
|
||||
@@ -77,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) {
|
||||
@@ -85,26 +104,56 @@ export default function ReportForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateTask = async (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 {
|
||||
await fetch("/api/tasks", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...updates }),
|
||||
});
|
||||
if (updates.type === 'PLANNED') {
|
||||
setPlannedTasks(plannedTasks.map(t => t.id === id ? { ...t, ...updates } : t));
|
||||
} else {
|
||||
setCompletedTasks(completedTasks.map(t => t.id === id ? { ...t, ...updates } : t));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update task");
|
||||
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 deleteTask = async (id: string, type: string) => {
|
||||
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));
|
||||
} else {
|
||||
setCompletedTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
|
||||
}
|
||||
|
||||
// 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(() => flushTask(id), 600);
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -144,13 +193,16 @@ export default function ReportForm() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4">
|
||||
<div className="glass-card p-8 max-w-md w-full text-center space-y-6 animate-fade-in">
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-primary to-accent-secondary bg-clip-text text-transparent">
|
||||
WFH Tracker
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/mpm-logo.png" alt="Message Point Media" className="h-12 mx-auto" />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-secondary to-gold-dark bg-clip-text text-transparent">
|
||||
WFH Daily Report
|
||||
</h1>
|
||||
<p className="text-text-dim">Please sign in with your company Google account to access your daily reports.</p>
|
||||
<button onClick={() => signIn("google")} className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
Continue with Google
|
||||
</button>
|
||||
<p className="text-xs text-text-dim tracking-wide pt-2">Born to Innovate. Built to Last.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -159,11 +211,15 @@ export default function ReportForm() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-8 space-y-8 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{view === "REPORT" ? "Daily Report" : "Administration"}</h1>
|
||||
<p className="text-text-dim flex items-center gap-2">
|
||||
<Calendar size={16} /> {new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/mpm-logo.png" alt="Message Point Media" className="h-10 hidden sm:block" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{view === "REPORT" ? "Daily Report" : "Administration"}</h1>
|
||||
<p className="text-text-dim flex items-center gap-2">
|
||||
<Calendar size={16} /> {new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{session.user.role === "ADMIN" && (
|
||||
@@ -285,12 +341,12 @@ export default function ReportForm() {
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={task.status || "DONE"}
|
||||
onChange={(e) => updateTask(task.id, { status: e.target.value, type: 'COMPLETED' })}
|
||||
onChange={(e) => updateTask(task.id, { status: e.target.value as TaskStatus, type: 'COMPLETED' })}
|
||||
className="bg-transparent text-sm text-text-dim border-none p-0 focus:ring-0 outline-none appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="DONE" className="bg-slate-800">Completed</option>
|
||||
<option value="IN_PROGRESS" className="bg-slate-800">In Progress</option>
|
||||
<option value="CANCELLED" className="bg-slate-800">Delayed</option>
|
||||
<option value="DONE" className="bg-bg-dark">Completed</option>
|
||||
<option value="IN_PROGRESS" className="bg-bg-dark">In Progress</option>
|
||||
<option value="CANCELLED" className="bg-bg-dark">Delayed</option>
|
||||
</select>
|
||||
<div className="flex-1 relative">
|
||||
<LinkIcon size={14} className="absolute left-0 top-1/2 -translate-y-1/2 text-text-dim" />
|
||||
@@ -313,13 +369,20 @@ export default function ReportForm() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="pt-8 border-t border-white/10 flex justify-end gap-4">
|
||||
<button
|
||||
<footer className="pt-8 border-t border-white/10 flex items-center justify-end gap-4">
|
||||
{saveFailed ? (
|
||||
<span className="text-sm text-red-400 flex items-center gap-2">
|
||||
<AlertTriangle size={16} /> Some changes couldn't be saved — retrying…
|
||||
</span>
|
||||
) : hasUnsaved ? (
|
||||
<span className="text-sm text-text-dim animate-pulse">Saving…</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={exportToDrive}
|
||||
disabled={saving || report.status === 'SUBMITTED'}
|
||||
disabled={saving}
|
||||
className="btn-primary flex items-center gap-2 px-8"
|
||||
>
|
||||
{saving ? "Processing..." : (report.status === 'SUBMITTED' ? "Already Submitted" : "Finalize & Export to Drive")}
|
||||
{saving ? "Processing..." : (report.status === 'SUBMITTED' ? "Re-export to Drive" : "Finalize & Export to Drive")}
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export const authOptions: NextAuthOptions = {
|
||||
async session({ session, user }) {
|
||||
if (session?.user && user) {
|
||||
session.user.id = user.id;
|
||||
session.user.role = (user as any).role || 'EMPLOYEE';
|
||||
session.user.role = user.role || 'EMPLOYEE';
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
+227
-23
@@ -1,13 +1,84 @@
|
||||
import { google } from 'googleapis';
|
||||
import { google, Auth } from 'googleapis';
|
||||
import { Readable } from 'stream';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { prisma } from './prisma';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
export async function uploadToDrive(accessToken: string, fileName: string, content: string, folderId?: string) {
|
||||
const auth = new google.auth.OAuth2();
|
||||
auth.setCredentials({ access_token: accessToken });
|
||||
// The MPM logo is embedded directly into the report HTML as a base64 data URI
|
||||
// so it renders regardless of network reachability (e.g. a LAN-only deployment
|
||||
// where Google's importer can't fetch an external URL). Read from disk once and
|
||||
// cache; `null` means the asset was missing and the report renders without it.
|
||||
let cachedLogoDataUri: string | null | undefined;
|
||||
function getLogoDataUri(): string | null {
|
||||
if (cachedLogoDataUri !== undefined) return cachedLogoDataUri;
|
||||
try {
|
||||
const logoPath = path.join(process.cwd(), 'public', 'mpm-logo-doc.png');
|
||||
const b64 = readFileSync(logoPath).toString('base64');
|
||||
cachedLogoDataUri = `data:image/png;base64,${b64}`;
|
||||
} catch (error) {
|
||||
console.error('MPM report logo could not be loaded; rendering without it:', error);
|
||||
cachedLogoDataUri = null;
|
||||
}
|
||||
return cachedLogoDataUri;
|
||||
}
|
||||
|
||||
type ReportWithRelations = Prisma.ReportGetPayload<{
|
||||
include: { tasks: true; user: true };
|
||||
}>;
|
||||
|
||||
export async function getGoogleAuth(userId: string) {
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId, provider: 'google' },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new Error('Google account not found');
|
||||
}
|
||||
|
||||
const auth = new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET
|
||||
);
|
||||
|
||||
auth.setCredentials({
|
||||
access_token: account.access_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expiry_date: account.expires_at ? account.expires_at * 1000 : null,
|
||||
});
|
||||
|
||||
// Check if the token is expired or will expire in the next 1 minute
|
||||
// NextAuth stores expires_at in seconds
|
||||
const isExpired = account.expires_at ? (account.expires_at * 1000) < (Date.now() + 60000) : true;
|
||||
|
||||
if (isExpired && account.refresh_token) {
|
||||
try {
|
||||
const { credentials } = await auth.refreshAccessToken();
|
||||
auth.setCredentials(credentials);
|
||||
|
||||
// Update database with new tokens
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token || account.refresh_token,
|
||||
expires_at: credentials.expiry_date ? Math.floor(credentials.expiry_date / 1000) : null,
|
||||
},
|
||||
});
|
||||
console.log('Successfully refreshed Google access token for user:', userId);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing access token:', error);
|
||||
// If refresh fails, we still return the auth object, but requests will fail with 401
|
||||
}
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function uploadToDrive(auth: Auth.OAuth2Client, fileName: string, content: string, folderId?: string) {
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
const fileMetadata: any = {
|
||||
const fileMetadata: { name: string; mimeType: string; parents?: string[] } = {
|
||||
name: fileName,
|
||||
mimeType: 'application/vnd.google-apps.document', // Automatically convert to Google Doc
|
||||
};
|
||||
@@ -17,7 +88,7 @@ export async function uploadToDrive(accessToken: string, fileName: string, conte
|
||||
}
|
||||
|
||||
const media = {
|
||||
mimeType: 'text/markdown',
|
||||
mimeType: 'text/html',
|
||||
body: Readable.from([content]),
|
||||
};
|
||||
|
||||
@@ -34,23 +105,156 @@ export async function uploadToDrive(accessToken: string, fileName: string, conte
|
||||
}
|
||||
}
|
||||
|
||||
export function generateReportMarkdown(report: any) {
|
||||
let md = `# WFH Daily Report - ${new Date(report.date).toLocaleDateString()}\n`;
|
||||
md += `**Employee:** ${report.user.name}\n`;
|
||||
md += `**Manager:** ${report.managerName}\n\n`;
|
||||
export async function updateDriveFile(auth: Auth.OAuth2Client, fileId: string, content: string) {
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
md += `## Planned Tasks\n`;
|
||||
report.tasks.filter((t: any) => t.type === 'PLANNED').forEach((t: any) => {
|
||||
md += `- [ ] ${t.description} (Est: ${t.timeEstimate})\n`;
|
||||
if (t.notes) md += ` - Notes: ${t.notes}\n`;
|
||||
});
|
||||
const media = {
|
||||
mimeType: 'text/html',
|
||||
body: Readable.from([content]),
|
||||
};
|
||||
|
||||
md += `\n## Completed Tasks\n`;
|
||||
report.tasks.filter((t: any) => t.type === 'COMPLETED').forEach((t: any) => {
|
||||
md += `- [x] ${t.description}\n`;
|
||||
md += ` - Status: ${t.status}\n`;
|
||||
if (t.link) md += ` - Work Link: ${t.link}\n`;
|
||||
});
|
||||
|
||||
return md;
|
||||
try {
|
||||
const response = await drive.files.update({
|
||||
fileId,
|
||||
media,
|
||||
fields: 'id, webViewLink',
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error updating Google Drive file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Only allow plain http(s) URLs into href attributes
|
||||
function safeLink(link: unknown): string | null {
|
||||
const url = String(link ?? '').trim();
|
||||
return /^https?:\/\//i.test(url) ? url : null;
|
||||
}
|
||||
|
||||
export function generateReportHTML(report: ReportWithRelations) {
|
||||
const dateObj = new Date(report.date);
|
||||
const dateStr = dateObj.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const plannedTasks = report.tasks.filter((t) => t.type === 'PLANNED');
|
||||
const completedTasks = report.tasks.filter((t) => t.type === 'COMPLETED');
|
||||
|
||||
// MPM brand palette (matches the hex values used across MPM technical docs)
|
||||
const GOLD_DARK = '#998643'; // document titles, section headings
|
||||
const GOLD_MID = '#DCBB4F'; // accent rules / dividers
|
||||
const SHADE_DARK = '#232022'; // body text / table header background
|
||||
const SHADE_LIGHT = '#F5F1EC'; // key-column / table header text fill
|
||||
const OFF_WHITE = '#FAF7F2'; // alternating data-table rows
|
||||
const ACCENT = '#849698'; // secondary / muted text
|
||||
const BORDER = '#E7E0D5'; // hairline cell borders (warm gray)
|
||||
|
||||
const bodyFont = "'Open Sans', Arial, sans-serif";
|
||||
const headingFont = "'Montserrat', 'Open Sans', Arial, sans-serif";
|
||||
|
||||
const statusLabel = (report.status || 'DRAFT').charAt(0) + (report.status || 'DRAFT').slice(1).toLowerCase();
|
||||
const docRef = `WFH-${new Date(report.date).toISOString().slice(0, 10)}`;
|
||||
|
||||
// Data-table cell styles
|
||||
const cellStyle = `padding: 8px 12px; border: 1px solid ${BORDER}; font-family: ${bodyFont}; font-size: 10.5pt; color: ${SHADE_DARK}; vertical-align: top;`;
|
||||
const headerStyle = `padding: 9px 12px; background-color: ${SHADE_DARK}; border: 1px solid ${SHADE_DARK}; font-family: ${headingFont}; font-size: 10pt; font-weight: 600; text-align: left; color: ${SHADE_LIGHT};`;
|
||||
const rowBg = (i: number) => (i % 2 === 1 ? ` background-color: ${OFF_WHITE};` : '');
|
||||
|
||||
// Document Control-style key/value row
|
||||
const metaKey = `width: 26%; padding: 8px 12px; background-color: ${SHADE_LIGHT}; border: 1px solid ${BORDER}; font-family: ${headingFont}; font-weight: 600; font-size: 10pt; color: ${SHADE_DARK};`;
|
||||
const metaVal = `padding: 8px 12px; border: 1px solid ${BORDER}; background-color: #FFFFFF; font-size: 10.5pt; color: ${SHADE_DARK};`;
|
||||
const metaRow = (k: string, v: string) =>
|
||||
`<tr><td style="${metaKey}">${k}</td><td style="${metaVal}">${v}</td></tr>`;
|
||||
|
||||
// Section label styled like the technical docs' "Document Control" heading.
|
||||
const sectionLabel = (text: string) =>
|
||||
`<p style="font-family: ${headingFont}; font-weight: 600; font-size: 10.5pt; letter-spacing: 1.5px; text-transform: uppercase; color: ${GOLD_DARK}; margin: 0 0 8px 0;">${text}</p>`;
|
||||
const sectionHeading = (text: string) =>
|
||||
`<h2 style="font-family: ${headingFont}; font-weight: 600; font-size: 14pt; color: ${GOLD_DARK}; margin: 26px 0 10px 0;">${text}</h2>`;
|
||||
|
||||
// A gold rule rendered as a filled 1-row table (survives Google Docs conversion,
|
||||
// where border-bottom on headings/divs does not).
|
||||
const goldRule = `<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 12px 0 22px 0;"><tr><td style="height: 3px; line-height: 3px; font-size: 0; padding: 0; background-color: ${GOLD_MID};"> </td></tr></table>`;
|
||||
|
||||
const emptyNote = (text: string) =>
|
||||
`<p style="font-style: italic; color: ${ACCENT}; font-family: ${bodyFont}; margin: 0 0 8px 0;">${text}</p>`;
|
||||
|
||||
// Embed the logo inline so it survives conversion without a reachable host.
|
||||
const logoDataUri = getLogoDataUri();
|
||||
const logoHtml = logoDataUri
|
||||
? `<img src="${logoDataUri}" alt="Message Point Media" style="height: 40px; margin-bottom: 14px;" />`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<html>
|
||||
<body style="font-family: ${bodyFont}; color: ${SHADE_DARK}; line-height: 1.5; max-width: 760px; margin: 0 auto; padding: 16px 24px;">
|
||||
${logoHtml}
|
||||
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 9pt; letter-spacing: 2px; text-transform: uppercase; color: ${SHADE_DARK}; margin: 0 0 2px 0;">Message Point Media</p>
|
||||
<h1 style="font-family: ${headingFont}; font-weight: 700; font-size: 23pt; color: ${GOLD_DARK}; margin: 0 0 4px 0;">WFH Daily Report</h1>
|
||||
<p style="font-size: 10.5pt; color: ${ACCENT}; margin: 0;">Daily Work-From-Home Status | ${escapeHtml(report.user.name)} | ${dateStr}</p>
|
||||
${goldRule}
|
||||
|
||||
${sectionLabel('Report Details')}
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 4px;">
|
||||
${metaRow('Employee', escapeHtml(report.user.name))}
|
||||
${metaRow('Date', dateStr)}
|
||||
${metaRow('Manager', escapeHtml(report.managerName || 'N/A'))}
|
||||
${metaRow('Status', statusLabel)}
|
||||
${metaRow('Reference', docRef)}
|
||||
</table>
|
||||
|
||||
${sectionHeading('1. Planned Tasks')}
|
||||
${plannedTasks.length > 0 ? `
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
|
||||
<tr>
|
||||
<th style="${headerStyle} width: 45%;">Description</th>
|
||||
<th style="${headerStyle} width: 20%;">Estimate</th>
|
||||
<th style="${headerStyle} width: 35%;">Notes</th>
|
||||
</tr>
|
||||
${plannedTasks.map((t, i) => `
|
||||
<tr>
|
||||
<td style="${cellStyle}${rowBg(i)}">${escapeHtml(t.description)}</td>
|
||||
<td style="${cellStyle}${rowBg(i)} color: ${ACCENT};">${escapeHtml(t.timeEstimate || '-')}</td>
|
||||
<td style="${cellStyle}${rowBg(i)} color: ${ACCENT};">${escapeHtml(t.notes || '-')}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
` : emptyNote('No planned tasks for today.')}
|
||||
|
||||
${sectionHeading('2. Completed Tasks')}
|
||||
${completedTasks.length > 0 ? `
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
|
||||
<tr>
|
||||
<th style="${headerStyle} width: 40%;">Description</th>
|
||||
<th style="${headerStyle} width: 20%;">Status</th>
|
||||
<th style="${headerStyle} width: 40%;">Work Link</th>
|
||||
</tr>
|
||||
${completedTasks.map((t, i) => {
|
||||
const link = safeLink(t.link);
|
||||
return `
|
||||
<tr>
|
||||
<td style="${cellStyle}${rowBg(i)}">${escapeHtml(t.description)}</td>
|
||||
<td style="${cellStyle}${rowBg(i)} font-weight: 600; color: ${GOLD_DARK};">${escapeHtml(t.status || 'Done')}</td>
|
||||
<td style="${cellStyle}${rowBg(i)}">${link ? `<a href="${escapeHtml(link)}" style="color: ${GOLD_DARK}; text-decoration: none;">${escapeHtml(link)}</a>` : '-'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</table>
|
||||
` : emptyNote('No completed tasks reported today.')}
|
||||
|
||||
${goldRule}
|
||||
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 10pt; color: ${SHADE_DARK}; margin: 0 0 2px 0;">Prepared by ${escapeHtml(report.user.name)}</p>
|
||||
<p style="font-size: 9pt; color: ${ACCENT}; margin: 0 0 10px 0;">Message Point Media · ${docRef} · Generated by the WFH Daily Report app</p>
|
||||
<p style="font-family: ${headingFont}; font-weight: 600; font-size: 9.5pt; letter-spacing: 0.5px; color: ${GOLD_DARK}; margin: 0 0 2px 0;">Born to Innovate. Built to Last.</p>
|
||||
<p style="font-size: 8.5pt; font-style: italic; color: ${ACCENT}; margin: 0;">Compelling… Affordable… Dynamic… Messaging… It’s What We Do!</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
+1
-1
@@ -25,6 +25,6 @@ export const prisma = new Proxy({} as PrismaClient, {
|
||||
return Reflect.get(getPrismaClient(), prop)
|
||||
},
|
||||
apply(_target, thisArg, args) {
|
||||
return Reflect.apply(getPrismaClient() as unknown as Function, thisArg, args)
|
||||
return Reflect.apply(getPrismaClient() as unknown as (...a: unknown[]) => unknown, thisArg, args)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const TASK_STATUSES = ["PENDING", "DONE", "IN_PROGRESS", "CANCELLED"] as const;
|
||||
export const REPORT_STATUSES = ["IN_PROGRESS", "COMPLETED", "SUBMITTED"] as const;
|
||||
|
||||
const httpLink = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.refine((v) => v === "" || /^https?:\/\//i.test(v), {
|
||||
message: "Link must start with http:// or https://",
|
||||
});
|
||||
|
||||
export const createReportSchema = z.object({
|
||||
managerName: z.string().trim().min(1).max(200),
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const updateReportSchema = z.object({
|
||||
managerName: z.string().trim().min(1).max(200).optional(),
|
||||
status: z.enum(REPORT_STATUSES).optional(),
|
||||
});
|
||||
|
||||
export const createTaskSchema = z.object({
|
||||
reportId: z.string().min(1),
|
||||
type: z.enum(["PLANNED", "COMPLETED"]),
|
||||
description: z.string().max(2000).default(""),
|
||||
timeEstimate: z.string().max(100).nullish(),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
link: httpLink.nullish(),
|
||||
status: z.enum(TASK_STATUSES).nullish(),
|
||||
});
|
||||
|
||||
export const updateTaskSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["PLANNED", "COMPLETED"]).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
timeEstimate: z.string().max(100).nullish(),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
link: httpLink.nullish(),
|
||||
status: z.enum(TASK_STATUSES).nullish(),
|
||||
});
|
||||
|
||||
export const updateSettingSchema = z.object({
|
||||
key: z.enum(["GOOGLE_DRIVE_FOLDER_ID"]),
|
||||
value: z.string().trim().max(500),
|
||||
});
|
||||
|
||||
export const updateUserRoleSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
role: z.enum(["EMPLOYEE", "ADMIN"]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse and validate a request body. Returns the parsed data, or a
|
||||
* NextResponse 400 that the route should return immediately.
|
||||
*/
|
||||
export async function parseBody<T extends z.ZodTypeAny>(
|
||||
req: Request,
|
||||
schema: T
|
||||
): Promise<{ data: z.infer<T>; error: null } | { data: null; error: NextResponse }> {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await req.json();
|
||||
} catch {
|
||||
return {
|
||||
data: null,
|
||||
error: NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }),
|
||||
};
|
||||
}
|
||||
|
||||
const result = schema.safeParse(json);
|
||||
if (!result.success) {
|
||||
return {
|
||||
data: null,
|
||||
error: NextResponse.json(
|
||||
{ error: "Validation failed", details: result.error.flatten().fieldErrors },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { data: result.data, error: null };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Serialized shapes of API responses (Dates arrive as ISO strings over JSON).
|
||||
|
||||
export type TaskType = "PLANNED" | "COMPLETED";
|
||||
export type TaskStatus = "PENDING" | "DONE" | "IN_PROGRESS" | "CANCELLED";
|
||||
export type ReportStatus = "IN_PROGRESS" | "COMPLETED" | "SUBMITTED";
|
||||
export type UserRole = "EMPLOYEE" | "ADMIN";
|
||||
|
||||
export interface TaskDTO {
|
||||
id: string;
|
||||
type: TaskType;
|
||||
description: string;
|
||||
timeEstimate: string | null;
|
||||
notes: string | null;
|
||||
status: TaskStatus | null;
|
||||
link: string | null;
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
export interface ReportUserDTO {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export interface ReportDTO {
|
||||
id: string;
|
||||
date: string;
|
||||
managerName: string;
|
||||
status: ReportStatus;
|
||||
driveFileId: string | null;
|
||||
userId: string;
|
||||
tasks: TaskDTO[];
|
||||
user?: ReportUserDTO;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReportsPageDTO {
|
||||
reports: ReportDTO[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUserDTO {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
role: UserRole;
|
||||
reports: { date: string; status: ReportStatus }[];
|
||||
}
|
||||
+7
-1
@@ -70,5 +70,11 @@ For the application to work, you must configure your Google Cloud Project:
|
||||
1. **Authorized Redirect URIs**: `http://[your-unraid-ip]:3000/api/auth/callback/google`
|
||||
2. **Scopes**: Enable `Google Drive API` and ensure `auth/drive.file` scope is requested.
|
||||
|
||||
## Database & Upgrades
|
||||
|
||||
The database schema is managed by Prisma Migrate: every container start runs `prisma migrate deploy`, which applies any pending schema migrations automatically before the server boots.
|
||||
|
||||
**Upgrading from a version prior to migrations** (i.e. a database created by the old `db push` startup): the first boot after the upgrade will log a one-time `P3005` error followed by `Migration 0_init marked as applied.` — this is the automatic baseline step and is expected. No manual action is required, and your data is preserved. Any duplicate reports for the same user and day (a bug in older versions) are merged automatically during this migration.
|
||||
|
||||
## Support
|
||||
The database is a single SQLite file located at `/mnt/user/appdata/wfh-daily-report/data/dev.db`. You can back this file up directly.
|
||||
The database is a single SQLite file located at `/mnt/user/appdata/wfh-daily-report/data/dev.db`. You can back this file up directly (ideally while the container is stopped, or copy the file atomically).
|
||||
|
||||
Reference in New Issue
Block a user