chore: initialize Next.js 15 project with Tailwind, TypeScript and tooling config

This commit is contained in:
Hardik 2026-05-05 23:23:43 +05:30
commit 36f3826684
16 changed files with 11039 additions and 0 deletions

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# Python
__pycache__/
*.pyc
*.pyo
# Node / Next.js (nested app has its own .gitignore too)
node_modules/
.next/
.pnpm-store/
# Env files with real secrets
App/pelagia-portal/.env
App/pelagia-portal/.env.local
App/pelagia-portal/.env.*.local
# Build / generated
App/pelagia-portal/.vercel/
App/pelagia-portal/coverage/
# OS
.DS_Store
Thumbs.db

View file

@ -0,0 +1,36 @@
# =============================================================
# Pelagia Portal — Environment Variables
# Copy this file to .env.local and fill in your values
#
# DEVELOPMENT (NODE_ENV=development, i.e. `pnpm dev`):
# - File uploads are stored locally in .dev-uploads/ — no R2 needed
# - Emails are logged to the terminal — no Resend key needed
# - Only AUTH + DATABASE vars are required to run the app locally
#
# PRODUCTION (NODE_ENV=production, i.e. `pnpm build && pnpm start`):
# - All sections below must be filled in
# =============================================================
# ── Auth ─────────────────────────────────────────────────────
NEXTAUTH_SECRET=your-32-char-secret-here-generate-with-openssl
NEXTAUTH_URL=http://localhost:3000
# ── Database ──────────────────────────────────────────────────
# Local PostgreSQL or Supabase
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pelagia_portal"
# Supabase connection pooling URL (use for serverless deployments)
# DATABASE_POOL_URL=
# ── Cloudflare R2 Storage (production only) ──────────────────
# Not required in development — files are stored in .dev-uploads/
R2_ACCOUNT_ID=your-cloudflare-account-id
R2_ACCESS_KEY_ID=your-r2-access-key-id
R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
R2_BUCKET_NAME=pelagia-portal
R2_PUBLIC_URL=https://your-bucket.your-account.r2.cloudflarestorage.com
# ── Email / Resend (production only) ─────────────────────────
# Not required in development — emails are printed to the terminal
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM=noreply@pelagiaportal.com
EMAIL_FROM_NAME="Pelagia Portal"

41
App/pelagia-portal/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
# Next.js
/.next/
/out/
# Production
/build
# Testing
/coverage
/playwright-report
/test-results
/blob-report
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Prisma
prisma/*.db
prisma/*.db-journal
# Dev local file uploads
.dev-uploads/
# Misc
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vercel
*.tsbuildinfo
next-env.d.ts

View file

@ -0,0 +1,216 @@
# Pelagia Portal
An internal purchase order management system for a maritime vessel-operations company. Digitises the full PO lifecycle — from crew requisition through manager approval, vendor validation, accounts payment, and receipt confirmation — replacing ad-hoc email chains and spreadsheets with a single auditable workflow.
## Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| Language | TypeScript 5 (strict) |
| Database | PostgreSQL 16 via Prisma 5 |
| Auth | NextAuth.js v5 (credentials) |
| Styling | Tailwind CSS v4 + shadcn/ui |
| File Storage | Cloudflare R2 (production) / local filesystem (development) |
| Email | Resend (production) / console log (development) |
---
## Prerequisites
| Tool | Required Version |
|---|---|
| Node.js | >= 20.11.0 LTS |
| pnpm | >= 9.0.0 |
| PostgreSQL | >= 16 (local or Docker) |
Install pnpm if you don't have it:
```bash
npm install -g pnpm
```
---
## Development Setup
In development mode the app requires **only a database and auth secret** — Cloudflare R2 and Resend are not needed. File uploads are saved to `.dev-uploads/` on your local machine, and emails are printed to the terminal instead of being sent.
### 1. Install dependencies
```bash
pnpm install
```
### 2. Configure environment
Copy the example file and fill in the two required values:
```bash
cp .env.example .env.local
```
Minimum `.env.local` for development:
```env
NEXTAUTH_SECRET=<generate with: openssl rand -base64 32>
NEXTAUTH_URL=http://localhost:3000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pelagia_portal"
```
The R2 and Resend variables are not needed in development and can be left as placeholders.
### 3. Set up the database
Create the database (if it doesn't exist yet), run migrations, and seed sample data:
```bash
pnpm db:migrate # runs prisma migrate dev
pnpm db:seed # seeds vessels, accounts, vendors, and demo users
```
To inspect the database with a GUI:
```bash
pnpm db:studio # opens Prisma Studio at http://localhost:5555
```
### 4. Start the dev server
```bash
pnpm dev
```
The app will be available at [http://localhost:3000](http://localhost:3000).
**Email behaviour in dev:** all notification emails are logged to the terminal in place of actual delivery. Look for lines starting with `📧 [DEV EMAIL]`.
**File upload behaviour in dev:** uploaded files are written to `.dev-uploads/` at the project root. This directory is git-ignored.
---
## Serving in Production
Production requires all environment variables to be set, including Cloudflare R2 credentials and a Resend API key.
### 1. Configure environment
Set the following variables in your hosting platform (Vercel, etc.) or in `.env.local` for a self-hosted deploy:
```env
# Auth
NEXTAUTH_SECRET=<strong random secret>
NEXTAUTH_URL=https://your-domain.com
# Database
DATABASE_URL=postgresql://<user>:<password>@<host>:<port>/<db>
# Cloudflare R2
R2_ACCOUNT_ID=<your cloudflare account id>
R2_ACCESS_KEY_ID=<r2 access key>
R2_SECRET_ACCESS_KEY=<r2 secret key>
R2_BUCKET_NAME=pelagia-portal
R2_PUBLIC_URL=https://<bucket>.<account>.r2.cloudflarestorage.com
# Email
RESEND_API_KEY=re_<your key>
EMAIL_FROM=noreply@yourdomain.com
EMAIL_FROM_NAME="Pelagia Portal"
```
### 2. Run database migrations
```bash
pnpm db:migrate:deploy # runs prisma migrate deploy (safe for production)
```
### 3. Build and start
```bash
pnpm build
pnpm start
```
The app listens on port 3000 by default. Point your reverse proxy (nginx, Caddy, etc.) or hosting platform to that port.
---
## Database Management
| Command | Purpose |
|---|---|
| `pnpm db:migrate` | Create and run a new migration (dev only) |
| `pnpm db:migrate:deploy` | Apply pending migrations without prompting (CI/production) |
| `pnpm db:push` | Push schema changes without a migration file (prototyping only) |
| `pnpm db:seed` | Seed sample data |
| `pnpm db:studio` | Open Prisma Studio GUI |
| `pnpm db:reset` | Drop and recreate the database, then re-seed (dev only) |
---
## Testing
```bash
pnpm test # unit + integration tests (Vitest)
pnpm test:watch # watch mode
pnpm test:e2e # end-to-end tests (Playwright)
pnpm test:e2e:ui # Playwright with interactive UI
```
---
## Other Scripts
```bash
pnpm lint # ESLint
pnpm type-check # tsc --noEmit
pnpm email:preview # live-preview email templates at http://localhost:3001
```
---
## Project Structure
```
pelagia-portal/
├── app/ # Next.js App Router pages and API routes
│ ├── (auth)/ # Login
│ ├── (portal)/ # Authenticated shell (sidebar + header)
│ │ ├── dashboard/
│ │ ├── po/ # PO creation, detail, edit
│ │ ├── approvals/ # Manager approval queue
│ │ ├── payments/ # Accounts payment queue
│ │ ├── history/ # Audit trail
│ │ └── admin/ # User, vessel, account, vendor management
│ └── api/
│ ├── auth/ # NextAuth endpoints
│ ├── files/sign/ # Generate presigned upload URL (production)
│ ├── files/dev/ # Local file upload/download handler (dev only)
│ └── reports/export/ # CSV / PDF export
├── components/ # Shared UI components (shadcn/ui + custom)
├── lib/ # Business logic
│ ├── po-state-machine.ts # All PO state transitions enforced here
│ ├── permissions.ts # Role → allowed-action map
│ ├── notifier.ts # Email dispatch (Resend in prod, console in dev)
│ ├── storage.ts # File storage (R2 in prod, local in dev)
│ └── validations/ # Zod schemas
├── emails/ # React Email templates
├── prisma/ # Schema and migrations
└── tests/ # Vitest unit/integration + Playwright E2E
```
---
## Roles
| Role | Description |
|---|---|
| Technical | Deck/engine crew — create and submit POs, confirm receipt |
| Manning | Crew-management staff — same as Technical |
| Manager | Review, approve, reject, request edits |
| Accounts | Process payment for approved POs |
| SuperUser | Combined Technical + Manning + Manager authority |
| Auditor | Read-only access to all records and reports |
| Admin | Manage users, vessels, accounts, and vendors |
User accounts are provisioned by an Admin; there is no self-registration.

View file

@ -0,0 +1,50 @@
@import "tailwindcss";
@theme {
/* Brand colours */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-800: #1e40af;
--color-success: #16a34a;
--color-success-50: #f0fdf4;
--color-success-100: #dcfce7;
--color-success-700: #15803d;
--color-warning: #d97706;
--color-warning-50: #fffbeb;
--color-warning-100: #fef3c7;
--color-warning-700: #b45309;
--color-danger: #dc2626;
--color-danger-50: #fef2f2;
--color-danger-100: #fee2e2;
--color-danger-700: #b91c1c;
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace;
}
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
background-color: #f9fafb;
color: #111827;
}
}

View file

@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "Pelagia Portal",
template: "%s | Pelagia Portal",
},
description: "Purchase Order Management System",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body>{children}</body>
</html>
);
}

View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

View file

@ -0,0 +1,73 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import type { POStatus } from "@prisma/client";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(amount: number | string, currency = "INR"): string {
return new Intl.NumberFormat("en-IN", { style: "currency", currency }).format(
Number(amount)
);
}
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
}
export function formatDateTime(date: Date | string): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
}
export function generatePoNumber(): string {
const year = new Date().getFullYear();
const seq = Math.floor(Math.random() * 100000)
.toString()
.padStart(5, "0");
return `PO-${year}-${seq}`;
}
export const PO_STATUS_LABELS: Record<POStatus, string> = {
DRAFT: "Draft",
SUBMITTED: "Submitted",
MGR_REVIEW: "Under Review",
VENDOR_ID_PENDING: "Vendor ID Pending",
EDITS_REQUESTED: "Edits Requested",
REJECTED: "Rejected",
MGR_APPROVED: "Approved",
SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid",
CLOSED: "Closed",
};
export type BadgeVariant =
| "default"
| "secondary"
| "success"
| "warning"
| "danger"
| "outline";
export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
DRAFT: "outline",
SUBMITTED: "secondary",
MGR_REVIEW: "secondary",
VENDOR_ID_PENDING: "warning",
EDITS_REQUESTED: "warning",
REJECTED: "danger",
MGR_APPROVED: "success",
SENT_FOR_PAYMENT: "default",
PAID_DELIVERED: "success",
CLOSED: "secondary",
};

View file

@ -0,0 +1,24 @@
import type { NextConfig } from "next";
const isDev = process.env.NODE_ENV === "development";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.r2.cloudflarestorage.com",
},
...(isDev
? [{ protocol: "http" as const, hostname: "localhost" }]
: []),
],
},
};
export default nextConfig;

View file

@ -0,0 +1,79 @@
{
"name": "pelagia-portal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:watch": "vitest --config vitest.integration.config.ts",
"test:all": "vitest run && vitest run --config vitest.integration.config.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:reset": "prisma migrate reset",
"email:preview": "email dev --dir emails --port 3001"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.0",
"@aws-sdk/client-s3": "^3.705.0",
"@aws-sdk/s3-request-presigner": "^3.705.0",
"@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.2.4",
"@react-email/components": "^0.0.27",
"@react-email/render": "^1.0.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.13.3",
"resend": "^4.0.0",
"tailwind-merge": "^2.5.4",
"xlsx": "^0.18.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/bcryptjs": "^2.4.6",
"@types/jsdom": "^28.0.1",
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.16.0",
"eslint-config-next": "^15.1.0",
"jsdom": "^29.1.1",
"prisma": "^5.22.0",
"react-email": "^3.0.2",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}

View file

@ -0,0 +1,25 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});

10362
App/pelagia-portal/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,23 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./tests/setup.ts"],
include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
coverage: {
provider: "v8",
include: ["lib/**", "components/**"],
exclude: ["lib/db.ts"],
},
},
resolve: {
alias: {
"@": resolve(__dirname, "."),
},
},
});

View file

@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["tests/integration/**/*.test.ts"],
testTimeout: 30000,
hookTimeout: 30000,
// Run integration tests serially to avoid DB conflicts
pool: "forks",
poolOptions: { forks: { singleFork: true } },
},
resolve: {
alias: { "@": resolve(__dirname, ".") },
},
});