Initial import: property management SaaS + security hardening + admin dashboard
Property Management Network — Next.js 16 (App Router), Better Auth, Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend. Includes: - Security hardening: access-control/IDOR fixes, TLS-by-default DB layer, constant-time cron auth, strict security headers, atomic AI quota gating, HTML/email output encoding, demo-backdoor disabled in production. - Superadmin dashboard at /admin (overview/MRR, server-paginated users with ban/impersonate/plan/delete, billing, platform activity + admin audit log, AI usage, system health) via the Better Auth admin plugin. - Seed/migration utility scripts under scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# Dependencies & build output (reinstalled / rebuilt inside the image)
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Secrets — never bake env files into the image
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Local file storage (uploads live on a mounted volume, not in the image)
|
||||||
|
storage
|
||||||
|
|
||||||
|
# Version control & tooling
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.vercel
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Editor / OS noise
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Docs not needed at runtime
|
||||||
|
DOCS
|
||||||
|
README.md
|
||||||
|
COOLIFY.md
|
||||||
|
|
||||||
|
# Don't copy the Docker context files into the image
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ============================================
|
||||||
|
# PROPERTY MANAGEMENT NETWORK — Environment Variables
|
||||||
|
# ============================================
|
||||||
|
# Copy this file to .env.local and fill in your values.
|
||||||
|
# Never commit .env.local to version control.
|
||||||
|
|
||||||
|
# === DATABASE (external Postgres) ===
|
||||||
|
# Standard Postgres connection string.
|
||||||
|
DATABASE_URL=postgres://user:password@host:5432/dbname
|
||||||
|
|
||||||
|
# === BETTER AUTH ===
|
||||||
|
# Generate a secret: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
BETTER_AUTH_SECRET=your-random-secret-here
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Google OAuth — create credentials at https://console.cloud.google.com
|
||||||
|
# Authorized redirect URI: <BETTER_AUTH_URL>/api/auth/callback/google
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# === STORAGE (local disk) ===
|
||||||
|
# Directory where uploaded files are stored (kept out of the public web root).
|
||||||
|
STORAGE_DIR=./storage
|
||||||
|
|
||||||
|
# === STRIPE ===
|
||||||
|
# Get from: https://dashboard.stripe.com/apikeys
|
||||||
|
STRIPE_SECRET_KEY=sk_test_your-secret-key
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key
|
||||||
|
|
||||||
|
# Stripe Price IDs — create in Stripe Dashboard > Products
|
||||||
|
STRIPE_PRO_MONTHLY_PRICE_ID=price_your-pro-monthly-id
|
||||||
|
STRIPE_LANDLORD_MONTHLY_PRICE_ID=price_your-landlord-monthly-id
|
||||||
|
STRIPE_LIFETIME_PRICE_ID=price_your-lifetime-id
|
||||||
|
|
||||||
|
# === AI (OpenAI) ===
|
||||||
|
# Get from: https://platform.openai.com/api-keys
|
||||||
|
OPENAI_API_KEY=sk-your-api-key
|
||||||
|
|
||||||
|
# === EMAIL (Resend) ===
|
||||||
|
# Get from: https://resend.com/api-keys
|
||||||
|
RESEND_API_KEY=re_your-api-key
|
||||||
|
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||||
|
|
||||||
|
# === APP ===
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_APP_NAME=Property Management Network
|
||||||
|
CRON_SECRET=your-random-secret-string
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# PROPERTY MANAGEMENT NETWORK — Production Environment
|
||||||
|
# ============================================================================
|
||||||
|
# Set these in Coolify (Environment Variables). Do NOT commit real values.
|
||||||
|
#
|
||||||
|
# Build-time vs runtime:
|
||||||
|
# NEXT_PUBLIC_* are inlined into the browser bundle during `next build`, so
|
||||||
|
# they MUST also be set as Build Variables in Coolify (not just runtime).
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# === DATABASE (PostgreSQL) ===
|
||||||
|
# Coolify Postgres (internal): postgres://USER:PASSWORD@<service-name>:5432/DB
|
||||||
|
DATABASE_URL=postgres://user:password@db:5432/pmn
|
||||||
|
|
||||||
|
# TLS policy (app + migrations). Default is encrypted + certificate-verified.
|
||||||
|
# disable -> no TLS. Use for Coolify's internal/private-network Postgres
|
||||||
|
# and the bundled docker-compose DB (plaintext over a private net).
|
||||||
|
# no-verify -> encrypted but unverified (self-signed certs).
|
||||||
|
# require -> encrypted + verified (managed DBs with a public CA).
|
||||||
|
# DATABASE_CA -> optional custom CA cert (PEM) when verifying.
|
||||||
|
DATABASE_SSL=require
|
||||||
|
# DATABASE_CA=
|
||||||
|
|
||||||
|
# Run pending migrations automatically when the container starts.
|
||||||
|
# Set to "false" for multi-replica deploys and run migrations as a one-off job.
|
||||||
|
RUN_MIGRATIONS_ON_START=true
|
||||||
|
|
||||||
|
# === BETTER AUTH ===
|
||||||
|
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
BETTER_AUTH_SECRET=replace-with-a-64-char-hex-secret
|
||||||
|
# Public base URL of the app (no trailing slash).
|
||||||
|
BETTER_AUTH_URL=https://propertymanagement.network
|
||||||
|
|
||||||
|
# Google OAuth (optional). Redirect URI: <BETTER_AUTH_URL>/api/auth/callback/google
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# === STORAGE (local disk — mount a persistent volume on this path) ===
|
||||||
|
STORAGE_DIR=/app/storage
|
||||||
|
|
||||||
|
# === STRIPE ===
|
||||||
|
STRIPE_SECRET_KEY=sk_live_xxx
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
|
||||||
|
STRIPE_PRO_MONTHLY_PRICE_ID=price_xxx
|
||||||
|
STRIPE_LANDLORD_MONTHLY_PRICE_ID=price_xxx
|
||||||
|
STRIPE_LIFETIME_PRICE_ID=price_xxx
|
||||||
|
|
||||||
|
# === AI (OpenAI) ===
|
||||||
|
OPENAI_API_KEY=sk-xxx
|
||||||
|
|
||||||
|
# === EMAIL (Resend) ===
|
||||||
|
RESEND_API_KEY=re_xxx
|
||||||
|
RESEND_FROM_EMAIL=noreply@propertymanagement.network
|
||||||
|
|
||||||
|
# === APP (NEXT_PUBLIC_* — also set as Build Variables) ===
|
||||||
|
NEXT_PUBLIC_APP_URL=https://propertymanagement.network
|
||||||
|
NEXT_PUBLIC_APP_NAME=Property Management Network
|
||||||
|
|
||||||
|
# === CRON ===
|
||||||
|
# Bearer token required by the /api/cron/* and /api/follow-ups/run endpoints.
|
||||||
|
CRON_SECRET=replace-with-a-random-string
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Keep shell scripts LF so they run inside Linux containers even when the repo
|
||||||
|
# is checked out / edited on Windows.
|
||||||
|
*.sh text eol=lf
|
||||||
|
docker-entrypoint.sh text eol=lf
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local file storage (uploaded documents/photos)
|
||||||
|
/storage
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
!.env.production.example
|
||||||
|
|
||||||
|
# Legacy Supabase check script — contains a hardcoded service_role key.
|
||||||
|
# Excluded from version control; rotate that key and delete this file.
|
||||||
|
supabase/verify.mjs
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
.env.local
|
||||||
|
DOCS/
|
||||||
|
|
||||||
|
.vercel
|
||||||
|
.env*.local
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
# Deploying Property Management Network on Coolify
|
||||||
|
|
||||||
|
This app is a Next.js 16 (App Router) server that needs:
|
||||||
|
|
||||||
|
- a **PostgreSQL** database,
|
||||||
|
- a **persistent volume** for uploaded files (documents/photos are stored on local disk under `STORAGE_DIR`),
|
||||||
|
- a few third-party API keys (Stripe, OpenAI, Resend),
|
||||||
|
- **scheduled tasks** for the rent/lease cron jobs (Coolify replaces `vercel.json` crons).
|
||||||
|
|
||||||
|
The repo ships a production `Dockerfile` (standalone output), a `/api/health` liveness probe, and an entrypoint that runs database migrations on boot.
|
||||||
|
|
||||||
|
There are two ways to deploy. **Path A (Dockerfile + separate Postgres) is recommended.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path A — Dockerfile build pack + Coolify Postgres (recommended)
|
||||||
|
|
||||||
|
### 1. Create the database
|
||||||
|
In your Coolify project: **+ New → Database → PostgreSQL**. Once created, copy its **internal connection string** (looks like `postgres://postgres:PASSWORD@<service>:5432/postgres`). Use the internal host — the app talks to it over Coolify's private network.
|
||||||
|
|
||||||
|
### 2. Create the application
|
||||||
|
**+ New → Application → Public/Private Git Repository**, point it at this repo, and set **Build Pack = Dockerfile**.
|
||||||
|
|
||||||
|
### 3. Set environment variables
|
||||||
|
Under the app's **Environment Variables**, add everything from [`.env.production.example`](.env.production.example). At minimum:
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL` | Internal Postgres URL from step 1. |
|
||||||
|
| `DATABASE_SSL` | TLS policy. Use `disable` for Coolify's internal/private-network Postgres; `require` (default) for managed/external DBs; `no-verify` for self-signed certs. |
|
||||||
|
| `BETTER_AUTH_SECRET` | `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
|
||||||
|
| `BETTER_AUTH_URL` | Your public URL, e.g. `https://propertymanagement.network` |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | Same public URL. **Also mark as a Build Variable** (see below). |
|
||||||
|
| `NEXT_PUBLIC_APP_NAME` | `Property Management Network` (Build Variable too). |
|
||||||
|
| `CRON_SECRET` | Random string; protects the cron endpoints. |
|
||||||
|
| `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | Email sending. |
|
||||||
|
| `STRIPE_*` | Billing (optional to start). |
|
||||||
|
| `OPENAI_API_KEY` | AI assistant (optional to start). |
|
||||||
|
|
||||||
|
> **Build Variables:** `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_APP_NAME` are inlined into the browser bundle at build time. In Coolify, set them so they're **available at build** (toggle "Build Variable" / "Available at Buildtime"). They're passed to the image via `ARG`/`--build-arg`.
|
||||||
|
|
||||||
|
### 4. Add a persistent volume for uploads
|
||||||
|
Uploaded files are written to `STORAGE_DIR` (default `/app/storage`). Without a volume they're lost on every redeploy.
|
||||||
|
|
||||||
|
Under the app's **Storages → Add**: mount a persistent volume at the container path **`/app/storage`**.
|
||||||
|
|
||||||
|
### 5. Domain & port
|
||||||
|
- Set the app's **Domain** to your URL; Coolify provisions HTTPS automatically.
|
||||||
|
- The container listens on **port 3000** (already `EXPOSE`d). Coolify usually detects this; set the port to `3000` if asked.
|
||||||
|
|
||||||
|
### 6. Health check
|
||||||
|
The image has a built-in Docker `HEALTHCHECK` hitting `/api/health`. You can also set Coolify's health check path to `/api/health`.
|
||||||
|
|
||||||
|
### 7. Deploy
|
||||||
|
Click **Deploy**. On boot the entrypoint runs `scripts/migrate.mjs` to apply migrations, then starts the server. Watch the deploy logs for `[migrate] Migrations applied successfully.` followed by the Next.js ready line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path B — Docker Compose (app + Postgres bundled)
|
||||||
|
|
||||||
|
Use the included [`docker-compose.yml`](docker-compose.yml) with Coolify's **Docker Compose** build pack. It defines the `app` and a `db` (Postgres 17) plus named volumes `app-storage` and `db-data`.
|
||||||
|
|
||||||
|
Set these env vars in Coolify (mark `NEXT_PUBLIC_*` and `POSTGRES_*` as available at build time):
|
||||||
|
|
||||||
|
```
|
||||||
|
POSTGRES_USER=pmn
|
||||||
|
POSTGRES_PASSWORD=<strong-password>
|
||||||
|
POSTGRES_DB=pmn
|
||||||
|
BETTER_AUTH_SECRET=<hex>
|
||||||
|
BETTER_AUTH_URL=https://your-domain
|
||||||
|
NEXT_PUBLIC_APP_URL=https://your-domain
|
||||||
|
NEXT_PUBLIC_APP_NAME=Property Management Network
|
||||||
|
CRON_SECRET=<random>
|
||||||
|
RESEND_API_KEY=... # plus STRIPE_*, OPENAI_API_KEY as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATABASE_URL` is composed automatically from the `POSTGRES_*` values inside the compose file. The app waits for the DB healthcheck before starting and migrations retry while Postgres comes up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database migrations
|
||||||
|
|
||||||
|
Migrations live in `lib/db/migrations` (Drizzle). They run automatically on container start via the entrypoint.
|
||||||
|
|
||||||
|
- To **disable** auto-migrate (e.g. when running more than one replica), set `RUN_MIGRATIONS_ON_START=false` and run them as a one-off instead:
|
||||||
|
```sh
|
||||||
|
# From a Coolify terminal/exec into the container:
|
||||||
|
node scripts/migrate.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduled tasks (cron)
|
||||||
|
|
||||||
|
Coolify does not read `vercel.json`. Recreate the two jobs under the app's **Scheduled Tasks**. Each runs a command inside the container; authenticate with the `CRON_SECRET` env var that's already present there.
|
||||||
|
|
||||||
|
| Name | Schedule (UTC) | Command |
|
||||||
|
|---|---|---|
|
||||||
|
| Daily (rent reminders, overdue, lease expiry) | `0 9 * * *` | `wget -q -O- --header="Authorization: Bearer $CRON_SECRET" http://127.0.0.1:3000/api/cron/daily` |
|
||||||
|
| Late fees | `0 8 * * *` | `wget -q -O- --header="Authorization: Bearer $CRON_SECRET" http://127.0.0.1:3000/api/cron/late-fees` |
|
||||||
|
|
||||||
|
(The `daily` route already combines rent reminders, overdue marking, and 60/30/7-day lease-expiry emails.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stripe webhook (if using billing)
|
||||||
|
|
||||||
|
Point a Stripe webhook at `https://<your-domain>/api/stripe/webhook` and put its signing secret in `STRIPE_WEBHOOK_SECRET`. Subscribe to: `checkout.session.completed`, `customer.subscription.created/updated/deleted`, `invoice.payment_failed`, `payment_intent.succeeded`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-deploy checklist
|
||||||
|
|
||||||
|
- [ ] `https://<domain>/api/health` returns `{"status":"ok",...}`
|
||||||
|
- [ ] Home page shows **Property Management Network** branding
|
||||||
|
- [ ] Sign up / log in works (verifies `DATABASE_URL` + `BETTER_AUTH_*`)
|
||||||
|
- [ ] Upload a document, redeploy, confirm it persists (verifies the `/app/storage` volume)
|
||||||
|
- [ ] Trigger the `daily` scheduled task manually and confirm a 200 in logs
|
||||||
|
- [ ] (If billing) Stripe webhook delivers successfully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Google OAuth:** set `GOOGLE_CLIENT_ID/SECRET` and add `<BETTER_AUTH_URL>/api/auth/callback/google` as an authorized redirect URI.
|
||||||
|
- **Scaling:** with more than one replica, disable per-instance auto-migration (`RUN_MIGRATIONS_ON_START=false`) and note that local-disk storage is per-container — move uploads to object storage (e.g. S3) if you scale horizontally.
|
||||||
|
- **TLS:** the app and migrator default to encrypted + certificate-verified Postgres connections. Set `DATABASE_SSL=disable` for Coolify's internal private-network Postgres (and the bundled compose DB), `require` for managed/external DBs with a public CA, or `no-verify` for self-signed certs (optionally supply `DATABASE_CA`).
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Property Management Network — production image for Coolify / Docker
|
||||||
|
# Multi-stage build producing a slim Next.js standalone server.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
# libc6-compat keeps some native/optional deps happy on Alpine.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ── Install dependencies (cached on lockfile) ────────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ── Build the app ────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS builder
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# NEXT_PUBLIC_* values are inlined into the client bundle at build time, so they
|
||||||
|
# must be present here. Pass them as build args from Coolify (Build Variables).
|
||||||
|
ARG NEXT_PUBLIC_APP_URL
|
||||||
|
ARG NEXT_PUBLIC_APP_NAME="Property Management Network"
|
||||||
|
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||||
|
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runtime image ────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
# Uploaded documents/photos live here — mount a persistent volume on this path.
|
||||||
|
ENV STORAGE_DIR=/app/storage
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
|
||||||
|
|
||||||
|
# Next.js standalone output: server.js + the minimal node_modules it traced.
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Migration runner: the SQL files, the script, and the full drizzle-orm package
|
||||||
|
# (standalone tracing omits the migrator submodule the app never imports).
|
||||||
|
COPY --from=builder /app/lib/db/migrations ./lib/db/migrations
|
||||||
|
COPY --from=builder /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
|
||||||
|
COPY scripts/migrate.mjs ./scripts/migrate.mjs
|
||||||
|
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
RUN chmod +x ./docker-entrypoint.sh
|
||||||
|
|
||||||
|
# Create the storage mount point owned by the runtime user.
|
||||||
|
RUN mkdir -p /app/storage && chown -R nextjs:nodejs /app/storage
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Liveness probe (also wired into Coolify). Uses Node's global fetch — no curl/wget needed.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="public/logo-light.svg">
|
||||||
|
<img alt="Property Management Network" src="public/logo-dark.svg" width="360">
|
||||||
|
</picture>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# Property Management Network
|
||||||
|
|
||||||
|
**Property management SaaS for independent landlords.** Track properties, tenants, rent, maintenance, leases, and expenses — all in one clean dashboard.
|
||||||
|
|
||||||
|
Built with Next.js 16, PostgreSQL (Drizzle ORM), Better Auth, Stripe, and OpenAI. Ready to deploy on Vercel in under 10 minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Property Management Network replaces the spreadsheet + WhatsApp chaos that most small landlords live with. Key capabilities:
|
||||||
|
|
||||||
|
- **Properties & units** — manage your entire portfolio with occupancy tracking
|
||||||
|
- **Tenant profiles** — contact info, lease history, payment records, and a private tenant portal
|
||||||
|
- **Rent tracking** — log payments, send Stripe payment links, auto-mark overdue balances
|
||||||
|
- **Maintenance requests** — status workflow (Open → In Progress → Resolved), tenant submissions via portal
|
||||||
|
- **Lease management** — expiry countdowns, automated 60/30/7-day email alerts
|
||||||
|
- **Expenses** — categorized logging with recurring expense support
|
||||||
|
- **Documents** — file vault per property with drag-and-drop upload to local disk, served through an auth-gated route
|
||||||
|
- **AI features** — AI-powered recommendations, predictions, and impact tracking (Pro+)
|
||||||
|
- **Automated emails** — rent reminders, overdue alerts, lease expiry notifications via Resend
|
||||||
|
- **Tenant portal** — token-based (no login), tenants can view rent history and submit maintenance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revenue model
|
||||||
|
|
||||||
|
| Plan | Price | Limits |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Starter | Free | 1 property, 3 tenants, no AI |
|
||||||
|
| Pro | $29/mo | 10 properties, unlimited tenants, AI (50 calls/mo) |
|
||||||
|
| Landlord | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) |
|
||||||
|
| Lifetime | $199 one-time | Everything in Landlord, forever |
|
||||||
|
|
||||||
|
Subscription billing via Stripe. Lifetime deal is ideal for Flippa buyers who want to offer an LTD to early customers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Tech |
|
||||||
|
|-------|------|
|
||||||
|
| Framework | Next.js 16.2 (App Router, TypeScript) |
|
||||||
|
| Styling | Tailwind CSS + Geist font |
|
||||||
|
| Database | PostgreSQL (via Drizzle ORM) |
|
||||||
|
| Auth | Better Auth (email/password + Google OAuth) |
|
||||||
|
| Storage | Local disk (auth-gated file serving) |
|
||||||
|
| Payments | Stripe (subscriptions + payment links) |
|
||||||
|
| AI | OpenAI (gpt-4o-mini) |
|
||||||
|
| Email | Resend |
|
||||||
|
| Cron | Vercel Cron Jobs |
|
||||||
|
| Deploy | Vercel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Clone and install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-repo>
|
||||||
|
cd property-management-network
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure environment variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in `.env.local`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database (PostgreSQL via Drizzle ORM)
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
# Auth (Better Auth)
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
BETTER_AUTH_SECRET=your-random-secret-string
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# File storage (local disk)
|
||||||
|
STORAGE_DIR=./storage
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
STRIPE_PRO_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_LANDLORD_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_LIFETIME_PRICE_ID=
|
||||||
|
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# Resend
|
||||||
|
RESEND_API_KEY=
|
||||||
|
RESEND_FROM_EMAIL=Property Management Network <noreply@yourdomain.com>
|
||||||
|
|
||||||
|
# App
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
CRON_SECRET=your-random-secret-string
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run database migrations
|
||||||
|
|
||||||
|
The schema is managed with Drizzle ORM (see `drizzle.config.ts`). Point `DATABASE_URL` at your PostgreSQL instance in `.env.local`, then apply the migrations from `lib/db/migrations`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
To regenerate migrations after changing the schema, use `npm run db:generate`. For quick local prototyping you can push the schema directly with `npm run db:push`.
|
||||||
|
|
||||||
|
### 4. Configure Stripe
|
||||||
|
|
||||||
|
Create three products in your Stripe dashboard:
|
||||||
|
- **Pro Monthly** — $29/mo recurring → copy Price ID to `STRIPE_PRO_MONTHLY_PRICE_ID`
|
||||||
|
- **Landlord Monthly** — $59/mo recurring → copy Price ID to `STRIPE_LANDLORD_MONTHLY_PRICE_ID`
|
||||||
|
- **Lifetime** — $199 one-time → copy Price ID to `STRIPE_LIFETIME_PRICE_ID`
|
||||||
|
|
||||||
|
Set up a webhook at `https://yourdomain.com/api/stripe/webhook` listening to:
|
||||||
|
- `checkout.session.completed`
|
||||||
|
- `customer.subscription.created`
|
||||||
|
- `customer.subscription.updated`
|
||||||
|
- `customer.subscription.deleted`
|
||||||
|
- `invoice.payment_failed`
|
||||||
|
- `payment_intent.succeeded`
|
||||||
|
|
||||||
|
### 5. Configure Resend
|
||||||
|
|
||||||
|
Add a verified sending domain in your Resend dashboard. Update `RESEND_FROM_EMAIL` with your domain address.
|
||||||
|
|
||||||
|
### 6. (Optional) Google OAuth
|
||||||
|
|
||||||
|
Create OAuth credentials in the Google Cloud Console and set `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` to enable Google sign-in via Better Auth.
|
||||||
|
|
||||||
|
### 7. Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
### 8. Deploy to Vercel
|
||||||
|
|
||||||
|
Connect the repo in the Vercel dashboard and add all environment variables under **Settings → Environment Variables**.
|
||||||
|
|
||||||
|
Cron jobs are pre-configured in `vercel.json` and run automatically on Vercel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── (marketing)/ # Landing page, pricing, legal
|
||||||
|
├── (auth)/ # Login, signup, password reset
|
||||||
|
├── (dashboard)/ # All dashboard pages (auth-gated)
|
||||||
|
│ ├── dashboard/ # Overview + stats
|
||||||
|
│ ├── properties/ # Property + unit management
|
||||||
|
│ ├── tenants/ # Tenant profiles
|
||||||
|
│ ├── rent/ # Payment tracking
|
||||||
|
│ ├── maintenance/ # Maintenance requests
|
||||||
|
│ ├── leases/ # Lease tracking
|
||||||
|
│ ├── expenses/ # Expense logging
|
||||||
|
│ └── settings/ # Billing + profile
|
||||||
|
├── api/
|
||||||
|
│ ├── properties/ # CRUD
|
||||||
|
│ ├── tenants/ # CRUD + auto unit assignment
|
||||||
|
│ ├── rent/ # CRUD + Stripe payment links
|
||||||
|
│ ├── maintenance/ # CRUD + status workflow
|
||||||
|
│ ├── leases/ # CRUD
|
||||||
|
│ ├── expenses/ # CRUD
|
||||||
|
│ ├── documents/ # Document metadata (files on local disk)
|
||||||
|
│ ├── ai/ # Rent receipts + maintenance summaries
|
||||||
|
│ ├── notifications/ # Send emails via Resend
|
||||||
|
│ ├── stripe/ # Checkout, portal, webhook
|
||||||
|
│ └── cron/ # Rent reminders + lease expiry alerts
|
||||||
|
└── tenant-portal/[token]/ # Public tenant portal (no login)
|
||||||
|
|
||||||
|
lib/
|
||||||
|
├── db/ # Drizzle schema, queries, migrations
|
||||||
|
├── auth.ts # Better Auth config
|
||||||
|
├── storage.ts # Local-disk file storage helpers
|
||||||
|
├── stripe/ # Client, plans, payment links
|
||||||
|
├── ai/ # OpenAI client + prompts
|
||||||
|
├── email/ # Resend client + HTML templates
|
||||||
|
└── validations/ # Zod schemas for all entities
|
||||||
|
|
||||||
|
drizzle.config.ts # Drizzle ORM config (DATABASE_URL, migrations dir)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
11 tables, managed via Drizzle ORM:
|
||||||
|
|
||||||
|
`profiles` · `properties` · `units` · `tenants` · `rent_payments` · `maintenance_requests` · `leases` · `expenses` · `documents` · `notifications` · `usage_events`
|
||||||
|
|
||||||
|
Data isolation is enforced in the application layer: every API route authenticates via `getSessionUser()` and scopes its queries by `user_id`. There is no database-level RLS, so this query scoping must be maintained carefully on every new route and query.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cron jobs
|
||||||
|
|
||||||
|
| Job | Schedule | What it does |
|
||||||
|
|-----|----------|--------------|
|
||||||
|
| Rent reminders | Daily 9am UTC | Marks overdue payments, sends 3-day reminder emails |
|
||||||
|
| Lease expiry | Daily 10am UTC | Sends 60/30/7-day expiry alerts to landlord |
|
||||||
|
|
||||||
|
Cron routes are protected with `CRON_SECRET` (Bearer token in `Authorization` header).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
# Security & Pre-Deployment Checklist
|
||||||
|
|
||||||
|
> **⚠️ TREAT ALL SECRETS IN `.env.local` AS COMPROMISED.**
|
||||||
|
> This project was distributed in a transfer package, which means every secret
|
||||||
|
> that was present in `.env.local` — the `DATABASE_URL` / Postgres password,
|
||||||
|
> `BETTER_AUTH_SECRET`, and any Stripe / OpenAI / Resend API keys — has left a
|
||||||
|
> trusted boundary and **must be treated as leaked**. Rotate **all** of them
|
||||||
|
> before any production deployment or client handoff. Do not assume "it was only
|
||||||
|
> a zip" — assume the file is public.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Rotate every secret before production / handoff
|
||||||
|
|
||||||
|
Work through this list and rotate each item. Do **not** reuse any value that
|
||||||
|
ever appeared in the distributed `.env.local`.
|
||||||
|
|
||||||
|
- [ ] **Postgres password** — change the database role's password (or provision a
|
||||||
|
brand-new role) and update `DATABASE_URL` everywhere it is configured.
|
||||||
|
Then revoke the old credential.
|
||||||
|
- [ ] **`BETTER_AUTH_SECRET`** — generate a fresh 32-byte secret:
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
Rotating this invalidates existing sessions — expected and desired.
|
||||||
|
- [ ] **Stripe** — roll the secret key (and restricted keys), and rotate the
|
||||||
|
webhook signing secret in the Stripe Dashboard.
|
||||||
|
- [ ] **OpenAI** — revoke the leaked API key and issue a new one.
|
||||||
|
- [ ] **Resend** — revoke the leaked API key and issue a new one.
|
||||||
|
- [ ] **`CRON_SECRET`** — set a strong random value (the cron routes now fail
|
||||||
|
closed if it is unset):
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
Configure the same value in Vercel so Cron sends
|
||||||
|
`Authorization: Bearer <CRON_SECRET>`.
|
||||||
|
- [ ] **Google OAuth** — if the client secret was present in the transfer,
|
||||||
|
rotate it in the Google Cloud Console.
|
||||||
|
|
||||||
|
## 2. Secret hygiene
|
||||||
|
|
||||||
|
- [ ] **Never commit `.env.local`** (or any real `.env*` with live values).
|
||||||
|
Confirm it is listed in `.gitignore`.
|
||||||
|
- [ ] Store production secrets in the deployment platform's encrypted env-var
|
||||||
|
store (e.g. Vercel Project Settings → Environment Variables), not in files.
|
||||||
|
- [ ] Use distinct secrets per environment (dev / preview / production).
|
||||||
|
|
||||||
|
## 3. Database / TLS
|
||||||
|
|
||||||
|
- [ ] Set **`DATABASE_SSL=require`** in production so the connection uses
|
||||||
|
**verified TLS** (encrypted + certificate-verified). `DATABASE_SSL=disable`
|
||||||
|
is **only** for local / unix-socket development.
|
||||||
|
If the provider uses a private/custom CA, supply it via `DATABASE_CA`.
|
||||||
|
- [ ] Use a **managed Postgres on a private network** (or the provider's private
|
||||||
|
endpoint) rather than a database exposed on a public IP.
|
||||||
|
|
||||||
|
## Resolved in code
|
||||||
|
|
||||||
|
The following hardening has already been applied in this codebase:
|
||||||
|
|
||||||
|
- **TLS enforcement** — `lib/db/index.ts` now defaults to verified TLS
|
||||||
|
(`rejectUnauthorized: true`) and never silently runs plaintext. Behavior is
|
||||||
|
controlled by the explicit `DATABASE_SSL` env var (`disable` / `no-verify` /
|
||||||
|
`require`), with optional `DATABASE_CA`.
|
||||||
|
- **Constant-time cron auth** — `lib/cron-auth.ts` performs a `timingSafeEqual`
|
||||||
|
bearer-token comparison that **fails closed** when `CRON_SECRET` is unset. All
|
||||||
|
cron routes (`daily`, `late-fees`, `lease-expiry`, `rent-reminders`) now use it
|
||||||
|
and share the standard `Authorization: Bearer` scheme.
|
||||||
|
- **Security headers** — `next.config.ts` sets a strict baseline on all routes:
|
||||||
|
`X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`
|
||||||
|
(protects the tenant-portal URL token), HSTS with preload,
|
||||||
|
`X-DNS-Prefetch-Control: off`, and a Content-Security-Policy.
|
||||||
|
- **Auth rate limiting** — `lib/auth.ts` enables better-auth's built-in rate
|
||||||
|
limiting (20 requests / 60s per IP) to slow brute-force and credential
|
||||||
|
stuffing.
|
||||||
|
- **Test-plan backdoor removed** — the development-only backdoor that bypassed
|
||||||
|
plan/subscription checks is disabled in production.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { getPlatformActivity, getAdminAuditLog } from "@/lib/db/admin-queries"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { formatDate } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Activity, Shield, Ban, CreditCard, UserCog, Trash2, History,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
// Type → small accent dot color for the platform activity list.
|
||||||
|
const typeDotColor: Record<string, string> = {
|
||||||
|
rent_paid: "bg-emerald-400",
|
||||||
|
rent_overdue: "bg-red-400",
|
||||||
|
tenant_added: "bg-blue-400",
|
||||||
|
tenant_removed: "bg-orange-400",
|
||||||
|
maintenance_opened: "bg-yellow-400",
|
||||||
|
maintenance_resolved: "bg-emerald-400",
|
||||||
|
lease_created: "bg-indigo-400",
|
||||||
|
lease_expiring: "bg-amber-400",
|
||||||
|
expense_added: "bg-purple-400",
|
||||||
|
property_added: "bg-cyan-400",
|
||||||
|
inspection_completed: "bg-teal-400",
|
||||||
|
vendor_added: "bg-pink-400",
|
||||||
|
ai_action: "bg-violet-400",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit action → colored pill classes.
|
||||||
|
const actionPill: Record<string, string> = {
|
||||||
|
ban: "border-red-500/20 bg-red-500/10 text-red-400",
|
||||||
|
plan_change: "border-indigo-500/20 bg-indigo-500/10 text-indigo-400",
|
||||||
|
impersonate: "border-amber-500/20 bg-amber-500/10 text-amber-400",
|
||||||
|
delete_user: "border-red-500/20 bg-red-500/10 text-red-400",
|
||||||
|
}
|
||||||
|
const DEFAULT_PILL = "border-white/[0.08] bg-white/[0.04] text-white/40"
|
||||||
|
|
||||||
|
// Audit action → icon.
|
||||||
|
const actionIcon: Record<string, React.ElementType> = {
|
||||||
|
ban: Ban,
|
||||||
|
plan_change: CreditCard,
|
||||||
|
impersonate: UserCog,
|
||||||
|
delete_user: Trash2,
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactJson(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return ""
|
||||||
|
try {
|
||||||
|
return typeof value === "string" ? value : JSON.stringify(value)
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminActivityPage() {
|
||||||
|
const [activity, audit] = await Promise.all([
|
||||||
|
getPlatformActivity({ limit: 60 }),
|
||||||
|
getAdminAuditLog({ limit: 40 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold text-white">Activity & Audit</h1>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
|
Platform-wide events and administrative actions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
|
||||||
|
{/* ── Platform Activity ───────────────────────────────────────── */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<Activity className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Platform Activity</h2>
|
||||||
|
<span className="ml-auto text-xs text-white/30">{activity.length} events</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Activity}
|
||||||
|
title="No activity yet"
|
||||||
|
description="Platform-wide events from all accounts will appear here as users take actions."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{activity.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-start gap-3 px-5 py-3.5 hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${
|
||||||
|
typeDotColor[a.type] ?? "bg-white/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{a.title}</p>
|
||||||
|
<p className="text-xs text-white/40 truncate mt-0.5">
|
||||||
|
{a.user_email ?? "Unknown user"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="shrink-0 text-xs text-white/25 mt-0.5">
|
||||||
|
{formatDate(a.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Admin Audit Log ─────────────────────────────────────────── */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<Shield className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Admin Audit Log</h2>
|
||||||
|
<span className="ml-auto text-xs text-white/30">{audit.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{audit.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={History}
|
||||||
|
title="No audit entries"
|
||||||
|
description="Sensitive admin actions like bans, plan changes, and impersonation are recorded here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{audit.map((entry) => {
|
||||||
|
const Icon = actionIcon[entry.action] ?? Shield
|
||||||
|
const meta = compactJson(entry.metadata)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="px-5 py-3.5 hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold ${
|
||||||
|
actionPill[entry.action] ?? DEFAULT_PILL
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
{entry.target_user_id && (
|
||||||
|
<span className="font-mono text-[11px] text-white/40 truncate">
|
||||||
|
{entry.target_user_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-white/25">
|
||||||
|
{formatDate(entry.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{meta && (
|
||||||
|
<p className="mt-1.5 font-mono text-[11px] text-white/40 break-all line-clamp-2">
|
||||||
|
{meta}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{entry.ip_address && (
|
||||||
|
<p className="mt-1 font-mono text-[11px] text-white/30">
|
||||||
|
{entry.ip_address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { getAiUsageAggregates } from "@/lib/db/admin-queries"
|
||||||
|
import { StatsCard } from "@/components/dashboard/stats-card"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { Brain, BarChart3, Users } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AdminAiUsagePage() {
|
||||||
|
const { byType, totalThisMonth, topUsers } = await getAiUsageAggregates()
|
||||||
|
|
||||||
|
const sortedByType = [...byType].sort((a, b) => b.count - a.count)
|
||||||
|
const maxTypeCount = sortedByType[0]?.count ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold text-white">AI Usage</h1>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
|
AI event volume across the platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 mb-6">
|
||||||
|
<StatsCard
|
||||||
|
label="AI Calls This Month"
|
||||||
|
value={totalThisMonth}
|
||||||
|
sub="Across all accounts"
|
||||||
|
icon={Brain}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
|
||||||
|
{/* ── Usage by type ───────────────────────────────────────────── */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<BarChart3 className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Usage by type</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedByType.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title="No AI usage yet"
|
||||||
|
description="AI event volume grouped by type will appear here once usage is recorded."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{sortedByType.map((row) => (
|
||||||
|
<div key={row.event_type} className="px-5 py-3.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium text-white truncate">
|
||||||
|
{row.event_type}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-sm font-semibold tabular-nums text-white/70">
|
||||||
|
{row.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/[0.06]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-rose-500 to-pink-500"
|
||||||
|
style={{
|
||||||
|
width: `${maxTypeCount ? Math.max(2, (row.count / maxTypeCount) * 100) : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Top consumers ───────────────────────────────────────────── */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<Users className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Top consumers</h2>
|
||||||
|
<span className="ml-auto text-xs text-white/30">This month</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{topUsers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="No consumers yet"
|
||||||
|
description="The accounts driving the most AI usage this month will be listed here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{topUsers.map((u, i) => (
|
||||||
|
<div
|
||||||
|
key={u.user_id}
|
||||||
|
className="flex items-center gap-3 px-5 py-3.5 hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-white/[0.04] text-[11px] font-semibold tabular-nums text-white/40">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-white">
|
||||||
|
{u.email}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-sm font-semibold tabular-nums text-white/70">
|
||||||
|
{u.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
getPlanDistribution,
|
||||||
|
computeMrr,
|
||||||
|
getAtRiskSubscriptions,
|
||||||
|
} from "@/lib/db/admin-queries"
|
||||||
|
import { StatsCard } from "@/components/dashboard/stats-card"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { PLAN_PRICES, getPlanLabel } from "@/lib/stripe/plans"
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/utils"
|
||||||
|
import type { Plan } from "@/types"
|
||||||
|
import { DollarSign, TrendingUp, Gem, CreditCard, Download, ShieldCheck } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
past_due: "Past due",
|
||||||
|
unpaid: "Unpaid",
|
||||||
|
incomplete: "Incomplete",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan rows for the distribution table (in display order)
|
||||||
|
const PLAN_ROWS: { plan: Plan; amount: number; oneTime: boolean }[] = [
|
||||||
|
{ plan: "starter", amount: 0, oneTime: false },
|
||||||
|
{ plan: "pro", amount: PLAN_PRICES.pro?.amount ?? 29, oneTime: false },
|
||||||
|
{ plan: "landlord", amount: PLAN_PRICES.landlord?.amount ?? 59, oneTime: false },
|
||||||
|
{ plan: "lifetime", amount: PLAN_PRICES.lifetime?.amount ?? 199, oneTime: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function AdminBillingPage() {
|
||||||
|
const dist = await getPlanDistribution()
|
||||||
|
const { mrr, arr, lifetimeRevenue } = computeMrr(dist)
|
||||||
|
const atRisk = await getAtRiskSubscriptions()
|
||||||
|
|
||||||
|
const paidCustomers = dist.pro + dist.landlord + dist.lifetime
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white">Billing</h1>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">Revenue, plan mix, and subscription health</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/admin/export/billing"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white/80 transition hover:border-white/[0.16] hover:bg-white/[0.08] hover:text-white"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI cards */}
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||||
|
<StatsCard label="MRR" value={formatCurrency(mrr)} sub="Recurring monthly" icon={DollarSign} variant="success" />
|
||||||
|
<StatsCard label="ARR" value={formatCurrency(arr)} sub="Annualized run-rate" icon={TrendingUp} variant="success" />
|
||||||
|
<StatsCard
|
||||||
|
label="Lifetime Revenue"
|
||||||
|
value={formatCurrency(lifetimeRevenue)}
|
||||||
|
sub="One-time payments"
|
||||||
|
icon={Gem}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
<StatsCard label="Paid Customers" value={paidCustomers} sub="Active paid plans" icon={CreditCard} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan distribution table */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden mb-6">
|
||||||
|
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Plan Distribution</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/[0.06] text-left text-[11px] uppercase tracking-wider text-white/40">
|
||||||
|
<th className="px-5 py-3 font-medium">Plan</th>
|
||||||
|
<th className="px-5 py-3 font-medium text-right">Subscribers</th>
|
||||||
|
<th className="px-5 py-3 font-medium text-right">Unit Price</th>
|
||||||
|
<th className="px-5 py-3 font-medium text-right">Monthly Contribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/[0.04]">
|
||||||
|
{PLAN_ROWS.map(({ plan, amount, oneTime }) => {
|
||||||
|
const count = dist[plan] ?? 0
|
||||||
|
const isStarter = plan === "starter"
|
||||||
|
return (
|
||||||
|
<tr key={plan} className="hover:bg-white/[0.02] transition-colors">
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span className="font-medium text-white">{getPlanLabel(plan)}</span>
|
||||||
|
{oneTime && (
|
||||||
|
<span className="ml-2 rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold text-amber-400">
|
||||||
|
one-time
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-right tabular-nums text-white/80">{count.toLocaleString()}</td>
|
||||||
|
<td className="px-5 py-3.5 text-right tabular-nums text-white/60">
|
||||||
|
{isStarter ? "—" : formatCurrency(amount)}
|
||||||
|
{oneTime && <span className="text-white/30"> /once</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-right tabular-nums">
|
||||||
|
{isStarter ? (
|
||||||
|
<span className="text-white/30">—</span>
|
||||||
|
) : oneTime ? (
|
||||||
|
<span className="text-amber-400">
|
||||||
|
{formatCurrency(count * amount)}
|
||||||
|
<span className="text-white/30 text-xs"> one-time</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold text-emerald-400">{formatCurrency(count * amount)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-white/[0.08] bg-white/[0.02]">
|
||||||
|
<td className="px-5 py-3.5 font-semibold text-white">MRR Total</td>
|
||||||
|
<td className="px-5 py-3.5 text-right tabular-nums text-white/60">{paidCustomers.toLocaleString()}</td>
|
||||||
|
<td className="px-5 py-3.5" />
|
||||||
|
<td className="px-5 py-3.5 text-right font-bold text-emerald-400 tabular-nums">{formatCurrency(mrr)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* At-risk subscriptions table */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<h2 className="text-sm font-semibold text-white">At-risk Subscriptions</h2>
|
||||||
|
{atRisk.length > 0 && (
|
||||||
|
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-rose-500/20 px-1.5 text-[10px] font-bold text-rose-400">
|
||||||
|
{atRisk.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{atRisk.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldCheck}
|
||||||
|
title="No at-risk subscriptions"
|
||||||
|
description="All paid subscriptions are in good standing."
|
||||||
|
className="py-14"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/[0.06] text-left text-[11px] uppercase tracking-wider text-white/40">
|
||||||
|
<th className="px-5 py-3 font-medium">Email</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Plan</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-5 py-3 font-medium text-right">Expires</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/[0.04]">
|
||||||
|
{atRisk.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-white/[0.02] transition-colors">
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span className="font-medium text-white">{s.email}</span>
|
||||||
|
{s.full_name && <span className="ml-2 text-xs text-white/40">{s.full_name}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-white/70">{getPlanLabel(s.plan as Plan)}</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span className="rounded-full border border-rose-500/20 bg-rose-500/10 px-2.5 py-1 text-[10px] font-semibold text-rose-400">
|
||||||
|
{STATUS_LABELS[s.subscription_status ?? ""] ?? s.subscription_status ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-right tabular-nums text-white/60">
|
||||||
|
{s.plan_expires_at ? formatDate(s.plan_expires_at) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { requireAdmin } from "@/lib/session"
|
||||||
|
import { AdminSidebar } from "@/components/admin/admin-sidebar"
|
||||||
|
import { AdminHeader } from "@/components/admin/admin-header"
|
||||||
|
import { Breadcrumbs } from "@/components/dashboard/breadcrumbs"
|
||||||
|
import { PageTransition } from "@/components/dashboard/page-transition"
|
||||||
|
import { ScrollToTop } from "@/components/ui/scroll-to-top"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
// Gate #2 (after proxy.ts edge check): redirects non-admins. Every
|
||||||
|
// /api/admin route handler re-checks via getAdminSession() — gate #3.
|
||||||
|
const { user, profile } = await requireAdmin()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-[#09090b]">
|
||||||
|
<AdminSidebar email={profile?.email ?? user.email} name={profile?.full_name ?? user.name ?? ""} />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<AdminHeader />
|
||||||
|
<main id="main-scroll" className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
|
<Breadcrumbs />
|
||||||
|
<PageTransition>{children}</PageTransition>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
getAdminOverviewStats,
|
||||||
|
getSignupsTrend,
|
||||||
|
getAtRiskSubscriptions,
|
||||||
|
} from "@/lib/db/admin-queries"
|
||||||
|
import { StatsCard } from "@/components/dashboard/stats-card"
|
||||||
|
import { PlanDonut, SignupsBars } from "@/components/admin/admin-charts"
|
||||||
|
import { getPlanLabel } from "@/lib/stripe/plans"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
import type { Plan } from "@/types"
|
||||||
|
import {
|
||||||
|
DollarSign, TrendingUp, Users, Activity, CreditCard,
|
||||||
|
UserPlus, Building2, Home, Banknote, Brain, AlertTriangle,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
past_due: "Past due",
|
||||||
|
unpaid: "Unpaid",
|
||||||
|
incomplete: "Incomplete",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminOverviewPage() {
|
||||||
|
const [stats, signupsTrend, atRisk] = await Promise.all([
|
||||||
|
getAdminOverviewStats(),
|
||||||
|
getSignupsTrend(6),
|
||||||
|
getAtRiskSubscriptions(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold text-white">Platform Overview</h1>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">Key metrics across all accounts</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI grid */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3 sm:gap-4 mb-6">
|
||||||
|
<StatsCard
|
||||||
|
label="MRR"
|
||||||
|
value={formatCurrency(stats.mrr)}
|
||||||
|
sub="Recurring monthly"
|
||||||
|
icon={DollarSign}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="ARR"
|
||||||
|
value={formatCurrency(stats.arr)}
|
||||||
|
sub="Annualized run-rate"
|
||||||
|
icon={TrendingUp}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Total Users"
|
||||||
|
value={stats.totalUsers}
|
||||||
|
sub={`${stats.paidUsers} paid · ${stats.freeUsers} free`}
|
||||||
|
icon={Users}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Active (30d)"
|
||||||
|
value={stats.activeUsers30d}
|
||||||
|
sub="Recently active"
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Paid Users"
|
||||||
|
value={stats.paidUsers}
|
||||||
|
sub={`${stats.freeUsers} free`}
|
||||||
|
icon={CreditCard}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="New Signups"
|
||||||
|
value={stats.newSignupsThisMonth}
|
||||||
|
sub="This month"
|
||||||
|
icon={UserPlus}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Total Properties"
|
||||||
|
value={stats.totalProperties}
|
||||||
|
sub={`${stats.totalUnits} unit${stats.totalUnits !== 1 ? "s" : ""}`}
|
||||||
|
icon={Building2}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Total Tenants"
|
||||||
|
value={stats.totalTenants}
|
||||||
|
sub="Across platform"
|
||||||
|
icon={Home}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Rent Collected"
|
||||||
|
value={formatCurrency(stats.rentCollectedThisMonth)}
|
||||||
|
sub="This month"
|
||||||
|
icon={Banknote}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="AI Calls"
|
||||||
|
value={stats.aiCallsThisMonth}
|
||||||
|
sub="This month"
|
||||||
|
icon={Brain}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-6">
|
||||||
|
<PlanDonut data={stats.planDistribution} />
|
||||||
|
<SignupsBars data={signupsTrend} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* At-risk subscriptions */}
|
||||||
|
{atRisk.length > 0 && (
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">At-risk subscriptions</h2>
|
||||||
|
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-rose-500/20 px-1.5 text-[10px] font-bold text-rose-400">
|
||||||
|
{atRisk.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{atRisk.slice(0, 8).map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between px-5 py-3 hover:bg-white/[0.02] transition-colors">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{s.email}</p>
|
||||||
|
<p className="text-xs text-white/40 truncate">
|
||||||
|
{s.full_name || "—"} · {getPlanLabel(s.plan as Plan)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="ml-3 shrink-0 rounded-full border border-rose-500/20 bg-rose-500/10 px-2.5 py-1 text-[10px] font-semibold text-rose-400">
|
||||||
|
{STATUS_LABELS[s.subscription_status ?? ""] ?? s.subscription_status ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries"
|
||||||
|
import { formatDate } from "@/lib/utils"
|
||||||
|
import { Settings, Database, Table2 } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
function humanize(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminSystemPage() {
|
||||||
|
const [{ counts, cronLastRun }, env] = await Promise.all([
|
||||||
|
getSystemCounts(),
|
||||||
|
Promise.resolve(getEnvHealth()),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold text-white">System Health</h1>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
|
Configuration, database, and table statistics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5">
|
||||||
|
{/* ── Environment configuration ───────────────────────────────── */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<Settings className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Environment configuration</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{env.map(({ key, present }) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between gap-3 px-5 py-3"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-white/60 truncate">{key}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`h-2 w-2 rounded-full ${
|
||||||
|
present ? "bg-emerald-400" : "bg-amber-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
present ? "text-emerald-400" : "text-amber-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{present ? "Configured" : "Missing"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Database ────────────────────────────────────────────────── */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<Database className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Database</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
<div className="flex items-center justify-between gap-3 px-5 py-3">
|
||||||
|
<span className="text-sm text-white/60">Connection</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||||
|
<span className="text-xs font-medium text-emerald-400">Connected</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 px-5 py-3">
|
||||||
|
<span className="text-sm text-white/60">Cron last run</span>
|
||||||
|
<span className="shrink-0 text-xs text-white/40">
|
||||||
|
{cronLastRun ? formatDate(cronLastRun) : "Never run"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Table row counts ──────────────────────────────────────────── */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<Table2 className="h-4 w-4 text-rose-400 shrink-0" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Table row counts</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-px bg-white/[0.04]">
|
||||||
|
{Object.entries(counts).map(([name, value]) => (
|
||||||
|
<div key={name} className="bg-[#16161f] px-5 py-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-white/40">
|
||||||
|
{humanize(name)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-2xl font-bold tabular-nums text-white">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Home,
|
||||||
|
Users as UsersIcon,
|
||||||
|
FileText,
|
||||||
|
CreditCard,
|
||||||
|
Wrench,
|
||||||
|
Receipt,
|
||||||
|
Sparkles,
|
||||||
|
ShieldAlert,
|
||||||
|
Ban,
|
||||||
|
Activity,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { getUserDetail } from "@/lib/db/admin-queries"
|
||||||
|
import { requireAdmin } from "@/lib/session"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
import { CopyButton } from "@/components/shared/copy-button"
|
||||||
|
import { UserActions } from "@/components/admin/user-actions"
|
||||||
|
import { formatDate, initials, cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const PLAN_BADGE: Record<string, string> = {
|
||||||
|
starter: "border-white/15 bg-white/[0.04] text-white/40",
|
||||||
|
pro: "border-indigo-500/30 bg-indigo-500/10 text-indigo-300",
|
||||||
|
landlord: "border-violet-500/30 bg-violet-500/10 text-violet-300",
|
||||||
|
lifetime: "border-amber-500/30 bg-amber-500/10 text-amber-300",
|
||||||
|
}
|
||||||
|
|
||||||
|
const COUNT_META: { key: string; label: string; icon: typeof Building2 }[] = [
|
||||||
|
{ key: "propertyCount", label: "Properties", icon: Building2 },
|
||||||
|
{ key: "unitCount", label: "Units", icon: Home },
|
||||||
|
{ key: "tenantCount", label: "Tenants", icon: UsersIcon },
|
||||||
|
{ key: "leaseCount", label: "Leases", icon: FileText },
|
||||||
|
{ key: "paymentCount", label: "Payments", icon: CreditCard },
|
||||||
|
{ key: "maintenanceCount", label: "Maintenance", icon: Wrench },
|
||||||
|
{ key: "expenseCount", label: "Expenses", icon: Receipt },
|
||||||
|
{ key: "aiCount", label: "AI calls", icon: Sparkles },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function AdminUserDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const [detail, { user: me }] = await Promise.all([getUserDetail(id), requireAdmin()])
|
||||||
|
|
||||||
|
if (!detail) notFound()
|
||||||
|
|
||||||
|
const { profile, account, counts, recentActivity } = detail
|
||||||
|
const isSelf = me.id === profile.id
|
||||||
|
const planKey = profile.plan ?? "starter"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BackButton href="/admin/users" label="Back to users" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-rose-500/20 to-red-500/20 text-lg font-bold text-rose-300 ring-1 ring-inset ring-rose-500/20">
|
||||||
|
{initials(profile.full_name || profile.email)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="text-xl font-bold text-white">{profile.full_name || "Unnamed user"}</h1>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize",
|
||||||
|
PLAN_BADGE[planKey] ?? PLAN_BADGE.starter
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{planKey}
|
||||||
|
</span>
|
||||||
|
{account?.role === "admin" && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-rose-500/30 bg-rose-500/10 px-2 py-0.5 text-xs font-medium text-rose-300">
|
||||||
|
<ShieldAlert className="h-3 w-3" /> Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{account?.banned && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||||
|
<Ban className="h-3 w-3" /> Banned
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSelf && (
|
||||||
|
<span className="inline-flex items-center rounded-full border border-white/15 bg-white/[0.04] px-2 py-0.5 text-xs font-medium text-white/50">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-white/50">{profile.email}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-white/30">
|
||||||
|
{profile.phone && <span>{profile.phone}</span>}
|
||||||
|
{profile.company_name && <span>{profile.company_name}</span>}
|
||||||
|
<span>Joined {formatDate(profile.created_at)}</span>
|
||||||
|
<span className="font-mono text-[11px]">{profile.id}</span>
|
||||||
|
</div>
|
||||||
|
{account?.banned && account.banReason && (
|
||||||
|
<p className="mt-2 text-xs text-red-400/80">Ban reason: {account.banReason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Counts grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{COUNT_META.map(({ key, label, icon: Icon }) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-white/30">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold tabular-nums text-white">
|
||||||
|
{(counts[key as keyof typeof counts] ?? 0).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Left: billing + activity */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Billing */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Billing</h2>
|
||||||
|
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Plan</dt>
|
||||||
|
<dd className="mt-1 text-sm capitalize text-white/80">{planKey}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Status</dt>
|
||||||
|
<dd className="mt-1 text-sm capitalize text-white/80">
|
||||||
|
{profile.subscription_status || "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Plan expires</dt>
|
||||||
|
<dd className="mt-1 text-sm text-white/80">
|
||||||
|
{profile.plan_expires_at ? formatDate(profile.plan_expires_at) : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Email verified</dt>
|
||||||
|
<dd className="mt-1 text-sm text-white/80">
|
||||||
|
{account?.emailVerified ? "Yes" : "No"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Stripe customer ID</dt>
|
||||||
|
<dd className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="truncate font-mono text-xs text-white/70">
|
||||||
|
{profile.stripe_customer_id || "—"}
|
||||||
|
</span>
|
||||||
|
{profile.stripe_customer_id && <CopyButton text={profile.stripe_customer_id} />}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wider text-white/30">Stripe subscription ID</dt>
|
||||||
|
<dd className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="truncate font-mono text-xs text-white/70">
|
||||||
|
{profile.stripe_subscription_id || "—"}
|
||||||
|
</span>
|
||||||
|
{profile.stripe_subscription_id && <CopyButton text={profile.stripe_subscription_id} />}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent activity */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Recent activity</h2>
|
||||||
|
{recentActivity.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-white/30">No recent activity.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
{recentActivity.map((a) => (
|
||||||
|
<li key={a.id} className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-white/[0.06] bg-white/[0.02] text-white/40">
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-white/80">{a.title}</p>
|
||||||
|
<p className="text-xs text-white/30">
|
||||||
|
{a.type} · {formatDate(a.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: actions */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<UserActions
|
||||||
|
userId={profile.id}
|
||||||
|
email={profile.email}
|
||||||
|
currentPlan={planKey}
|
||||||
|
banned={!!account?.banned}
|
||||||
|
isSelf={isSelf}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { getUsersPage } from "@/lib/db/admin-queries"
|
||||||
|
import { UsersTable } from "@/components/admin/users-table"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AdminUsersPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ q?: string; page?: string; plan?: string; sort?: string; dir?: string }>
|
||||||
|
}) {
|
||||||
|
const { q, page, plan, sort, dir } = await searchParams
|
||||||
|
|
||||||
|
const result = await getUsersPage({
|
||||||
|
q,
|
||||||
|
page: Number(page) || 1,
|
||||||
|
plan,
|
||||||
|
sort,
|
||||||
|
dir: dir === "asc" ? "asc" : dir === "desc" ? "desc" : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white">Users</h1>
|
||||||
|
<p className="mt-1 text-sm text-white/40">
|
||||||
|
Manage accounts, plans and access across the platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsersTable data={result} query={{ q, plan, sort, dir }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Logo } from "@/components/shared/logo"
|
||||||
|
import { resetPassword } from "@/app/actions/auth"
|
||||||
|
|
||||||
|
export default async function ForgotPasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string; success?: string }>
|
||||||
|
}) {
|
||||||
|
const params = await searchParams
|
||||||
|
const error = params.error
|
||||||
|
const success = params.success
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-8 flex flex-col items-center">
|
||||||
|
<Logo size="lg" />
|
||||||
|
<h1 className="mt-6 text-2xl font-bold text-white">Reset your password</h1>
|
||||||
|
<p className="mt-2 text-sm text-white/60">
|
||||||
|
Enter your email and we'll send a reset link
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[#111118] p-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{decodeURIComponent(error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success === "email-sent" && (
|
||||||
|
<div className="mb-4 rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
|
||||||
|
Check your email — reset link sent.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={resetPassword} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-white/70">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Send reset link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-white/40">
|
||||||
|
Remember your password?{" "}
|
||||||
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sign in to Property Management Network",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#09090b] px-4">
|
||||||
|
<div className="w-full max-w-md">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Logo } from "@/components/shared/logo"
|
||||||
|
import { signIn, signInWithGoogle } from "@/app/actions/auth"
|
||||||
|
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string; success?: string }>
|
||||||
|
}) {
|
||||||
|
const params = await searchParams
|
||||||
|
const error = params.error
|
||||||
|
const success = params.success
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-8 flex flex-col items-center">
|
||||||
|
<Logo size="lg" />
|
||||||
|
<h1 className="mt-6 text-2xl font-bold text-white">Welcome back</h1>
|
||||||
|
<p className="mt-2 text-sm text-white/60">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[#111118] p-8">
|
||||||
|
{/* Google OAuth */}
|
||||||
|
<form action={signInWithGoogle}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<GoogleIcon />
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-white/10" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs">
|
||||||
|
<span className="bg-[#111118] px-3 text-white/40">or continue with email</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error / Success messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{decodeURIComponent(error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success === "password-updated" && (
|
||||||
|
<div className="mb-4 rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
|
||||||
|
Password updated. Sign in below.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email + Password form */}
|
||||||
|
<form action={signIn} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-white/70">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium text-white/70">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<Link href="/forgot-password" className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="mt-2 w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-white/40">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300">
|
||||||
|
Sign up free
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoogleIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Logo } from "@/components/shared/logo"
|
||||||
|
import { signUp, signInWithGoogle } from "@/app/actions/auth"
|
||||||
|
|
||||||
|
export default async function SignupPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string; success?: string }>
|
||||||
|
}) {
|
||||||
|
const params = await searchParams
|
||||||
|
const error = params.error
|
||||||
|
const success = params.success
|
||||||
|
|
||||||
|
if (success === "check-email") {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-8 flex flex-col items-center">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[#111118] p-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/10 text-2xl">
|
||||||
|
✉️
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Check your email</h2>
|
||||||
|
<p className="mt-2 text-sm text-white/60">
|
||||||
|
We sent a confirmation link to your email. Click it to activate your account.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="mt-6 inline-block text-sm text-indigo-400 hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-8 flex flex-col items-center">
|
||||||
|
<Logo size="lg" />
|
||||||
|
<h1 className="mt-6 text-2xl font-bold text-white">Create your account</h1>
|
||||||
|
<p className="mt-2 text-sm text-white/60">Start managing your properties for free</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[#111118] p-8">
|
||||||
|
{/* Google OAuth */}
|
||||||
|
<form action={signInWithGoogle}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<GoogleIcon />
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-white/10" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs">
|
||||||
|
<span className="bg-[#111118] px-3 text-white/40">or sign up with email</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{decodeURIComponent(error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={signUp} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="full_name" className="mb-1.5 block text-sm font-medium text-white/70">
|
||||||
|
Full name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="full_name"
|
||||||
|
name="full_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autoComplete="name"
|
||||||
|
placeholder="John Smith"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-white/70">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-white/70">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="mt-2 w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-xs text-white/30">
|
||||||
|
By signing up you agree to our{" "}
|
||||||
|
<Link href="/terms" className="underline hover:text-white/50">Terms</Link>
|
||||||
|
{" "}and{" "}
|
||||||
|
<Link href="/privacy" className="underline hover:text-white/50">Privacy Policy</Link>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-white/40">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoogleIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Logo } from "@/components/shared/logo"
|
||||||
|
import { updatePassword } from "@/app/actions/auth"
|
||||||
|
|
||||||
|
export default async function UpdatePasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string; token?: string }>
|
||||||
|
}) {
|
||||||
|
const params = await searchParams
|
||||||
|
const error = params.error
|
||||||
|
const token = params.token ?? ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-8 flex flex-col items-center">
|
||||||
|
<Logo size="lg" />
|
||||||
|
<h1 className="mt-6 text-2xl font-bold text-white">Set new password</h1>
|
||||||
|
<p className="mt-2 text-sm text-white/60">Choose a strong password</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[#111118] p-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{decodeURIComponent(error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={updatePassword} className="space-y-4">
|
||||||
|
<input type="hidden" name="token" value={token} />
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-white/70">
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Update password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-white/40">
|
||||||
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { formatDistanceToNow } from "date-fns"
|
||||||
|
import {
|
||||||
|
DollarSign, UserPlus, Wrench, FileText, AlertTriangle,
|
||||||
|
Building2, ClipboardCheck, Users, Receipt, Zap, Activity,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const typeConfig: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||||
|
rent_paid: { icon: DollarSign, color: "text-emerald-400", bg: "bg-emerald-500/10" },
|
||||||
|
rent_overdue: { icon: AlertTriangle, color: "text-red-400", bg: "bg-red-500/10" },
|
||||||
|
tenant_added: { icon: UserPlus, color: "text-blue-400", bg: "bg-blue-500/10" },
|
||||||
|
tenant_removed: { icon: Users, color: "text-orange-400", bg: "bg-orange-500/10" },
|
||||||
|
maintenance_opened: { icon: Wrench, color: "text-yellow-400", bg: "bg-yellow-500/10" },
|
||||||
|
maintenance_resolved: { icon: ClipboardCheck, color: "text-emerald-400", bg: "bg-emerald-500/10" },
|
||||||
|
lease_created: { icon: FileText, color: "text-indigo-400", bg: "bg-indigo-500/10" },
|
||||||
|
lease_expiring: { icon: AlertTriangle, color: "text-amber-400", bg: "bg-amber-500/10" },
|
||||||
|
expense_added: { icon: Receipt, color: "text-purple-400", bg: "bg-purple-500/10" },
|
||||||
|
property_added: { icon: Building2, color: "text-cyan-400", bg: "bg-cyan-500/10" },
|
||||||
|
inspection_completed: { icon: ClipboardCheck, color: "text-teal-400", bg: "bg-teal-500/10" },
|
||||||
|
vendor_added: { icon: Users, color: "text-pink-400", bg: "bg-pink-500/10" },
|
||||||
|
ai_action: { icon: Zap, color: "text-violet-400", bg: "bg-violet-500/10" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_OPTIONS = [
|
||||||
|
{ label: "All", value: "" },
|
||||||
|
{ label: "Rent", value: "rent" },
|
||||||
|
{ label: "Tenants", value: "tenant" },
|
||||||
|
{ label: "Maintenance", value: "maintenance" },
|
||||||
|
{ label: "Leases", value: "lease" },
|
||||||
|
{ label: "AI", value: "ai" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ActivityFeed({ activities }: { activities: any[] }) {
|
||||||
|
const [filter, setFilter] = useState("")
|
||||||
|
|
||||||
|
const filtered = filter
|
||||||
|
? activities.filter((a) => a.type.startsWith(filter))
|
||||||
|
: activities
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Activity Feed</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">{filtered.length} events</p>
|
||||||
|
</div>
|
||||||
|
<Activity className="h-5 w-5 text-white/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{FILTER_OPTIONS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => setFilter(f.value)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
||||||
|
filter === f.value
|
||||||
|
? "bg-indigo-600 text-white"
|
||||||
|
: "border border-white/10 text-white/50 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feed */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-16 text-center">
|
||||||
|
<Activity className="h-8 w-8 text-white/10 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-white/30">No activity yet</p>
|
||||||
|
<p className="text-xs text-white/20 mt-1">Actions like adding tenants, recording payments, and maintenance requests will appear here</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] divide-y divide-white/[0.04] overflow-hidden">
|
||||||
|
{filtered.map((activity) => {
|
||||||
|
const cfg = typeConfig[activity.type] ?? { icon: Activity, color: "text-white/40", bg: "bg-white/5" }
|
||||||
|
const Icon = cfg.icon
|
||||||
|
return (
|
||||||
|
<div key={activity.id} className="flex items-start gap-4 px-5 py-4 hover:bg-white/[0.02] transition">
|
||||||
|
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${cfg.bg}`}>
|
||||||
|
<Icon className={`h-3.5 w-3.5 ${cfg.color}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white">{activity.title}</p>
|
||||||
|
{activity.description && (
|
||||||
|
<p className="text-xs text-white/40 mt-0.5">{activity.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="shrink-0 text-xs text-white/25 mt-0.5">
|
||||||
|
{formatDistanceToNow(new Date(activity.created_at), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { activity_log } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { ActivityFeed } from "./activity-feed"
|
||||||
|
|
||||||
|
export const metadata = { title: "Activity" }
|
||||||
|
|
||||||
|
export default async function ActivityPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const activities = await db
|
||||||
|
.select()
|
||||||
|
.from(activity_log)
|
||||||
|
.where(eq(activity_log.user_id, user.id))
|
||||||
|
.orderBy(desc(activity_log.created_at))
|
||||||
|
.limit(100)
|
||||||
|
|
||||||
|
return <ActivityFeed activities={activities ?? []} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { formatDistanceToNow } from "date-fns"
|
||||||
|
import {
|
||||||
|
Zap, BarChart3, Sparkles, Activity, Bot,
|
||||||
|
TrendingUp, ShieldAlert, Wrench, ArrowRight,
|
||||||
|
CheckCircle, AlertTriangle, Brain,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
|
const riskBadge: Record<string, string> = {
|
||||||
|
critical: "text-red-400 bg-red-500/10 ring-red-500/20",
|
||||||
|
high: "text-orange-400 bg-orange-500/10 ring-orange-500/20",
|
||||||
|
medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20",
|
||||||
|
low: "text-emerald-400 bg-emerald-500/10 ring-emerald-500/20",
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityBadge: Record<string, string> = {
|
||||||
|
high: "text-red-400 bg-red-500/10 ring-red-500/20",
|
||||||
|
medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20",
|
||||||
|
low: "text-white/40 bg-white/5 ring-white/10",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recentRecs: any[]
|
||||||
|
recentPredictions: any[]
|
||||||
|
activityLog: any[]
|
||||||
|
stats: {
|
||||||
|
totalImpact: number
|
||||||
|
approvedRecs: number
|
||||||
|
pendingRecs: number
|
||||||
|
occupancyRate: number
|
||||||
|
totalRevenue: number
|
||||||
|
overdueAmount: number
|
||||||
|
criticalMaintenance: number
|
||||||
|
riskAlerts: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiDashboardClient({ recentRecs, recentPredictions, activityLog, stats }: Props) {
|
||||||
|
const hasAlerts = stats.riskAlerts > 0 || stats.criticalMaintenance > 0 || stats.overdueAmount > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500/10">
|
||||||
|
<Brain className="h-5 w-5 text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">AI Dashboard</h2>
|
||||||
|
<p className="text-sm text-white/40">Your portfolio intelligence at a glance</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert banner */}
|
||||||
|
{hasAlerts && (
|
||||||
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
{stats.riskAlerts > 0 && <p className="text-sm text-red-300">{stats.riskAlerts} active risk alert{stats.riskAlerts > 1 ? "s" : ""} in your portfolio</p>}
|
||||||
|
{stats.criticalMaintenance > 0 && <p className="text-sm text-orange-300">{stats.criticalMaintenance} high-priority maintenance request{stats.criticalMaintenance > 1 ? "s" : ""} open</p>}
|
||||||
|
{stats.overdueAmount > 0 && <p className="text-sm text-amber-300">{formatCurrency(stats.overdueAmount)} in overdue rent</p>}
|
||||||
|
</div>
|
||||||
|
<Link href="/predictions" className="shrink-0 text-xs text-red-400 hover:text-red-300 transition flex items-center gap-1">
|
||||||
|
View <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Impact stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "AI Impact", value: formatCurrency(stats.totalImpact), sub: "est. monthly value", color: "text-violet-400", icon: Sparkles },
|
||||||
|
{ label: "Approved", value: stats.approvedRecs, sub: "recommendations", color: "text-emerald-400", icon: CheckCircle },
|
||||||
|
{ label: "Pending Review", value: stats.pendingRecs, sub: "recommendations", color: "text-amber-400", icon: Zap },
|
||||||
|
{ label: "Risk Alerts", value: stats.riskAlerts, sub: "active", color: "text-red-400", icon: ShieldAlert },
|
||||||
|
].map((s) => {
|
||||||
|
const Icon = s.icon
|
||||||
|
return (
|
||||||
|
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Icon className={`h-4 w-4 ${s.color}`} />
|
||||||
|
<span className="text-xs text-white/40">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||||
|
<p className="text-xs text-white/25 mt-1">{s.sub}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick links */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "AI Assistant", href: "/ai", icon: Bot, color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20" },
|
||||||
|
{ label: "AI Insights", href: "/recommendations", icon: Zap, color: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
|
||||||
|
{ label: "Predictions", href: "/predictions", icon: BarChart3, color: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
|
||||||
|
{ label: "Impact Tracking", href: "/impact", icon: TrendingUp,color: "text-emerald-400",bg: "bg-emerald-500/10",border: "border-emerald-500/20"},
|
||||||
|
].map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`rounded-xl border ${item.border} ${item.bg} p-4 flex items-center justify-between group hover:brightness-125 transition`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`h-4 w-4 ${item.color}`} />
|
||||||
|
<span className="text-sm font-medium text-white">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-white/20 group-hover:text-white/50 transition" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
{/* Recent recommendations */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-white/[0.06] flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-white">Recent Recommendations</p>
|
||||||
|
<Link href="/recommendations" className="text-xs text-indigo-400 hover:text-indigo-300 transition flex items-center gap-1">
|
||||||
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentRecs.length === 0 ? (
|
||||||
|
<div className="py-10 text-center">
|
||||||
|
<p className="text-xs text-white/25">No recommendations yet</p>
|
||||||
|
<Link href="/recommendations" className="text-xs text-indigo-400 hover:underline mt-1 block">Generate now</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{recentRecs.map((r) => (
|
||||||
|
<div key={r.id} className="px-5 py-3.5 flex items-start gap-3">
|
||||||
|
<Zap className="h-4 w-4 text-violet-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{r.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset ${priorityBadge[r.priority] ?? priorityBadge.low}`}>
|
||||||
|
{r.priority}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] font-medium capitalize ${r.status === "approved" ? "text-emerald-400" : r.status === "dismissed" ? "text-white/25" : "text-amber-400"}`}>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent predictions */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-white/[0.06] flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-white">Recent Predictions</p>
|
||||||
|
<Link href="/predictions" className="text-xs text-indigo-400 hover:text-indigo-300 transition flex items-center gap-1">
|
||||||
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentPredictions.length === 0 ? (
|
||||||
|
<div className="py-10 text-center">
|
||||||
|
<p className="text-xs text-white/25">No predictions yet</p>
|
||||||
|
<Link href="/predictions" className="text-xs text-indigo-400 hover:underline mt-1 block">Run analysis</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{recentPredictions.map((p) => (
|
||||||
|
<div key={p.id} className="px-5 py-3.5 flex items-start gap-3">
|
||||||
|
<BarChart3 className="h-4 w-4 text-blue-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{p.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset ${riskBadge[p.risk_level] ?? riskBadge.low}`}>
|
||||||
|
{p.risk_level}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/30">{p.timeframe}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Activity log */}
|
||||||
|
{activityLog.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-white/[0.06] flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4 text-violet-400" />
|
||||||
|
Recent AI Actions
|
||||||
|
</p>
|
||||||
|
<Link href="/activity" className="text-xs text-indigo-400 hover:text-indigo-300 transition flex items-center gap-1">
|
||||||
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{activityLog.map((a) => (
|
||||||
|
<div key={a.id} className="px-5 py-3 flex items-center gap-3">
|
||||||
|
<Zap className="h-3.5 w-3.5 text-violet-400 shrink-0" />
|
||||||
|
<p className="flex-1 text-sm text-white/60">{a.title}</p>
|
||||||
|
<span className="text-xs text-white/20 shrink-0">
|
||||||
|
{formatDistanceToNow(new Date(a.created_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, desc, eq, gte, inArray } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import {
|
||||||
|
ai_recommendations,
|
||||||
|
ai_predictions,
|
||||||
|
activity_log,
|
||||||
|
rent_payments,
|
||||||
|
units as unitsTable,
|
||||||
|
maintenance_requests,
|
||||||
|
} from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { AiDashboardClient } from "./ai-dashboard-client"
|
||||||
|
|
||||||
|
export const metadata = { title: "AI Dashboard" }
|
||||||
|
|
||||||
|
export default async function AiDashboardPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const threeMonthsAgo = new Date(now)
|
||||||
|
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3)
|
||||||
|
|
||||||
|
const [recs, predictions, activityLog, payments, units, maintenance] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(ai_recommendations)
|
||||||
|
.where(eq(ai_recommendations.user_id, user.id))
|
||||||
|
.orderBy(desc(ai_recommendations.created_at))
|
||||||
|
.limit(3),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(ai_predictions)
|
||||||
|
.where(eq(ai_predictions.user_id, user.id))
|
||||||
|
.orderBy(desc(ai_predictions.created_at))
|
||||||
|
.limit(3),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(activity_log)
|
||||||
|
.where(and(eq(activity_log.user_id, user.id), eq(activity_log.type, "ai_action")))
|
||||||
|
.orderBy(desc(activity_log.created_at))
|
||||||
|
.limit(5),
|
||||||
|
db
|
||||||
|
.select({ amount: rent_payments.amount, status: rent_payments.status })
|
||||||
|
.from(rent_payments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rent_payments.user_id, user.id),
|
||||||
|
gte(rent_payments.due_date, threeMonthsAgo.toISOString().slice(0, 10))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({ status: unitsTable.status })
|
||||||
|
.from(unitsTable)
|
||||||
|
.where(eq(unitsTable.user_id, user.id)),
|
||||||
|
db
|
||||||
|
.select({ status: maintenance_requests.status, priority: maintenance_requests.priority })
|
||||||
|
.from(maintenance_requests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(maintenance_requests.user_id, user.id),
|
||||||
|
inArray(maintenance_requests.status, ["open", "in_progress"])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const allRecsData = await db
|
||||||
|
.select({ status: ai_recommendations.status, action_data: ai_recommendations.action_data })
|
||||||
|
.from(ai_recommendations)
|
||||||
|
.where(eq(ai_recommendations.user_id, user.id))
|
||||||
|
const approvedRecs = allRecsData.filter((r) => r.status === "approved")
|
||||||
|
|
||||||
|
let totalImpact = 0
|
||||||
|
for (const r of approvedRecs) {
|
||||||
|
totalImpact += Number(r.action_data?.estimated_value ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const occupiedUnits = units?.filter((u: any) => u.status === "occupied").length ?? 0
|
||||||
|
const totalUnits = units?.length ?? 0
|
||||||
|
const occupancyRate = totalUnits > 0 ? Math.round((occupiedUnits / totalUnits) * 100) : 0
|
||||||
|
const totalRevenue = payments?.filter((p: any) => p.status === "paid").reduce((s: number, p: any) => s + Number(p.amount), 0) ?? 0
|
||||||
|
const overdueAmount = payments?.filter((p: any) => p.status === "overdue").reduce((s: number, p: any) => s + Number(p.amount), 0) ?? 0
|
||||||
|
const criticalMaintenance = maintenance?.filter((m: any) => m.priority === "emergency" || m.priority === "high").length ?? 0
|
||||||
|
const riskAlerts = predictions?.filter((p: any) => ["critical", "high"].includes(p.risk_level)).length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AiDashboardClient
|
||||||
|
recentRecs={recs ?? []}
|
||||||
|
recentPredictions={predictions ?? []}
|
||||||
|
activityLog={activityLog ?? []}
|
||||||
|
stats={{
|
||||||
|
totalImpact,
|
||||||
|
approvedRecs: approvedRecs.length,
|
||||||
|
pendingRecs: allRecsData.filter((r) => r.status === "pending").length,
|
||||||
|
occupancyRate,
|
||||||
|
totalRevenue,
|
||||||
|
overdueAmount,
|
||||||
|
criticalMaintenance,
|
||||||
|
riskAlerts,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react"
|
||||||
|
import { Send, Bot, Sparkles, Lock, Loader2, RotateCcw, Copy, Check, Zap } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import type { Plan } from "@/types"
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTED = [
|
||||||
|
"Which tenants have overdue rent this month?",
|
||||||
|
"Summarise my open maintenance requests",
|
||||||
|
"How is my occupancy rate?",
|
||||||
|
"Which leases are expiring in 60 days?",
|
||||||
|
"What were my total expenses this quarter?",
|
||||||
|
"Which property earns the most rent?",
|
||||||
|
]
|
||||||
|
|
||||||
|
function CopyButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {})
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[10px] text-white/25 hover:text-white/60 hover:bg-white/[0.04] transition"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
{copied ? <><Check className="h-3 w-3" /> Copied</> : <><Copy className="h-3 w-3" /> Copy</>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ msg }: { msg: Message }) {
|
||||||
|
if (msg.role === "user") {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-indigo-600 px-4 py-3 shadow-lg shadow-indigo-500/10">
|
||||||
|
<p className="text-sm text-white leading-relaxed">{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-md shadow-indigo-500/20 mt-0.5">
|
||||||
|
<Bot className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`rounded-2xl rounded-tl-sm px-4 py-3 ${
|
||||||
|
msg.error
|
||||||
|
? "bg-red-500/8 border border-red-500/20 text-red-300"
|
||||||
|
: "bg-[#1d1d2a] border border-white/[0.06] text-white/85"
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
{!msg.error && (
|
||||||
|
<div className="mt-1 pl-1">
|
||||||
|
<CopyButton text={msg.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiChatProps {
|
||||||
|
plan: Plan
|
||||||
|
limit: number
|
||||||
|
used: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiChat({ plan, limit, used }: AiChatProps) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [input, setInput] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [currentUsed, setCurrentUsed] = useState(used)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const isLocked = limit === 0
|
||||||
|
const isExhausted = !isLocked && currentUsed >= limit
|
||||||
|
const usagePct = limit > 0 ? Math.min((currentUsed / limit) * 100, 100) : 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}, [messages, loading])
|
||||||
|
|
||||||
|
async function send(question: string) {
|
||||||
|
if (!question.trim() || loading || isLocked || isExhausted) return
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, { role: "user", content: question }])
|
||||||
|
setInput("")
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Reset textarea height
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.style.height = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/ask", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ question }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: data.error ?? "Something went wrong.", error: true }])
|
||||||
|
} else {
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: data.answer }])
|
||||||
|
if (data.usage) setCurrentUsed(data.usage.used)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: "Network error. Please try again.", error: true }])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-64px-2rem)] max-w-3xl mx-auto">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/20">
|
||||||
|
<Sparkles className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-white">AI Assistant</h2>
|
||||||
|
<p className="text-xs text-white/35">Powered by your live portfolio data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!isLocked && (
|
||||||
|
<div className="hidden sm:flex flex-col items-end gap-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Zap className="h-3 w-3 text-white/30" />
|
||||||
|
<span className={`text-xs font-medium tabular-nums ${isExhausted ? "text-red-400" : "text-white/50"}`}>
|
||||||
|
{currentUsed} / {limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 h-1 rounded-full bg-white/[0.06]">
|
||||||
|
<div
|
||||||
|
className={`h-1 rounded-full transition-all ${isExhausted ? "bg-red-500" : usagePct > 75 ? "bg-amber-500" : "bg-indigo-500"}`}
|
||||||
|
style={{ width: `${usagePct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMessages([])}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl border border-white/[0.06] px-3 py-1.5 text-xs text-white/35 hover:text-white hover:border-white/20 transition"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" /> Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Locked state */}
|
||||||
|
{isLocked ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="relative text-center max-w-sm px-4">
|
||||||
|
<div className="absolute inset-0 rounded-3xl bg-indigo-500/5 blur-3xl -z-10" />
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/15 to-violet-500/10 border border-indigo-500/20 mx-auto mb-5 shadow-xl shadow-indigo-500/5">
|
||||||
|
<Lock className="h-9 w-9 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">AI requires Pro plan</h3>
|
||||||
|
<p className="text-sm text-white/45 mb-8 leading-relaxed">
|
||||||
|
Upgrade to unlock AI-powered insights about your properties, tenants, rent collection, and more.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link
|
||||||
|
href="/settings/billing"
|
||||||
|
className="flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-xl hover:shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Upgrade to Pro — 50 AI calls/mo
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings/demo"
|
||||||
|
className="block text-xs text-white/30 hover:text-white/60 transition"
|
||||||
|
>
|
||||||
|
Testing? Switch plan in Demo Data →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Chat area */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-5 pr-1 pb-4">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-7 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="relative mx-auto mb-5 h-20 w-20">
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-indigo-500/10 blur-xl" />
|
||||||
|
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/20 to-violet-500/10 border border-indigo-500/20">
|
||||||
|
<Bot className="h-9 w-9 text-indigo-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">How can I help?</h3>
|
||||||
|
<p className="text-sm text-white/40 max-w-xs leading-relaxed">
|
||||||
|
I have full access to your live portfolio — properties, tenants, payments, maintenance, and leases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/25 mb-3">Try asking</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{SUGGESTED.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q}
|
||||||
|
onClick={() => send(q)}
|
||||||
|
className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-left text-xs text-white/55 hover:border-indigo-500/30 hover:bg-indigo-500/5 hover:text-white/90 transition-all duration-150"
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<MessageBubble key={i} msg={msg} />
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 mt-0.5">
|
||||||
|
<Bot className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl rounded-tl-sm bg-[#1d1d2a] border border-white/[0.06] px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 text-indigo-400 animate-spin" />
|
||||||
|
<span className="text-xs text-white/35">Analysing your portfolio…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="shrink-0 mt-2">
|
||||||
|
{isExhausted && (
|
||||||
|
<div className="mb-2 rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-2.5 text-center text-xs text-red-400">
|
||||||
|
Monthly limit reached. <Link href="/settings/billing" className="font-semibold underline underline-offset-2">Upgrade for more calls →</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`relative flex items-end gap-2 rounded-2xl border bg-[#16161f] p-3 transition-colors ${
|
||||||
|
isExhausted ? "border-red-500/20 opacity-60" : "border-white/[0.08] focus-within:border-indigo-500/40"
|
||||||
|
}`}>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
placeholder={isExhausted ? "Monthly limit reached" : "Ask anything about your portfolio…"}
|
||||||
|
rows={1}
|
||||||
|
disabled={loading || isExhausted}
|
||||||
|
className="flex-1 resize-none bg-transparent text-sm text-white placeholder:text-white/25 focus:outline-none min-h-[24px] max-h-32 leading-6 disabled:cursor-not-allowed"
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
el.style.height = "auto"
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 128)}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => send(input)}
|
||||||
|
disabled={!input.trim() || loading || isExhausted}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white transition-all hover:bg-indigo-500 hover:shadow-md hover:shadow-indigo-500/30 disabled:opacity-35 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-center text-[10px] text-white/20">
|
||||||
|
Enter to send · Shift+Enter for new line{!isLocked && ` · ${limit - currentUsed} calls left this month`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Skeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
<Skeleton className="h-3.5 w-56" />
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 space-y-4">
|
||||||
|
<Skeleton className="h-48 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-12 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { AiChat } from "./ai-chat"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, eq, gte, sql } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { profiles, usage_events } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { PLAN_LIMITS } from "@/lib/stripe/plans"
|
||||||
|
import type { Plan } from "@/types"
|
||||||
|
|
||||||
|
export const metadata = { title: "AI Assistant — Property Management Network" }
|
||||||
|
|
||||||
|
export default async function AiPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const profile = await db.query.profiles.findFirst({
|
||||||
|
where: eq(profiles.id, user.id),
|
||||||
|
columns: { plan: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const plan = (profile?.plan ?? "starter") as Plan
|
||||||
|
const limit = PLAN_LIMITS[plan].maxAiCalls
|
||||||
|
|
||||||
|
// Count usage this month
|
||||||
|
const monthStart = new Date()
|
||||||
|
monthStart.setDate(1)
|
||||||
|
monthStart.setHours(0, 0, 0, 0)
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(usage_events)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(usage_events.user_id, user.id),
|
||||||
|
gte(usage_events.created_at, monthStart.toISOString())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const used = count ?? 0
|
||||||
|
|
||||||
|
return <AiChat plan={plan} limit={limit} used={used} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ChevronLeft, ChevronRight, CreditCard, FileText } from "lucide-react"
|
||||||
|
import { cn, formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||||
|
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"]
|
||||||
|
|
||||||
|
interface CalendarClientProps {
|
||||||
|
payments: any[]
|
||||||
|
leases: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarClient({ payments, leases }: CalendarClientProps) {
|
||||||
|
const today = new Date()
|
||||||
|
const [year, setYear] = useState(today.getFullYear())
|
||||||
|
const [month, setMonth] = useState(today.getMonth())
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (month === 0) { setMonth(11); setYear(y => y - 1) }
|
||||||
|
else setMonth(m => m - 1)
|
||||||
|
}
|
||||||
|
function nextMonth() {
|
||||||
|
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||||
|
else setMonth(m => m + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build calendar grid
|
||||||
|
const firstDay = new Date(year, month, 1).getDay()
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
const cells: (number | null)[] = [
|
||||||
|
...Array(firstDay).fill(null),
|
||||||
|
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||||
|
]
|
||||||
|
// Pad to complete last row
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null)
|
||||||
|
|
||||||
|
function dateKey(day: number) {
|
||||||
|
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group events by date
|
||||||
|
const eventsByDate: Record<string, { type: "payment" | "lease"; item: any }[]> = {}
|
||||||
|
|
||||||
|
for (const p of payments) {
|
||||||
|
const key = p.due_date?.slice(0, 10)
|
||||||
|
if (!key) continue
|
||||||
|
if (!eventsByDate[key]) eventsByDate[key] = []
|
||||||
|
eventsByDate[key].push({ type: "payment", item: p })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of leases) {
|
||||||
|
const key = l.lease_end?.slice(0, 10)
|
||||||
|
if (!key) continue
|
||||||
|
if (!eventsByDate[key]) eventsByDate[key] = []
|
||||||
|
eventsByDate[key].push({ type: "lease", item: l })
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEvents = selected ? (eventsByDate[selected] ?? []) : []
|
||||||
|
|
||||||
|
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="lg:col-span-2 rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
{/* Month nav */}
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<button onClick={prevMonth} className="rounded-lg p-1.5 text-white/40 hover:text-white transition hover:bg-white/[0.05]">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<h3 className="text-sm font-semibold text-white">{MONTHS[month]} {year}</h3>
|
||||||
|
<button onClick={nextMonth} className="rounded-lg p-1.5 text-white/40 hover:text-white transition hover:bg-white/[0.05]">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers */}
|
||||||
|
<div className="grid grid-cols-7 border-b border-white/[0.04]">
|
||||||
|
{DAYS.map((d) => (
|
||||||
|
<div key={d} className="py-2 text-center text-[10px] font-semibold uppercase tracking-wider text-white/25">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{cells.map((day, i) => {
|
||||||
|
const key = day ? dateKey(day) : null
|
||||||
|
const events = key ? (eventsByDate[key] ?? []) : []
|
||||||
|
const isToday = key === todayKey
|
||||||
|
const isSelected = key === selected
|
||||||
|
const paymentEvents = events.filter((e) => e.type === "payment")
|
||||||
|
const leaseEvents = events.filter((e) => e.type === "lease")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => day && key && setSelected(isSelected ? null : key)}
|
||||||
|
className={cn(
|
||||||
|
"relative min-h-[72px] border-b border-r border-white/[0.03] p-1.5 transition-colors",
|
||||||
|
day ? "cursor-pointer hover:bg-white/[0.03]" : "opacity-0 pointer-events-none",
|
||||||
|
isSelected && "bg-indigo-600/10 border-indigo-500/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day && (
|
||||||
|
<>
|
||||||
|
<span className={cn(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium",
|
||||||
|
isToday ? "bg-indigo-600 text-white font-bold" : "text-white/50"
|
||||||
|
)}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{paymentEvents.slice(0, 2).map((e, j) => (
|
||||||
|
<div key={j} className={cn(
|
||||||
|
"truncate rounded px-1 py-0.5 text-[9px] font-medium",
|
||||||
|
e.item.status === "paid"
|
||||||
|
? "bg-emerald-500/15 text-emerald-400"
|
||||||
|
: e.item.status === "overdue"
|
||||||
|
? "bg-red-500/15 text-red-400"
|
||||||
|
: "bg-indigo-500/15 text-indigo-400"
|
||||||
|
)}>
|
||||||
|
{e.item.tenant?.first_name} {formatCurrency(e.item.amount)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{leaseEvents.slice(0, 1).map((e, j) => (
|
||||||
|
<div key={j} className="truncate rounded bg-amber-500/15 px-1 py-0.5 text-[9px] font-medium text-amber-400">
|
||||||
|
Lease ends: {e.item.tenant?.first_name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{events.length > 3 && (
|
||||||
|
<div className="text-[9px] text-white/30 px-1">+{events.length - 3} more</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side panel */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white">
|
||||||
|
{selected ? new Date(selected + "T12:00:00").toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }) : "Select a date"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selected ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
|
||||||
|
<p className="text-sm text-white/30">Click any day to see events</p>
|
||||||
|
</div>
|
||||||
|
) : selectedEvents.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
|
||||||
|
<p className="text-sm text-white/30">No events this day</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04] p-3 space-y-1">
|
||||||
|
{selectedEvents.map((e, i) => (
|
||||||
|
<div key={i} className={cn(
|
||||||
|
"flex items-start gap-3 rounded-xl p-3",
|
||||||
|
e.type === "payment" ? "bg-indigo-500/5" : "bg-amber-500/5"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border",
|
||||||
|
e.type === "payment"
|
||||||
|
? "border-indigo-500/20 bg-indigo-500/10 text-indigo-400"
|
||||||
|
: "border-amber-500/20 bg-amber-500/10 text-amber-400"
|
||||||
|
)}>
|
||||||
|
{e.type === "payment" ? <CreditCard className="h-3.5 w-3.5" /> : <FileText className="h-3.5 w-3.5" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{e.type === "payment" ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-semibold text-white">{e.item.tenant?.first_name} {e.item.tenant?.last_name}</p>
|
||||||
|
<p className="text-xs text-white/40">{formatCurrency(e.item.amount)} due</p>
|
||||||
|
<span className={cn(
|
||||||
|
"mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-medium capitalize",
|
||||||
|
e.item.status === "paid" ? "bg-emerald-500/15 text-emerald-400" :
|
||||||
|
e.item.status === "overdue" ? "bg-red-500/15 text-red-400" :
|
||||||
|
"bg-white/10 text-white/40"
|
||||||
|
)}>
|
||||||
|
{e.item.status}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-semibold text-white">Lease Expiry</p>
|
||||||
|
<p className="text-xs text-white/40">{e.item.tenant?.first_name} {e.item.tenant?.last_name}</p>
|
||||||
|
<p className="text-xs text-white/30">{e.item.property?.name}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="border-t border-white/[0.06] px-5 py-3 space-y-1.5">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-white/20 mb-2">Legend</p>
|
||||||
|
{[
|
||||||
|
{ color: "bg-indigo-500/20 text-indigo-400", label: "Rent pending" },
|
||||||
|
{ color: "bg-emerald-500/20 text-emerald-400", label: "Rent paid" },
|
||||||
|
{ color: "bg-red-500/20 text-red-400", label: "Rent overdue" },
|
||||||
|
{ color: "bg-amber-500/20 text-amber-400", label: "Lease expiry" },
|
||||||
|
].map((l) => (
|
||||||
|
<div key={l.label} className="flex items-center gap-2">
|
||||||
|
<div className={cn("h-2 w-2 rounded-full", l.color)} />
|
||||||
|
<span className="text-xs text-white/40">{l.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Skeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-5 w-28" />
|
||||||
|
<Skeleton className="h-3.5 w-52" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div className="lg:col-span-2 rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="border-b border-white/[0.06] px-5 py-4 flex items-center justify-between">
|
||||||
|
<Skeleton className="h-4 w-8 rounded" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="h-4 w-8 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 border-b border-white/[0.04]">
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 m-1 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{Array.from({ length: 35 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[72px] m-0.5 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-20 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-20 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, eq, gte, lte } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { rent_payments, leases } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { CalendarClient } from "./calendar-client"
|
||||||
|
|
||||||
|
export const metadata = { title: "Calendar" }
|
||||||
|
|
||||||
|
export default async function CalendarPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const rangeStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||||
|
const rangeEnd = new Date(now.getFullYear(), now.getMonth() + 3, 0)
|
||||||
|
|
||||||
|
const [payments, leaseList] = await Promise.all([
|
||||||
|
db.query.rent_payments.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(rent_payments.user_id, user.id),
|
||||||
|
gte(rent_payments.due_date, rangeStart.toISOString().slice(0, 10)),
|
||||||
|
lte(rent_payments.due_date, rangeEnd.toISOString().slice(0, 10))
|
||||||
|
),
|
||||||
|
columns: { id: true, due_date: true, amount: true, status: true },
|
||||||
|
with: {
|
||||||
|
tenant: { columns: { first_name: true, last_name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.query.leases.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(leases.user_id, user.id),
|
||||||
|
gte(leases.lease_end, new Date().toISOString().slice(0, 10))
|
||||||
|
),
|
||||||
|
columns: { id: true, lease_end: true },
|
||||||
|
with: {
|
||||||
|
tenant: { columns: { first_name: true, last_name: true } },
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-white">Calendar</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">Rent due dates and lease expirations at a glance</p>
|
||||||
|
</div>
|
||||||
|
<CalendarClient payments={payments ?? []} leases={leaseList ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { DashboardSkeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function DashboardLoading() {
|
||||||
|
return <DashboardSkeleton />
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { profiles } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import {
|
||||||
|
getDashboardStats,
|
||||||
|
getRecentRentPayments,
|
||||||
|
getOpenMaintenanceRequests,
|
||||||
|
getExpiringLeases,
|
||||||
|
getMonthlyRevenue,
|
||||||
|
getExpenseBreakdown,
|
||||||
|
} from "@/lib/db/queries"
|
||||||
|
import { StatsCard } from "@/components/dashboard/stats-card"
|
||||||
|
import { RevenueChart } from "@/components/dashboard/revenue-chart"
|
||||||
|
import { ExpenseBreakdownChart } from "@/components/dashboard/expense-breakdown-chart"
|
||||||
|
import { QuickActions } from "@/components/dashboard/quick-actions"
|
||||||
|
import { RentStatusBadge } from "@/components/dashboard/rent-status-badge"
|
||||||
|
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Building2, CreditCard, Wrench, TrendingUp,
|
||||||
|
AlertTriangle, ArrowRight, CheckCircle2, Clock,
|
||||||
|
Sparkles, ChevronRight,
|
||||||
|
} from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
function GreetingBanner({ name }: { name: string }) {
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
const greeting = hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening"
|
||||||
|
const now = new Date()
|
||||||
|
const dateStr = now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
{greeting}, {name?.split(" ")[0] ?? "there"} 👋
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">{dateStr}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-4 py-2">
|
||||||
|
<div className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-emerald-400">All systems operational</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardingChecklist() {
|
||||||
|
const steps = [
|
||||||
|
{ label: "Add your first property", href: "/properties/new", icon: Building2 },
|
||||||
|
{ label: "Create a tenant profile", href: "/tenants/new", icon: TrendingUp },
|
||||||
|
{ label: "Record a rent payment", href: "/rent", icon: CreditCard },
|
||||||
|
{ label: "Set up a lease", href: "/leases/new", icon: CheckCircle2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-2xl border border-indigo-500/20 bg-gradient-to-br from-indigo-600/10 via-[#16161f] to-violet-600/5 p-5 mb-6">
|
||||||
|
<div className="absolute top-0 right-0 w-40 h-40 rounded-full bg-indigo-600/10 blur-3xl -z-0" />
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-600">
|
||||||
|
<Sparkles className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white">Welcome to Property Management Network!</h3>
|
||||||
|
<p className="text-xs text-white/50">Complete these steps to get started</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<Link
|
||||||
|
key={step.label}
|
||||||
|
href={step.href}
|
||||||
|
className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-sm text-white/60 transition-all hover:border-indigo-500/30 hover:bg-indigo-500/5 hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/10 text-[10px] font-bold text-white/30 group-hover:border-indigo-500/40 group-hover:text-indigo-400 transition-colors">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-xs font-medium">{step.label}</span>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 opacity-0 group-hover:opacity-100 text-indigo-400 transition-opacity" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
// Fetch profile for greeting
|
||||||
|
const profile = await db.query.profiles.findFirst({
|
||||||
|
where: eq(profiles.id, user.id),
|
||||||
|
columns: { full_name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const [stats, recentPayments, openMaintenance, expiringLeases, monthlyRevenue, expenseBreakdown] = await Promise.all([
|
||||||
|
getDashboardStats(user.id),
|
||||||
|
getRecentRentPayments(user.id),
|
||||||
|
getOpenMaintenanceRequests(user.id),
|
||||||
|
getExpiringLeases(user.id),
|
||||||
|
getMonthlyRevenue(user.id),
|
||||||
|
getExpenseBreakdown(user.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
const hasData = stats.totalProperties > 0
|
||||||
|
|
||||||
|
const rentTrendPct = stats.rentCollectedLastMonth > 0
|
||||||
|
? Math.round(((stats.rentCollectedThisMonth - stats.rentCollectedLastMonth) / stats.rentCollectedLastMonth) * 100)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const occupancyColor: "default" | "success" | "warning" | "danger" =
|
||||||
|
stats.occupancyRate >= 80 ? "success" : stats.occupancyRate >= 50 ? "warning" : "danger"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
{/* Greeting */}
|
||||||
|
<GreetingBanner name={(profile as any)?.full_name ?? ""} />
|
||||||
|
|
||||||
|
{/* Onboarding */}
|
||||||
|
{!hasData && <OnboardingChecklist />}
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||||
|
<StatsCard
|
||||||
|
label="Properties"
|
||||||
|
value={stats.totalProperties}
|
||||||
|
sub={`${stats.totalUnits} unit${stats.totalUnits !== 1 ? "s" : ""} total`}
|
||||||
|
icon={Building2}
|
||||||
|
variant="default"
|
||||||
|
progress={stats.totalProperties > 0 ? Math.min(100, (stats.totalProperties / 10) * 100) : 0}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Occupancy"
|
||||||
|
value={`${stats.occupancyRate}%`}
|
||||||
|
sub={`${stats.occupiedUnits} occupied · ${stats.vacantUnits} vacant`}
|
||||||
|
icon={TrendingUp}
|
||||||
|
variant={occupancyColor}
|
||||||
|
progress={stats.occupancyRate}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Rent Collected"
|
||||||
|
value={formatCurrency(stats.rentCollectedThisMonth)}
|
||||||
|
sub={`${formatCurrency(stats.rentPendingThisMonth)} pending`}
|
||||||
|
icon={CreditCard}
|
||||||
|
variant="success"
|
||||||
|
trend={rentTrendPct !== null ? { value: rentTrendPct, label: "vs last month" } : undefined}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Maintenance"
|
||||||
|
value={stats.openMaintenanceRequests}
|
||||||
|
sub={stats.rentOverdue > 0 ? `${formatCurrency(stats.rentOverdue)} overdue` : "No overdue rent"}
|
||||||
|
icon={Wrench}
|
||||||
|
variant={stats.openMaintenanceRequests > 0 ? "warning" : "default"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiring leases alert */}
|
||||||
|
{expiringLeases.length > 0 && (
|
||||||
|
<div className="mb-6 rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
|
||||||
|
<span className="text-sm font-semibold text-amber-400">
|
||||||
|
{expiringLeases.length} lease{expiringLeases.length > 1 ? "s" : ""} expiring soon
|
||||||
|
</span>
|
||||||
|
<Link href="/leases" className="ml-auto flex items-center gap-1 text-xs text-amber-400/70 hover:text-amber-300 transition">
|
||||||
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{expiringLeases.slice(0, 4).map((lease: any) => {
|
||||||
|
const days = daysUntil(lease.lease_end)
|
||||||
|
return (
|
||||||
|
<div key={lease.id} className="flex items-center justify-between rounded-xl bg-amber-500/[0.04] border border-amber-500/10 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-amber-400/60 shrink-0" />
|
||||||
|
<span className="text-xs text-white/70 truncate">
|
||||||
|
{lease.tenant?.first_name} {lease.tenant?.last_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-semibold tabular-nums shrink-0 ml-2 ${days <= 7 ? "text-red-400" : days <= 30 ? "text-amber-400" : "text-white/50"}`}>
|
||||||
|
{days <= 0 ? "Expired" : `${days}d`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Middle row: Revenue chart + Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-5 mb-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<RevenueChart
|
||||||
|
data={monthlyRevenue}
|
||||||
|
thisMonth={stats.rentCollectedThisMonth}
|
||||||
|
pending={stats.rentPendingThisMonth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<QuickActions />
|
||||||
|
<ExpenseBreakdownChart data={expenseBreakdown} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom row: Recent payments + Open maintenance */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
|
||||||
|
{/* Recent Payments */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-4 w-4 text-white/30" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Recent Payments</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/rent" className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition">
|
||||||
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentPayments.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={CreditCard}
|
||||||
|
title="No payments yet"
|
||||||
|
description="Record your first rent payment to track collections."
|
||||||
|
action={{ label: "Record payment", href: "/rent" }}
|
||||||
|
className="py-10"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{recentPayments.map((payment: any) => (
|
||||||
|
<div key={payment.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-white/[0.02] transition-colors">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500/30 to-violet-500/30 text-xs font-bold text-indigo-300">
|
||||||
|
{payment.tenant?.first_name?.[0]}{payment.tenant?.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{payment.tenant?.first_name} {payment.tenant?.last_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/40 truncate">
|
||||||
|
{payment.property?.name} · {formatDate(payment.due_date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 shrink-0 ml-3">
|
||||||
|
<span className="text-sm font-bold text-white tabular-nums">
|
||||||
|
{formatCurrency(payment.amount)}
|
||||||
|
</span>
|
||||||
|
<RentStatusBadge status={payment.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Open Maintenance */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-4 w-4 text-white/30" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">Open Maintenance</h2>
|
||||||
|
{stats.openMaintenanceRequests > 0 && (
|
||||||
|
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-amber-500/20 px-1.5 text-[10px] font-bold text-amber-400">
|
||||||
|
{stats.openMaintenanceRequests}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link href="/maintenance" className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition">
|
||||||
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openMaintenance.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Wrench}
|
||||||
|
title="No open requests"
|
||||||
|
description="All maintenance is up to date."
|
||||||
|
className="py-10"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{openMaintenance.map((req: any) => (
|
||||||
|
<Link
|
||||||
|
key={req.id}
|
||||||
|
href={`/maintenance/${req.id}`}
|
||||||
|
className="flex items-center justify-between px-5 py-3.5 hover:bg-white/[0.02] transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-white group-hover:text-indigo-200 transition-colors">
|
||||||
|
{req.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/40 truncate mt-0.5">
|
||||||
|
{req.property?.name}{req.tenant ? ` · ${req.tenant.first_name} ${req.tenant.last_name}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 flex flex-col items-end gap-1 shrink-0">
|
||||||
|
<PriorityBadge priority={req.priority} />
|
||||||
|
<MaintenanceStatusBadge status={req.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
export default function DashboardError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-xl border border-red-500/10 bg-red-500/5 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500/10 mb-4">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-semibold text-white">Something went wrong</h2>
|
||||||
|
<p className="mt-2 max-w-sm text-sm text-white/50">
|
||||||
|
{error.message || "An unexpected error occurred. Try refreshing the page."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="mt-6 flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Receipt, Download, Plus } from "lucide-react"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/utils"
|
||||||
|
import { DeleteButton } from "@/components/shared/delete-button"
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
repairs: "text-orange-400 bg-orange-500/10 ring-orange-500/20",
|
||||||
|
utilities: "text-blue-400 bg-blue-500/10 ring-blue-500/20",
|
||||||
|
insurance: "text-purple-400 bg-purple-500/10 ring-purple-500/20",
|
||||||
|
mortgage: "text-indigo-400 bg-indigo-500/10 ring-indigo-500/20",
|
||||||
|
taxes: "text-red-400 bg-red-500/10 ring-red-500/20",
|
||||||
|
management: "text-cyan-400 bg-cyan-500/10 ring-cyan-500/20",
|
||||||
|
supplies: "text-green-400 bg-green-500/10 ring-green-500/20",
|
||||||
|
other: "text-white/40 bg-white/5 ring-white/10",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpensesClient({ expenses: initial, properties }: { expenses: any[]; properties: any[] }) {
|
||||||
|
const [expenses, setExpenses] = useState(initial)
|
||||||
|
const [propertyFilter, setPropertyFilter] = useState("")
|
||||||
|
|
||||||
|
const filtered = useMemo(() =>
|
||||||
|
propertyFilter ? expenses.filter((e) => e.property_id === propertyFilter) : expenses,
|
||||||
|
[expenses, propertyFilter]
|
||||||
|
)
|
||||||
|
|
||||||
|
const total = filtered.reduce((s, e) => s + Number(e.amount), 0)
|
||||||
|
|
||||||
|
const byCategory = filtered.reduce((acc: Record<string, number>, e) => {
|
||||||
|
acc[e.category] = (acc[e.category] ?? 0) + Number(e.amount)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
function onDeleted(id: string) {
|
||||||
|
setExpenses((prev) => prev.filter((e) => e.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Expenses</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
|
{filtered.length} records
|
||||||
|
{total > 0 && <span className="ml-2 text-rose-400">{formatCurrency(total)} total</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href="/api/expenses/export"
|
||||||
|
className="flex items-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-xs font-medium text-white/50 hover:border-white/20 hover:text-white transition"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" /> Export CSV
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/expenses/new"
|
||||||
|
className="flex items-center gap-1.5 rounded-xl bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-500 transition"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Add Expense
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property filter */}
|
||||||
|
{properties.length > 1 && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setPropertyFilter("")}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${!propertyFilter ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
All Properties
|
||||||
|
</button>
|
||||||
|
{properties.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setPropertyFilter(p.id)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${propertyFilter === p.id ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category breakdown */}
|
||||||
|
{Object.keys(byCategory).length > 0 && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{Object.entries(byCategory)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([cat, amt]) => (
|
||||||
|
<div key={cat} className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ring-1 ring-inset ${categoryColors[cat] ?? categoryColors.other}`}>
|
||||||
|
<span className="capitalize">{cat}</span>
|
||||||
|
<span className="opacity-60">{formatCurrency(amt as number)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!filtered.length ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Receipt}
|
||||||
|
title="No expenses yet"
|
||||||
|
description="Track property expenses to monitor profitability and prepare for tax season."
|
||||||
|
action={{ label: "Add expense", href: "/expenses/new" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden sm:block rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/[0.06]">
|
||||||
|
{["Description", "Property", "Category", "Date", "Amount", ""].map((h) => (
|
||||||
|
<th key={h} className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/[0.04]">
|
||||||
|
{filtered.map((expense) => (
|
||||||
|
<tr key={expense.id} className="group hover:bg-white/[0.02] transition">
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-sm font-medium text-white">{expense.description}</p>
|
||||||
|
{expense.vendor && <p className="text-xs text-white/35">{expense.vendor}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-sm text-white/60">{expense.property?.name ?? "—"}</p>
|
||||||
|
{expense.unit && <p className="text-xs text-white/35">Unit {expense.unit.unit_number}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ring-1 ring-inset ${categoryColors[expense.category] ?? categoryColors.other}`}>
|
||||||
|
{expense.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-sm text-white/50">{formatDate(expense.expense_date)}</td>
|
||||||
|
<td className="px-5 py-3.5 text-sm font-bold text-white tabular-nums">{formatCurrency(expense.amount)}</td>
|
||||||
|
<td className="px-5 py-3.5 text-right">
|
||||||
|
<DeleteButton id={expense.id} endpoint="/api/expenses" onDeleted={() => onDeleted(expense.id)} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{filtered.map((expense) => (
|
||||||
|
<div key={expense.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-white truncate">{expense.description}</p>
|
||||||
|
{expense.vendor && <p className="text-xs text-white/35 mt-0.5">{expense.vendor}</p>}
|
||||||
|
</div>
|
||||||
|
<p className="shrink-0 text-sm font-bold text-white tabular-nums">{formatCurrency(expense.amount)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium capitalize ring-1 ring-inset ${categoryColors[expense.category] ?? categoryColors.other}`}>
|
||||||
|
{expense.category}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/35">{formatDate(expense.expense_date)}</span>
|
||||||
|
</div>
|
||||||
|
<DeleteButton id={expense.id} endpoint="/api/expenses" onDeleted={() => onDeleted(expense.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function ExpensesLoading() {
|
||||||
|
return <TableSkeleton rows={7} cols={5} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { asc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { ExpenseForm } from "@/components/forms/expense-form"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "Add Expense" }
|
||||||
|
|
||||||
|
export default async function NewExpensePage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const propertyList = await db.query.properties.findMany({
|
||||||
|
where: eq(properties.user_id, user.id),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
with: {
|
||||||
|
units: { columns: { id: true, unit_number: true } },
|
||||||
|
},
|
||||||
|
orderBy: asc(properties.name),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<BackButton href="/expenses" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Add Expense</h2>
|
||||||
|
<p className="text-sm text-white/40">Log a property expense</p>
|
||||||
|
</div>
|
||||||
|
<ExpenseForm properties={propertyList ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { asc, desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { expenses, properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { ExpensesClient } from "./expenses-client"
|
||||||
|
|
||||||
|
export const metadata = { title: "Expenses" }
|
||||||
|
|
||||||
|
export default async function ExpensesPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const [expenseList, propertyList] = await Promise.all([
|
||||||
|
db.query.expenses.findMany({
|
||||||
|
where: eq(expenses.user_id, user.id),
|
||||||
|
with: {
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
unit: { columns: { unit_number: true } },
|
||||||
|
},
|
||||||
|
orderBy: desc(expenses.expense_date),
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ id: properties.id, name: properties.name })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.user_id, user.id))
|
||||||
|
.orderBy(asc(properties.name)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpensesClient
|
||||||
|
expenses={expenseList ?? []}
|
||||||
|
properties={propertyList ?? []}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { formatDistanceToNow } from "date-fns"
|
||||||
|
import {
|
||||||
|
Plus, Play, Trash2, ToggleLeft, ToggleRight,
|
||||||
|
Loader2, Bell, DollarSign, Wrench, FileText, Home, X,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const RULE_TYPES = [
|
||||||
|
{ value: "overdue_rent", label: "Overdue Rent Reminder", icon: DollarSign, color: "text-red-400", desc: "Remind tenants when rent is overdue" },
|
||||||
|
{ value: "maintenance_stale",label: "Stale Maintenance Alert", icon: Wrench, color: "text-orange-400", desc: "Follow up on open maintenance requests" },
|
||||||
|
{ value: "lease_renewal", label: "Lease Renewal Notice", icon: FileText, color: "text-blue-400", desc: "Alert tenants about expiring leases" },
|
||||||
|
{ value: "vacant_unit", label: "Vacant Unit Reminder", icon: Home, color: "text-amber-400", desc: "Internal alerts for vacant units" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
overdue_rent: "text-red-400 bg-red-500/10",
|
||||||
|
maintenance_stale: "text-orange-400 bg-orange-500/10",
|
||||||
|
lease_renewal: "text-blue-400 bg-blue-500/10",
|
||||||
|
vacant_unit: "text-amber-400 bg-amber-500/10",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUpsClient({ rules: initial, logs: initialLogs }: { rules: any[]; logs: any[] }) {
|
||||||
|
const [rules, setRules] = useState(initial)
|
||||||
|
const [logs, setLogs] = useState(initialLogs)
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [form, setForm] = useState({ type: "overdue_rent", name: "", trigger_days: 3, message_template: "" })
|
||||||
|
|
||||||
|
async function runFollowUps() {
|
||||||
|
setRunning(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/follow-ups/run", { method: "POST" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed to run"); return }
|
||||||
|
if (data.sent === 0) {
|
||||||
|
toast.info("No follow-ups triggered — all rules are up to date")
|
||||||
|
} else {
|
||||||
|
toast.success(`${data.sent} follow-up${data.sent !== 1 ? "s" : ""} triggered`)
|
||||||
|
setLogs((prev) => [...data.results, ...prev].slice(0, 30))
|
||||||
|
}
|
||||||
|
// refresh last_run_at
|
||||||
|
const refreshed = await fetch("/api/follow-ups")
|
||||||
|
const refreshedData = await refreshed.json()
|
||||||
|
setRules(refreshedData.rules ?? rules)
|
||||||
|
} catch {
|
||||||
|
toast.error("Network error — please try again")
|
||||||
|
} finally {
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRule() {
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/follow-ups", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed to save"); return }
|
||||||
|
setRules((prev) => [...prev, data])
|
||||||
|
setShowForm(false)
|
||||||
|
setForm({ type: "overdue_rent", name: "", trigger_days: 3, message_template: "" })
|
||||||
|
toast.success("Rule added")
|
||||||
|
} catch {
|
||||||
|
toast.error("Network error")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRule(id: string, is_active: boolean) {
|
||||||
|
const res = await fetch(`/api/follow-ups/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ is_active: !is_active }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed to update"); return }
|
||||||
|
setRules((prev) => prev.map((r) => r.id === id ? data : r))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRule(id: string) {
|
||||||
|
await fetch(`/api/follow-ups/${id}`, { method: "DELETE" })
|
||||||
|
setRules((prev) => prev.filter((r) => r.id !== id))
|
||||||
|
toast.success("Rule deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5 text-indigo-400" />
|
||||||
|
Automated Follow-ups
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">Set rules and run them manually to trigger follow-up actions</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl border border-white/10 px-3 py-2 text-xs font-medium text-white/60 hover:text-white transition"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Add Rule
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={runFollowUps}
|
||||||
|
disabled={running || rules.filter((r) => r.is_active).length === 0}
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
|
{running ? "Running…" : "Run Now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add rule form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="rounded-2xl border border-indigo-500/20 bg-[#16161f] p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-white">New Follow-up Rule</p>
|
||||||
|
<button onClick={() => setShowForm(false)} className="text-white/30 hover:text-white transition"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Rule Name *</label>
|
||||||
|
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. 3-day overdue reminder" className={cls} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Type *</label>
|
||||||
|
<select value={form.type} onChange={(e) => setForm((f) => ({ ...f, type: e.target.value }))} className={cls}>
|
||||||
|
{RULE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">
|
||||||
|
Trigger after{" "}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={90}
|
||||||
|
value={form.trigger_days}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, trigger_days: parseInt(e.target.value) || 1 }))}
|
||||||
|
className="mx-1 w-14 rounded border border-white/10 bg-white/5 px-2 py-0.5 text-sm text-white text-center outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
{" "}days
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Custom message (optional — leave blank for default)</label>
|
||||||
|
<textarea value={form.message_template} onChange={(e) => setForm((f) => ({ ...f, message_template: e.target.value }))} rows={3} placeholder="Leave blank to use the default message…" className={cls} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<button onClick={() => setShowForm(false)} className="rounded-xl border border-white/10 px-4 py-2 text-sm text-white/40 hover:text-white transition">Cancel</button>
|
||||||
|
<button onClick={saveRule} disabled={saving || !form.name.trim()} className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||||
|
{saving ? "Saving…" : "Save Rule"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rules list */}
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
|
||||||
|
<Bell className="h-8 w-8 text-white/10 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-white/30">No follow-up rules yet</p>
|
||||||
|
<p className="text-xs text-white/20 mt-1">Add rules to automate reminders for overdue rent, maintenance, and lease renewals</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rules.map((rule) => {
|
||||||
|
const typeInfo = RULE_TYPES.find((t) => t.value === rule.type)
|
||||||
|
const Icon = typeInfo?.icon ?? Bell
|
||||||
|
return (
|
||||||
|
<div key={rule.id} className={`rounded-xl border border-white/[0.06] bg-[#16161f] p-4 flex items-center gap-4 ${!rule.is_active ? "opacity-50" : ""}`}>
|
||||||
|
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${typeColors[rule.type] ?? "text-white/40 bg-white/5"}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-white">{rule.name}</p>
|
||||||
|
<p className="text-xs text-white/40 mt-0.5">
|
||||||
|
{typeInfo?.desc} · Triggers after {rule.trigger_days} day{rule.trigger_days !== 1 ? "s" : ""}
|
||||||
|
{rule.last_run_at && <span className="ml-2 text-white/25">Last run {formatDistanceToNow(new Date(rule.last_run_at), { addSuffix: true })}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button onClick={() => toggleRule(rule.id, rule.is_active)} className="p-1.5 text-white/30 hover:text-white transition" title={rule.is_active ? "Disable" : "Enable"}>
|
||||||
|
{rule.is_active ? <ToggleRight className="h-5 w-5 text-indigo-400" /> : <ToggleLeft className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteRule(rule.id)} className="p-1.5 text-white/20 hover:text-red-400 transition rounded-lg hover:bg-red-500/10">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log */}
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-white/[0.06]">
|
||||||
|
<p className="text-sm font-semibold text-white">Follow-up Log</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div key={log.id} className="px-5 py-3.5 flex items-start gap-4">
|
||||||
|
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${typeColors[log.type] ?? "text-white/40 bg-white/5"}`}>
|
||||||
|
<Bell className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white">{log.subject}</p>
|
||||||
|
{log.recipient_email && (
|
||||||
|
<p className="text-xs text-white/35 mt-0.5">{log.recipient_name} · {log.recipient_email}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-white/25 mt-1 line-clamp-2">{log.message}</p>
|
||||||
|
</div>
|
||||||
|
<p className="shrink-0 text-xs text-white/25 mt-0.5">
|
||||||
|
{formatDistanceToNow(new Date(log.created_at), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { asc, desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { follow_up_rules, follow_up_log } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { FollowUpsClient } from "./follow-ups-client"
|
||||||
|
|
||||||
|
export const metadata = { title: "Automated Follow-ups" }
|
||||||
|
|
||||||
|
export default async function FollowUpsPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const [rules, logs] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(follow_up_rules)
|
||||||
|
.where(eq(follow_up_rules.user_id, user.id))
|
||||||
|
.orderBy(asc(follow_up_rules.created_at)),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(follow_up_log)
|
||||||
|
.where(eq(follow_up_log.user_id, user.id))
|
||||||
|
.orderBy(desc(follow_up_log.created_at))
|
||||||
|
.limit(30),
|
||||||
|
])
|
||||||
|
|
||||||
|
return <FollowUpsClient rules={rules ?? []} logs={logs ?? []} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { formatDistanceToNow } from "date-fns"
|
||||||
|
import { TrendingUp, ShieldCheck, PiggyBank, Zap, CheckCircle, BarChart3 } from "lucide-react"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
rent_increase: "Rent Increase",
|
||||||
|
vacancy_alert: "Vacancy",
|
||||||
|
maintenance_urgent: "Maintenance",
|
||||||
|
lease_renewal: "Lease Renewal",
|
||||||
|
expense_alert: "Expense",
|
||||||
|
cash_flow: "Cash Flow",
|
||||||
|
risk_alert: "Risk",
|
||||||
|
opportunity: "Opportunity",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImpactClient({
|
||||||
|
stats,
|
||||||
|
activityLog,
|
||||||
|
}: {
|
||||||
|
stats: {
|
||||||
|
totals: { generated: number; approved: number; dismissed: number; pending: number; approval_rate: number }
|
||||||
|
impact: { revenue: number; savings: number; risk_prevented: number; total: number }
|
||||||
|
recent_approved: any[]
|
||||||
|
}
|
||||||
|
activityLog: any[]
|
||||||
|
}) {
|
||||||
|
const { totals, impact, recent_approved } = stats
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-violet-400" />
|
||||||
|
AI Impact Tracking
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">Estimated value from approved AI recommendations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Impact cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span className="text-xs text-emerald-400 font-medium">Revenue Added</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{formatCurrency(impact.revenue)}</p>
|
||||||
|
<p className="text-xs text-white/30 mt-1">per month est.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<PiggyBank className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="text-xs text-blue-400 font-medium">Cost Savings</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{formatCurrency(impact.savings)}</p>
|
||||||
|
<p className="text-xs text-white/30 mt-1">per month est.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-amber-400" />
|
||||||
|
<span className="text-xs text-amber-400 font-medium">Risk Prevented</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{formatCurrency(impact.risk_prevented)}</p>
|
||||||
|
<p className="text-xs text-white/30 mt-1">per month est.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-violet-500/20 bg-violet-500/5 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Zap className="h-4 w-4 text-violet-400" />
|
||||||
|
<span className="text-xs text-violet-400 font-medium">Total Impact</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">{formatCurrency(impact.total)}</p>
|
||||||
|
<p className="text-xs text-white/30 mt-1">per month est.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "Generated", value: totals.generated, color: "text-white" },
|
||||||
|
{ label: "Approved", value: totals.approved, color: "text-emerald-400" },
|
||||||
|
{ label: "Dismissed", value: totals.dismissed, color: "text-white/40" },
|
||||||
|
{ label: "Approval Rate", value: `${totals.approval_rate}%`, color: "text-violet-400" },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4 text-center">
|
||||||
|
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||||
|
<p className="text-xs text-white/30 mt-1">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
{/* Recent approved */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-white/[0.06]">
|
||||||
|
<p className="text-sm font-semibold text-white">Recently Approved</p>
|
||||||
|
</div>
|
||||||
|
{recent_approved.length === 0 ? (
|
||||||
|
<div className="py-10 text-center">
|
||||||
|
<p className="text-xs text-white/25">No approved recommendations yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{recent_approved.map((r) => (
|
||||||
|
<div key={r.id} className="px-5 py-3.5 flex items-start gap-3">
|
||||||
|
<CheckCircle className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white">{r.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-xs text-white/30">{typeLabels[r.type] ?? r.type}</span>
|
||||||
|
{r.action_data?.estimated_value > 0 && (
|
||||||
|
<span className="text-xs text-emerald-400">
|
||||||
|
+{formatCurrency(r.action_data.estimated_value)}/mo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{r.applied_at && (
|
||||||
|
<span className="text-xs text-white/20 shrink-0">
|
||||||
|
{formatDistanceToNow(new Date(r.applied_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI activity log */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-white/[0.06]">
|
||||||
|
<p className="text-sm font-semibold text-white">AI Action Log</p>
|
||||||
|
</div>
|
||||||
|
{activityLog.length === 0 ? (
|
||||||
|
<div className="py-10 text-center">
|
||||||
|
<p className="text-xs text-white/25">No AI actions yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{activityLog.map((a) => (
|
||||||
|
<div key={a.id} className="px-5 py-3.5 flex items-start gap-3">
|
||||||
|
<Zap className="h-4 w-4 text-violet-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white/70">{a.title}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-white/20 shrink-0">
|
||||||
|
{formatDistanceToNow(new Date(a.created_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { ai_recommendations, activity_log } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { ImpactClient } from "./impact-client"
|
||||||
|
|
||||||
|
export const metadata = { title: "AI Impact" }
|
||||||
|
|
||||||
|
export default async function ImpactPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const [recs, activityRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(ai_recommendations)
|
||||||
|
.where(eq(ai_recommendations.user_id, user.id)),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(activity_log)
|
||||||
|
.where(and(eq(activity_log.user_id, user.id), eq(activity_log.type, "ai_action")))
|
||||||
|
.orderBy(desc(activity_log.created_at))
|
||||||
|
.limit(20),
|
||||||
|
])
|
||||||
|
|
||||||
|
const all = recs ?? []
|
||||||
|
const approved = all.filter((r) => r.status === "approved")
|
||||||
|
const dismissed = all.filter((r) => r.status === "dismissed")
|
||||||
|
|
||||||
|
let totalRevenue = 0
|
||||||
|
let totalSavings = 0
|
||||||
|
let totalRiskPrevented = 0
|
||||||
|
|
||||||
|
for (const r of approved) {
|
||||||
|
const val = Number(r.action_data?.estimated_value ?? 0)
|
||||||
|
const vtype = r.action_data?.value_type ?? "revenue"
|
||||||
|
if (vtype === "revenue") totalRevenue += val
|
||||||
|
else if (vtype === "savings") totalSavings += val
|
||||||
|
else if (vtype === "risk_prevention") totalRiskPrevented += val
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totals: {
|
||||||
|
generated: all.length,
|
||||||
|
approved: approved.length,
|
||||||
|
dismissed: dismissed.length,
|
||||||
|
pending: all.filter((r) => r.status === "pending").length,
|
||||||
|
approval_rate: all.length > 0 ? Math.round((approved.length / all.length) * 100) : 0,
|
||||||
|
},
|
||||||
|
impact: {
|
||||||
|
revenue: totalRevenue,
|
||||||
|
savings: totalSavings,
|
||||||
|
risk_prevented: totalRiskPrevented,
|
||||||
|
total: totalRevenue + totalSavings + totalRiskPrevented,
|
||||||
|
},
|
||||||
|
recent_approved: approved
|
||||||
|
.sort((a, b) => new Date(b.applied_at ?? 0).getTime() - new Date(a.applied_at ?? 0).getTime())
|
||||||
|
.slice(0, 5),
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ImpactClient stats={stats} activityLog={activityRows ?? []} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ClipboardList, Plus, X, CheckCircle2, Clock, Trash2 } from "lucide-react"
|
||||||
|
import { Select } from "@/components/ui/select"
|
||||||
|
import { formatDate } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
const INSPECTION_TYPES = [
|
||||||
|
{ value: "move_in", label: "Move-In" },
|
||||||
|
{ value: "move_out", label: "Move-Out" },
|
||||||
|
{ value: "routine", label: "Routine" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
move_in: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
|
||||||
|
move_out: "text-rose-400 bg-rose-500/10 border-rose-500/20",
|
||||||
|
routine: "text-blue-400 bg-blue-500/10 border-blue-500/20",
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusIcon: Record<string, React.ElementType> = {
|
||||||
|
draft: Clock,
|
||||||
|
completed: CheckCircle2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectionManager({ inspections: initial, properties }: { inspections: any[]; properties: any[] }) {
|
||||||
|
const [inspections, setInspections] = useState(initial)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selectedProp, setSelectedProp] = useState("")
|
||||||
|
const [form, setForm] = useState({ type: "move_in", unit_id: "", date: new Date().toISOString().slice(0, 10), notes: "" })
|
||||||
|
|
||||||
|
const propertyOptions = [
|
||||||
|
{ value: "", label: "Select property…" },
|
||||||
|
...properties.map((p: any) => ({ value: p.id, label: p.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const units = properties.find((p: any) => p.id === selectedProp)?.units ?? []
|
||||||
|
const unitOptions = [
|
||||||
|
{ value: "", label: "No specific unit" },
|
||||||
|
...units.map((u: any) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
|
||||||
|
|
||||||
|
async function toggleStatus(id: string, current: string) {
|
||||||
|
const next = current === "completed" ? "draft" : "completed"
|
||||||
|
const res = await fetch(`/api/inspections/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: next }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setInspections(v => v.map(i => i.id === id ? { ...i, status: next } : i))
|
||||||
|
toast.success(next === "completed" ? "Marked complete" : "Marked draft")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInspection(id: string) {
|
||||||
|
await fetch(`/api/inspections/${id}`, { method: "DELETE" })
|
||||||
|
setInspections(v => v.filter(i => i.id !== id))
|
||||||
|
toast.success("Inspection deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!selectedProp) { toast.error("Select a property"); return }
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch("/api/inspections", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ property_id: selectedProp, ...form }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed"); return }
|
||||||
|
setInspections(v => [data, ...v])
|
||||||
|
setShowForm(false)
|
||||||
|
setSelectedProp("")
|
||||||
|
setForm({ type: "move_in", unit_id: "", date: new Date().toISOString().slice(0, 10), notes: "" })
|
||||||
|
toast.success("Inspection created")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!showForm && (
|
||||||
|
<button onClick={() => setShowForm(true)} className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 transition hover:shadow-lg hover:shadow-indigo-500/25">
|
||||||
|
<Plus className="h-4 w-4" /> New Inspection
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="rounded-2xl border border-indigo-500/20 bg-[#16161f] p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-white">New Inspection</p>
|
||||||
|
<button onClick={() => setShowForm(false)} className="text-white/30 hover:text-white transition"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Type</label>
|
||||||
|
<Select value={form.type} onChange={v => setForm(f => ({ ...f, type: v }))} options={INSPECTION_TYPES} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Date</label>
|
||||||
|
<input type="date" value={form.date} onChange={e => setForm(f => ({ ...f, date: e.target.value }))} className={cls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Property *</label>
|
||||||
|
<Select value={selectedProp} onChange={setSelectedProp} options={propertyOptions} />
|
||||||
|
</div>
|
||||||
|
{selectedProp && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Unit</label>
|
||||||
|
<Select value={form.unit_id} onChange={v => setForm(f => ({ ...f, unit_id: v }))} options={unitOptions} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Notes</label>
|
||||||
|
<input value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="Optional notes…" className={cls} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<button onClick={() => setShowForm(false)} className="rounded-xl border border-white/10 px-4 py-2 text-sm text-white/40 hover:text-white transition">Cancel</button>
|
||||||
|
<button onClick={create} disabled={loading || !selectedProp} className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||||
|
{loading ? "Creating…" : "Create Inspection"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inspections.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
|
||||||
|
<ClipboardList className="h-8 w-8 text-white/10 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-white/30">No inspections yet</p>
|
||||||
|
<p className="text-xs text-white/20 mt-1">Create move-in and move-out checklists for each unit</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-3 border-b border-white/[0.06]">
|
||||||
|
<p className="text-sm font-semibold text-white">{inspections.length} Inspection{inspections.length !== 1 ? "s" : ""}</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{inspections.map((ins: any) => {
|
||||||
|
const StatusIcon = statusIcon[ins.status] ?? Clock
|
||||||
|
const typeColor = typeColors[ins.type] ?? typeColors.routine
|
||||||
|
const typeLabel = INSPECTION_TYPES.find(t => t.value === ins.type)?.label ?? ins.type
|
||||||
|
return (
|
||||||
|
<div key={ins.id} className="flex items-center gap-4 px-5 py-4">
|
||||||
|
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border ${typeColor}`}>
|
||||||
|
<ClipboardList className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-white">{typeLabel} Inspection</p>
|
||||||
|
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-medium capitalize ${typeColor}`}>{typeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/35 mt-0.5">
|
||||||
|
{ins.property?.name}{ins.unit ? ` · Unit ${ins.unit.unit_number}` : ""} · {formatDate(ins.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleStatus(ins.id, ins.status)}
|
||||||
|
className="flex items-center gap-1 text-xs hover:opacity-80 transition"
|
||||||
|
title={ins.status === "completed" ? "Mark as draft" : "Mark as complete"}
|
||||||
|
>
|
||||||
|
<StatusIcon className={`h-3.5 w-3.5 ${ins.status === "completed" ? "text-emerald-400" : "text-white/30"}`} />
|
||||||
|
<span className="text-white/30 capitalize">{ins.status}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteInspection(ins.id)}
|
||||||
|
className="p-1 text-white/20 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||||
|
export default function Loading() { return <TableSkeleton rows={5} cols={3} /> }
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { inspections, properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { InspectionManager } from "./inspection-manager"
|
||||||
|
|
||||||
|
export const metadata = { title: "Inspections" }
|
||||||
|
|
||||||
|
export default async function InspectionsPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const [inspectionList, propertyList] = await Promise.all([
|
||||||
|
db.query.inspections.findMany({
|
||||||
|
where: eq(inspections.user_id, user.id),
|
||||||
|
with: {
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
unit: { columns: { unit_number: true } },
|
||||||
|
},
|
||||||
|
orderBy: desc(inspections.created_at),
|
||||||
|
}),
|
||||||
|
db.query.properties.findMany({
|
||||||
|
where: eq(properties.user_id, user.id),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
with: {
|
||||||
|
units: { columns: { id: true, unit_number: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Inspections</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">Move-in and move-out condition reports per unit</p>
|
||||||
|
</div>
|
||||||
|
<InspectionManager inspections={inspectionList ?? []} properties={propertyList ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { profiles } from "@/lib/db/schema"
|
||||||
|
import { getSession } from "@/lib/session"
|
||||||
|
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||||
|
import { Header } from "@/components/dashboard/header"
|
||||||
|
import { CommandPalette } from "@/components/dashboard/command-palette"
|
||||||
|
import { PageTransition } from "@/components/dashboard/page-transition"
|
||||||
|
import { Breadcrumbs } from "@/components/dashboard/breadcrumbs"
|
||||||
|
import { ScrollToTop } from "@/components/ui/scroll-to-top"
|
||||||
|
import { ImpersonationBanner } from "@/components/admin/impersonation-banner"
|
||||||
|
|
||||||
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await getSession()
|
||||||
|
const user = session?.user
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await db.query.profiles.findFirst({
|
||||||
|
where: eq(profiles.id, user.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set by the Better Auth admin plugin while an admin is impersonating.
|
||||||
|
const impersonating = Boolean((session?.session as { impersonatedBy?: string })?.impersonatedBy)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-[#09090b] overflow-hidden">
|
||||||
|
{impersonating && <ImpersonationBanner label={profile?.email ?? user.email} />}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<Sidebar profile={profile ?? null} />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main id="main-scroll" className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
|
<Breadcrumbs />
|
||||||
|
<PageTransition>
|
||||||
|
{children}
|
||||||
|
</PageTransition>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<CommandPalette />
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function LeasesLoading() {
|
||||||
|
return <TableSkeleton rows={6} cols={6} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { tenants as tenantsTable, properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { LeaseForm } from "@/components/forms/lease-form"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "Add Lease" }
|
||||||
|
|
||||||
|
export default async function NewLeasePage({ searchParams }: { searchParams: Promise<Record<string, string>> }) {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const prefill = {
|
||||||
|
tenant_id: params.tenant_id ?? "",
|
||||||
|
property_id: params.property_id ?? "",
|
||||||
|
unit_id: params.unit_id ?? "",
|
||||||
|
rent_amount: params.rent_amount ?? "",
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tenants, properties_] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: tenantsTable.id,
|
||||||
|
first_name: tenantsTable.first_name,
|
||||||
|
last_name: tenantsTable.last_name,
|
||||||
|
unit_id: tenantsTable.unit_id,
|
||||||
|
property_id: tenantsTable.property_id,
|
||||||
|
})
|
||||||
|
.from(tenantsTable)
|
||||||
|
.where(and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")))
|
||||||
|
.orderBy(asc(tenantsTable.first_name)),
|
||||||
|
db.query.properties.findMany({
|
||||||
|
where: eq(properties.user_id, user.id),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
with: {
|
||||||
|
units: { columns: { id: true, unit_number: true } },
|
||||||
|
},
|
||||||
|
orderBy: asc(properties.name),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<BackButton href="/leases" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Add Lease</h2>
|
||||||
|
<p className="text-sm text-white/40">
|
||||||
|
{prefill.tenant_id ? "Renewing lease — dates pre-filled from previous lease" : "Create a lease record for a tenant"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LeaseForm tenants={tenants ?? []} properties={properties_ ?? []} prefill={prefill} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { eq, asc } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { leases as leasesTable } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { FileText, AlertTriangle, ArrowRight } from "lucide-react"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { formatDate, daysUntil } from "@/lib/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export const metadata = { title: "Leases" }
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
|
||||||
|
expired: "text-red-400 bg-red-500/10 border-red-500/20",
|
||||||
|
terminated: "text-white/40 bg-white/5 border-white/10",
|
||||||
|
renewed: "text-blue-400 bg-blue-500/10 border-blue-500/20",
|
||||||
|
}
|
||||||
|
|
||||||
|
function DaysChip({ days, status }: { days: number; status: string }) {
|
||||||
|
if (status !== "active") return <span className="text-sm text-white/25">—</span>
|
||||||
|
if (days <= 0) return <span className="text-xs font-medium text-red-400">Expired</span>
|
||||||
|
const color = days <= 7 ? "text-red-400" : days <= 30 ? "text-amber-400" : days <= 60 ? "text-yellow-400" : "text-white/40"
|
||||||
|
return (
|
||||||
|
<span className={cn("flex items-center gap-1 text-sm font-medium", color)}>
|
||||||
|
{days}d
|
||||||
|
{days <= 60 && <AlertTriangle className="h-3 w-3" />}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LeasesPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const leases = await db.query.leases.findMany({
|
||||||
|
where: eq(leasesTable.user_id, user.id),
|
||||||
|
with: {
|
||||||
|
tenant: { columns: { first_name: true, last_name: true } },
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
unit: { columns: { unit_number: true } },
|
||||||
|
},
|
||||||
|
orderBy: asc(leasesTable.lease_end),
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = leases?.filter((l: any) => l.status === "active").length ?? 0
|
||||||
|
const expiring = leases?.filter((l: any) => l.status === "active" && daysUntil(l.lease_end) <= 60).length ?? 0
|
||||||
|
const expired = leases?.filter((l: any) => l.status === "expired").length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "Active", value: active, color: "text-emerald-400" },
|
||||||
|
{ label: "Expiring (60 days)", value: expiring, color: "text-amber-400" },
|
||||||
|
{ label: "Expired", value: expired, color: "text-red-400" },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
<p className="text-xs text-white/35">{s.label}</p>
|
||||||
|
<p className={`mt-1 text-2xl font-bold tabular-nums ${s.color}`}>{s.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!leases?.length ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title="No leases yet"
|
||||||
|
description="Add leases to track expiry dates and get automatic reminders."
|
||||||
|
action={{ label: "Add lease", href: "/leases/new" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden md:block rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/[0.06]">
|
||||||
|
{["Tenant", "Property / Unit", "Period", "Rent", "Status", "Ends in", ""].map((h) => (
|
||||||
|
<th key={h} className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/[0.04]">
|
||||||
|
{leases.map((lease) => {
|
||||||
|
const days = daysUntil(lease.lease_end)
|
||||||
|
const canRenew = (lease.status === "active" && days <= 60) || lease.status === "expired"
|
||||||
|
return (
|
||||||
|
<tr key={lease.id} className="group hover:bg-white/[0.02] transition">
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{lease.tenant?.first_name} {lease.tenant?.last_name}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-sm text-white/70">{lease.property?.name}</p>
|
||||||
|
<p className="text-xs text-white/35">Unit {lease.unit?.unit_number ?? "—"}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-xs text-white/50">{formatDate(lease.lease_start)}</p>
|
||||||
|
<p className="text-xs text-white/50">→ {formatDate(lease.lease_end)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-sm font-semibold text-white tabular-nums">${lease.rent_amount}<span className="text-xs text-white/30">/mo</span></p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span className={cn("rounded-md border px-2 py-0.5 text-xs font-medium capitalize", statusColors[lease.status] ?? statusColors.active)}>
|
||||||
|
{lease.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<DaysChip days={days} status={lease.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-right">
|
||||||
|
{canRenew && (
|
||||||
|
<Link
|
||||||
|
href={`/leases/new?tenant_id=${lease.tenant_id}&property_id=${lease.property_id}&unit_id=${lease.unit_id ?? ""}&rent_amount=${lease.rent_amount}`}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
|
||||||
|
>
|
||||||
|
Renew <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{leases.map((lease) => {
|
||||||
|
const days = daysUntil(lease.lease_end)
|
||||||
|
const canRenew = (lease.status === "active" && days <= 60) || lease.status === "expired"
|
||||||
|
return (
|
||||||
|
<div key={lease.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">
|
||||||
|
{lease.tenant?.first_name} {lease.tenant?.last_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/40 mt-0.5">
|
||||||
|
{lease.property?.name} · Unit {lease.unit?.unit_number ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={cn("shrink-0 rounded-md border px-2 py-0.5 text-xs font-medium capitalize", statusColors[lease.status] ?? statusColors.active)}>
|
||||||
|
{lease.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-white/40">
|
||||||
|
<span>{formatDate(lease.lease_start)} → {formatDate(lease.lease_end)}</span>
|
||||||
|
<span className="font-semibold text-white">${lease.rent_amount}/mo</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-white/[0.04] pt-2">
|
||||||
|
<DaysChip days={days} status={lease.status} />
|
||||||
|
{canRenew && (
|
||||||
|
<Link
|
||||||
|
href={`/leases/new?tenant_id=${lease.tenant_id}&property_id=${lease.property_id}&unit_id=${lease.unit_id ?? ""}&rent_amount=${lease.rent_amount}`}
|
||||||
|
className="text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
|
||||||
|
>
|
||||||
|
Renew →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { maintenance_requests } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/utils"
|
||||||
|
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
|
||||||
|
import { MaintenanceStatusUpdater } from "@/components/forms/maintenance-status-updater"
|
||||||
|
|
||||||
|
export default async function MaintenanceDetailPage({ params }: { params: Promise<{ requestId: string }> }) {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const { requestId } = await params
|
||||||
|
|
||||||
|
const req = await db.query.maintenance_requests.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(maintenance_requests.id, requestId),
|
||||||
|
eq(maintenance_requests.user_id, user.id)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
property: { columns: { name: true, address_line1: true, city: true } },
|
||||||
|
unit: { columns: { unit_number: true } },
|
||||||
|
tenant: { columns: { first_name: true, last_name: true, email: true, phone: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!req) notFound()
|
||||||
|
const request = req as any
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-white/40">
|
||||||
|
<Link href="/maintenance" className="hover:text-white transition">Maintenance</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white/70 truncate">{request.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h2 className="text-xl font-bold text-white">{request.title}</h2>
|
||||||
|
<PriorityBadge priority={request.priority} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-white/40">
|
||||||
|
{request.property?.name}{request.unit ? ` · Unit ${request.unit.unit_number}` : ""} · Opened {formatDate(request.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<MaintenanceStatusBadge status={request.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main details */}
|
||||||
|
<div className="lg:col-span-2 space-y-5">
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||||
|
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-white/30">Description</h3>
|
||||||
|
<p className="text-sm text-white/80 whitespace-pre-wrap">{request.description}</p>
|
||||||
|
|
||||||
|
{request.resolution_notes && (
|
||||||
|
<>
|
||||||
|
<h3 className="mb-2 mt-5 text-xs font-medium uppercase tracking-wider text-white/30">Resolution Notes</h3>
|
||||||
|
<p className="text-sm text-white/80 whitespace-pre-wrap">{request.resolution_notes}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status updater */}
|
||||||
|
<MaintenanceStatusUpdater request={req} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Details card */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Details</h3>
|
||||||
|
<Row label="Category" value={request.category} />
|
||||||
|
<Row label="Priority" value={request.priority} />
|
||||||
|
<Row label="Status" value={request.status.replace("_", " ")} />
|
||||||
|
{request.assigned_to && <Row label="Assigned To" value={request.assigned_to} />}
|
||||||
|
{request.estimated_cost && <Row label="Est. Cost" value={formatCurrency(request.estimated_cost)} />}
|
||||||
|
{request.actual_cost && <Row label="Actual Cost" value={formatCurrency(request.actual_cost)} />}
|
||||||
|
{request.resolved_at && <Row label="Resolved" value={formatDate(request.resolved_at)} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tenant card */}
|
||||||
|
{request.tenant && (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Tenant</h3>
|
||||||
|
<p className="text-sm font-medium text-white">{request.tenant.first_name} {request.tenant.last_name}</p>
|
||||||
|
{request.tenant.email && <p className="text-xs text-white/40">{request.tenant.email}</p>}
|
||||||
|
{request.tenant.phone && <p className="text-xs text-white/40">{request.tenant.phone}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-white/40">{label}</span>
|
||||||
|
<span className="text-xs font-medium text-white capitalize">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { CardGridSkeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function MaintenanceLoading() {
|
||||||
|
return <CardGridSkeleton cards={6} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { formatDate } from "@/lib/utils"
|
||||||
|
import { Wrench, SlidersHorizontal } from "lucide-react"
|
||||||
|
import { Select } from "@/components/ui/select"
|
||||||
|
|
||||||
|
export function MaintenanceList({ requests, properties }: { requests: any[]; properties: any[] }) {
|
||||||
|
const [propertyId, setPropertyId] = useState("")
|
||||||
|
const [status, setStatus] = useState("")
|
||||||
|
|
||||||
|
const filtered = useMemo(() =>
|
||||||
|
requests.filter((r) => {
|
||||||
|
if (propertyId && r.property_id !== propertyId) return false
|
||||||
|
if (status && r.status !== status) return false
|
||||||
|
return true
|
||||||
|
}), [requests, propertyId, status])
|
||||||
|
|
||||||
|
const open = requests.filter((r) => r.status === "open").length
|
||||||
|
const inProgress = requests.filter((r) => r.status === "in_progress").length
|
||||||
|
const resolved = requests.filter((r) => r.status === "resolved").length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "Open", value: open, color: "text-amber-400", dot: "bg-amber-500" },
|
||||||
|
{ label: "In Progress", value: inProgress, color: "text-blue-400", dot: "bg-blue-500" },
|
||||||
|
{ label: "Resolved", value: resolved, color: "text-emerald-400", dot: "bg-emerald-500" },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${s.dot}`} />
|
||||||
|
<p className="text-xs text-white/35">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
<p className={`mt-1.5 text-2xl font-bold tabular-nums ${s.color}`}>{s.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<SlidersHorizontal className="h-3.5 w-3.5 text-white/25 shrink-0" />
|
||||||
|
<Select
|
||||||
|
value={propertyId}
|
||||||
|
onChange={setPropertyId}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All properties" },
|
||||||
|
...properties.map((p) => ({ value: p.id, label: p.name })),
|
||||||
|
]}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All statuses" },
|
||||||
|
{ value: "open", label: "Open" },
|
||||||
|
{ value: "in_progress", label: "In Progress" },
|
||||||
|
{ value: "resolved", label: "Resolved" },
|
||||||
|
{ value: "closed", label: "Closed" },
|
||||||
|
]}
|
||||||
|
className="w-36"
|
||||||
|
/>
|
||||||
|
{(propertyId || status) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setPropertyId(""); setStatus("") }}
|
||||||
|
className="text-xs text-white/35 hover:text-white/70 transition"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-white/25">{filtered.length} result{filtered.length !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filtered.length ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Wrench}
|
||||||
|
title="No maintenance requests"
|
||||||
|
description="Create requests to track and manage property maintenance issues."
|
||||||
|
action={{ label: "New request", href: "/maintenance/new" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((req: any) => (
|
||||||
|
<Link
|
||||||
|
key={req.id}
|
||||||
|
href={`/maintenance/${req.id}`}
|
||||||
|
className="group flex items-start gap-4 rounded-xl border border-white/[0.06] bg-[#16161f] p-4 transition-all duration-150 hover:border-indigo-500/25 hover:bg-[#1a1a2e] hover:shadow-lg hover:shadow-indigo-500/5"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-semibold text-white group-hover:text-indigo-100 transition-colors">{req.title}</p>
|
||||||
|
<PriorityBadge priority={req.priority} />
|
||||||
|
</div>
|
||||||
|
{req.description && (
|
||||||
|
<p className="mt-1 text-xs text-white/40 line-clamp-1">{req.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center gap-2 flex-wrap text-xs text-white/30">
|
||||||
|
{req.property?.name && <span>{req.property.name}</span>}
|
||||||
|
{req.unit && <span>· Unit {req.unit.unit_number}</span>}
|
||||||
|
{req.tenant && <span>· {req.tenant.first_name} {req.tenant.last_name}</span>}
|
||||||
|
<span>· {formatDate(req.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<MaintenanceStatusBadge status={req.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { properties, tenants } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { MaintenanceForm } from "@/components/forms/maintenance-form"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "New Maintenance Request" }
|
||||||
|
|
||||||
|
export default async function NewMaintenancePage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const [propertyList, tenantList] = await Promise.all([
|
||||||
|
db.query.properties.findMany({
|
||||||
|
where: eq(properties.user_id, user.id),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
with: {
|
||||||
|
units: { columns: { id: true, unit_number: true } },
|
||||||
|
},
|
||||||
|
orderBy: asc(properties.name),
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: tenants.id,
|
||||||
|
first_name: tenants.first_name,
|
||||||
|
last_name: tenants.last_name,
|
||||||
|
unit_id: tenants.unit_id,
|
||||||
|
})
|
||||||
|
.from(tenants)
|
||||||
|
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<BackButton href="/maintenance" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">New Maintenance Request</h2>
|
||||||
|
<p className="text-sm text-white/40">Log a maintenance issue for a property</p>
|
||||||
|
</div>
|
||||||
|
<MaintenanceForm properties={propertyList ?? []} tenants={tenantList ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { asc, desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { maintenance_requests, properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { MaintenanceList } from "./maintenance-list"
|
||||||
|
|
||||||
|
export const metadata = { title: "Maintenance" }
|
||||||
|
|
||||||
|
export default async function MaintenancePage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const [requests, propertyList] = await Promise.all([
|
||||||
|
db.query.maintenance_requests.findMany({
|
||||||
|
where: eq(maintenance_requests.user_id, user.id),
|
||||||
|
with: {
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
unit: { columns: { unit_number: true } },
|
||||||
|
tenant: { columns: { first_name: true, last_name: true } },
|
||||||
|
},
|
||||||
|
orderBy: desc(maintenance_requests.created_at),
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ id: properties.id, name: properties.name })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.user_id, user.id))
|
||||||
|
.orderBy(asc(properties.name)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Maintenance</h2>
|
||||||
|
<p className="text-sm text-white/40">{requests?.length ?? 0} total requests</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/maintenance/new" className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition">
|
||||||
|
<Plus className="h-4 w-4" /> New Request
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<MaintenanceList requests={requests ?? []} properties={propertyList ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { ai_predictions } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { PredictionsClient } from "./predictions-client"
|
||||||
|
|
||||||
|
export const metadata = { title: "Predictive Analytics" }
|
||||||
|
|
||||||
|
export default async function PredictionsPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const predictions = await db
|
||||||
|
.select()
|
||||||
|
.from(ai_predictions)
|
||||||
|
.where(eq(ai_predictions.user_id, user.id))
|
||||||
|
.orderBy(desc(ai_predictions.created_at))
|
||||||
|
|
||||||
|
return <PredictionsClient predictions={predictions ?? []} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
TrendingUp, TrendingDown, AlertTriangle, ShieldAlert,
|
||||||
|
Wrench, Home, Zap, RefreshCw, Loader2, BarChart3,
|
||||||
|
ArrowUpRight, ArrowDownRight,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
|
const typeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; border: string }> = {
|
||||||
|
revenue_forecast: { icon: TrendingUp, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
||||||
|
occupancy_forecast: { icon: Home, color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20" },
|
||||||
|
cash_flow_risk: { icon: TrendingDown, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
|
||||||
|
tenant_risk: { icon: AlertTriangle, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
|
||||||
|
maintenance_risk: { icon: Wrench, color: "text-orange-400", bg: "bg-orange-500/10", border: "border-orange-500/20" },
|
||||||
|
vacancy_risk: { icon: ShieldAlert, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
|
||||||
|
growth_opportunity: { icon: Zap, color: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const riskBadge: Record<string, string> = {
|
||||||
|
critical: "text-red-400 bg-red-500/10 ring-red-500/20",
|
||||||
|
high: "text-orange-400 bg-orange-500/10 ring-orange-500/20",
|
||||||
|
medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20",
|
||||||
|
low: "text-emerald-400 bg-emerald-500/10 ring-emerald-500/20",
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidenceBadge: Record<string, string> = {
|
||||||
|
high: "text-emerald-400",
|
||||||
|
medium: "text-amber-400",
|
||||||
|
low: "text-white/30",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PredictionsClient({ predictions: initial }: { predictions: any[] }) {
|
||||||
|
const [predictions, setPredictions] = useState(initial)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [filter, setFilter] = useState("")
|
||||||
|
|
||||||
|
const riskItems = predictions.filter((p) => ["critical", "high"].includes(p.risk_level))
|
||||||
|
const filtered = filter ? predictions.filter((p) => p.type === filter) : predictions
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
setGenerating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/predictions", { method: "POST" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed to generate"); return }
|
||||||
|
setPredictions(data)
|
||||||
|
toast.success(`${data.length} predictions generated`)
|
||||||
|
setFilter("")
|
||||||
|
} catch {
|
||||||
|
toast.error("Network error — please try again")
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-blue-400" />
|
||||||
|
Predictive Analytics
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
|
AI-powered forecasts and risk alerts based on your portfolio trends
|
||||||
|
{riskItems.length > 0 && (
|
||||||
|
<span className="ml-2 text-red-400">{riskItems.length} risk alert{riskItems.length > 1 ? "s" : ""}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={generate}
|
||||||
|
disabled={generating}
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
{generating ? "Analyzing…" : "Run Analysis"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk alerts banner */}
|
||||||
|
{riskItems.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ShieldAlert className="h-4 w-4 text-red-400" />
|
||||||
|
<p className="text-sm font-semibold text-red-400">{riskItems.length} Active Risk Alert{riskItems.length > 1 ? "s" : ""}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{riskItems.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center gap-2">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset ${riskBadge[r.risk_level]}`}>
|
||||||
|
{r.risk_level}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-white/70">{r.title}</span>
|
||||||
|
<span className="text-xs text-white/30">{r.timeframe}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
{predictions.length > 0 && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter("")}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${!filter ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
All ({predictions.length})
|
||||||
|
</button>
|
||||||
|
{["revenue_forecast", "occupancy_forecast", "cash_flow_risk", "vacancy_risk", "growth_opportunity"].map((t) => {
|
||||||
|
const count = predictions.filter((p) => p.type === t).length
|
||||||
|
if (!count) return null
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setFilter(t)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium capitalize transition ${filter === t ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
{t.replace(/_/g, " ")} ({count})
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-16 text-center">
|
||||||
|
<BarChart3 className="h-8 w-8 text-white/10 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-white/30">No predictions yet</p>
|
||||||
|
<p className="text-xs text-white/20 mt-1">Click "Run Analysis" to generate AI-powered forecasts</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
{filtered.map((pred) => {
|
||||||
|
const cfg = typeConfig[pred.type] ?? typeConfig.growth_opportunity
|
||||||
|
const Icon = cfg.icon
|
||||||
|
const changePercent = pred.data?.change_percent ?? 0
|
||||||
|
const isPositive = changePercent >= 0
|
||||||
|
const isRisk = ["cash_flow_risk", "tenant_risk", "maintenance_risk", "vacancy_risk"].includes(pred.type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={pred.id} className={`rounded-xl border ${cfg.border} bg-[#16161f] p-5`}>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${cfg.bg}`}>
|
||||||
|
<Icon className={`h-4 w-4 ${cfg.color}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{pred.title}</p>
|
||||||
|
<p className="text-xs text-white/30">{pred.timeframe}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset shrink-0 ${riskBadge[pred.risk_level] ?? riskBadge.low}`}>
|
||||||
|
{pred.risk_level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-white/50 leading-relaxed mb-3">{pred.prediction}</p>
|
||||||
|
|
||||||
|
{pred.data?.metric && (
|
||||||
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.06] p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/30">{pred.data.metric}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-sm text-white/50">
|
||||||
|
{typeof pred.data.current_value === "number" && pred.data.metric?.toLowerCase().includes("revenue")
|
||||||
|
? formatCurrency(pred.data.current_value)
|
||||||
|
: `${pred.data.current_value}${pred.data.metric?.includes("%") || pred.data.metric?.toLowerCase().includes("rate") ? "%" : ""}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/20">→</span>
|
||||||
|
<span className="text-sm font-semibold text-white">
|
||||||
|
{typeof pred.data.predicted_value === "number" && pred.data.metric?.toLowerCase().includes("revenue")
|
||||||
|
? formatCurrency(pred.data.predicted_value)
|
||||||
|
: `${pred.data.predicted_value}${pred.data.metric?.includes("%") || pred.data.metric?.toLowerCase().includes("rate") ? "%" : ""}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{changePercent !== 0 && (
|
||||||
|
<div className={`flex items-center gap-1 text-sm font-semibold ${
|
||||||
|
isRisk
|
||||||
|
? (isPositive ? "text-red-400" : "text-emerald-400")
|
||||||
|
: (isPositive ? "text-emerald-400" : "text-red-400")
|
||||||
|
}`}>
|
||||||
|
{isPositive
|
||||||
|
? <ArrowUpRight className="h-4 w-4" />
|
||||||
|
: <ArrowDownRight className="h-4 w-4" />}
|
||||||
|
{Math.abs(changePercent)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 mt-3">
|
||||||
|
<span className="text-xs text-white/25">Confidence:</span>
|
||||||
|
<span className={`text-xs font-medium capitalize ${confidenceBadge[pred.confidence] ?? confidenceBadge.medium}`}>
|
||||||
|
{pred.confidence}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { FileText, Download, Trash2, Loader2 } from "lucide-react"
|
||||||
|
import { FileUpload } from "@/components/shared/file-upload"
|
||||||
|
import { formatDate } from "@/lib/utils"
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const propertyId = params.propertyId as string
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [docs, setDocs] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null)
|
||||||
|
const [propertyName, setPropertyName] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/documents?property_id=${propertyId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setDocs(data.documents ?? [])
|
||||||
|
setPropertyName(data.propertyName ?? "")
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [propertyId])
|
||||||
|
|
||||||
|
async function deleteDoc(id: string) {
|
||||||
|
setDeleting(id)
|
||||||
|
await fetch(`/api/documents/${id}`, { method: "DELETE" })
|
||||||
|
setDocs((prev) => prev.filter((d) => d.id !== id))
|
||||||
|
setDeleting(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-white/40 mb-1">
|
||||||
|
<Link href="/properties" className="hover:text-white transition">Properties</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/properties/${propertyId}`} className="hover:text-white transition">{propertyName || propertyId}</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white/70">Documents</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Documents</h2>
|
||||||
|
<p className="text-sm text-white/40">{docs.length} file{docs.length !== 1 ? "s" : ""}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileUpload
|
||||||
|
propertyId={propertyId}
|
||||||
|
onUploaded={(doc) => setDocs((prev) => [doc, ...prev])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-white/30" />
|
||||||
|
</div>
|
||||||
|
) : docs.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-white/[0.06] bg-[#16161f] py-16 text-center">
|
||||||
|
<FileText className="h-10 w-10 text-white/10 mb-3" />
|
||||||
|
<p className="text-sm text-white/40">No documents yet</p>
|
||||||
|
<p className="text-xs text-white/25 mt-1">Upload leases, insurance, or any property files</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<div key={doc.id} className="flex items-center gap-4 px-5 py-4">
|
||||||
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||||
|
<FileText className="h-4 w-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-white">{doc.name}</p>
|
||||||
|
<p className="text-xs text-white/40">
|
||||||
|
{formatBytes(doc.file_size ?? 0)} · {formatDate(doc.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={doc.file_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 text-white/50 hover:text-white transition"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteDoc(doc.id)}
|
||||||
|
disabled={deleting === doc.id}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 text-white/50 hover:text-red-400 hover:border-red-500/30 transition disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{deleting === doc.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { PropertyForm } from "@/components/forms/property-form"
|
||||||
|
|
||||||
|
export default async function EditPropertyPage({ params }: { params: Promise<{ propertyId: string }> }) {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const { propertyId } = await params
|
||||||
|
const property = await db.query.properties.findFirst({
|
||||||
|
where: and(eq(properties.id, propertyId), eq(properties.user_id, user.id)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!property) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Edit Property</h2>
|
||||||
|
<p className="text-sm text-white/40">{property.name}</p>
|
||||||
|
</div>
|
||||||
|
<PropertyForm property={property} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation"
|
||||||
|
import { and, eq, gte } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { properties as propertiesTable, rent_payments, expenses as expensesTable } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { MapPin, Plus, BedDouble, Bath, Edit } from "lucide-react"
|
||||||
|
import { formatCurrency, getOccupancyRate } from "@/lib/utils"
|
||||||
|
import { DeletePropertyButton } from "@/components/forms/delete-property-button"
|
||||||
|
import { AiMaintenanceSummary } from "@/components/forms/ai-maintenance-summary"
|
||||||
|
import { PropertyRevenueChart } from "@/components/dashboard/property-revenue-chart"
|
||||||
|
import { PropertyPhotoUpload } from "@/components/forms/property-photo-upload"
|
||||||
|
|
||||||
|
export default async function PropertyDetailPage({ params }: { params: Promise<{ propertyId: string }> }) {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const { propertyId } = await params
|
||||||
|
|
||||||
|
const sixMonthsAgo = new Date()
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
|
||||||
|
sixMonthsAgo.setDate(1)
|
||||||
|
const rangeStart = sixMonthsAgo.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const [property, payments, expenses] = await Promise.all([
|
||||||
|
db.query.properties.findFirst({
|
||||||
|
where: and(eq(propertiesTable.id, propertyId), eq(propertiesTable.user_id, user.id)),
|
||||||
|
with: {
|
||||||
|
units: {
|
||||||
|
with: {
|
||||||
|
current_tenant: { columns: { first_name: true, last_name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ amount: rent_payments.amount, status: rent_payments.status, due_date: rent_payments.due_date })
|
||||||
|
.from(rent_payments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rent_payments.user_id, user.id),
|
||||||
|
eq(rent_payments.property_id, propertyId),
|
||||||
|
gte(rent_payments.due_date, rangeStart)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({ amount: expensesTable.amount, expense_date: expensesTable.expense_date })
|
||||||
|
.from(expensesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(expensesTable.user_id, user.id),
|
||||||
|
eq(expensesTable.property_id, propertyId),
|
||||||
|
gte(expensesTable.expense_date, rangeStart)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!property) notFound()
|
||||||
|
|
||||||
|
// Build 6-month chart data
|
||||||
|
const chartData = Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const d = new Date(); d.setMonth(d.getMonth() - (5 - i)); d.setDate(1)
|
||||||
|
const key = d.toISOString().slice(0, 7)
|
||||||
|
const label = d.toLocaleDateString("en-US", { month: "short" })
|
||||||
|
const revenue = (payments ?? []).filter(p => p.status === "paid" && p.due_date?.startsWith(key)).reduce((s, p) => s + Number(p.amount), 0)
|
||||||
|
const expense = (expenses ?? []).filter(e => e.expense_date?.startsWith(key)).reduce((s, e) => s + Number(e.amount), 0)
|
||||||
|
return { label, revenue, expense }
|
||||||
|
})
|
||||||
|
|
||||||
|
const units = property.units ?? []
|
||||||
|
const occupied = units.filter((u: any) => u.status === "occupied").length
|
||||||
|
const occupancy = getOccupancyRate(occupied, units.length)
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
occupied: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
|
||||||
|
vacant: "text-white/50 bg-white/5 border-white/10",
|
||||||
|
maintenance: "text-amber-400 bg-amber-500/10 border-amber-500/20",
|
||||||
|
unavailable: "text-red-400 bg-red-500/10 border-red-500/20",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-white/40 mb-1">
|
||||||
|
<Link href="/properties" className="hover:text-white transition">Properties</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white/70">{property.name}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">{property.name}</h2>
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-sm text-white/40">
|
||||||
|
<MapPin className="h-3.5 w-3.5" />
|
||||||
|
{property.address_line1}, {property.city}{property.state ? `, ${property.state}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AiMaintenanceSummary propertyId={propertyId} />
|
||||||
|
<Link
|
||||||
|
href={`/properties/${propertyId}/edit`}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/70 hover:border-white/20 hover:text-white transition"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" /> Edit
|
||||||
|
</Link>
|
||||||
|
<DeletePropertyButton propertyId={propertyId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property Photo */}
|
||||||
|
<PropertyPhotoUpload propertyId={propertyId} currentImageUrl={property.image_url} />
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{[
|
||||||
|
{ label: "Total Units", value: units.length },
|
||||||
|
{ label: "Occupied", value: occupied },
|
||||||
|
{ label: "Vacant", value: units.length - occupied },
|
||||||
|
{ label: "Occupancy", value: `${occupancy}%` },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
<p className="text-xs text-white/40">{s.label}</p>
|
||||||
|
<p className="mt-1 text-xl font-bold text-white">{s.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Units */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Units</h3>
|
||||||
|
<Link
|
||||||
|
href={`/properties/${propertyId}/units/new`}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 transition"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Add Unit
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{units.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-sm text-white/30">
|
||||||
|
No units yet. Add your first unit.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{units.map((unit: any) => (
|
||||||
|
<div key={unit.id} className="flex items-center justify-between px-5 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-sm font-bold text-white">
|
||||||
|
{unit.unit_number}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-white">Unit {unit.unit_number}</span>
|
||||||
|
<span className={`rounded-full border px-2 py-0.5 text-xs font-medium ${statusColors[unit.status] ?? statusColors.vacant}`}>
|
||||||
|
{unit.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-3 text-xs text-white/40">
|
||||||
|
<span className="flex items-center gap-1"><BedDouble className="h-3 w-3" />{unit.bedrooms} bed</span>
|
||||||
|
<span className="flex items-center gap-1"><Bath className="h-3 w-3" />{unit.bathrooms} bath</span>
|
||||||
|
{unit.sq_ft && <span>{unit.sq_ft} sqft</span>}
|
||||||
|
</div>
|
||||||
|
{unit.current_tenant && (
|
||||||
|
<p className="mt-0.5 text-xs text-indigo-400">
|
||||||
|
{unit.current_tenant.first_name} {unit.current_tenant.last_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold text-white">{formatCurrency(unit.rent_amount)}<span className="text-xs text-white/40">/mo</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenue chart */}
|
||||||
|
<PropertyRevenueChart data={chartData} />
|
||||||
|
|
||||||
|
{/* Quick links */}
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
{[
|
||||||
|
{ label: "View Tenants", href: `/tenants?property=${propertyId}` },
|
||||||
|
{ label: "Maintenance", href: `/maintenance?property=${propertyId}` },
|
||||||
|
{ label: "Expenses", href: `/expenses?property=${propertyId}` },
|
||||||
|
{ label: "Documents", href: `/properties/${propertyId}/documents` },
|
||||||
|
].map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
import { UnitForm } from "@/components/forms/unit-form"
|
||||||
|
|
||||||
|
export const metadata = { title: "Add Unit" }
|
||||||
|
|
||||||
|
export default async function NewUnitPage({ params }: { params: Promise<{ propertyId: string }> }) {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const { propertyId } = await params
|
||||||
|
|
||||||
|
const property = await db.query.properties.findFirst({
|
||||||
|
where: and(eq(properties.id, propertyId), eq(properties.user_id, user.id)),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!property) redirect("/properties")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<BackButton href={`/properties/${propertyId}`} />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Add Unit</h2>
|
||||||
|
<p className="text-sm text-white/40">{property.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||||
|
<UnitForm propertyId={propertyId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { CardGridSkeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function PropertiesLoading() {
|
||||||
|
return <CardGridSkeleton cards={6} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { PropertyForm } from "@/components/forms/property-form"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "Add Property" }
|
||||||
|
|
||||||
|
export default async function NewPropertyPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<BackButton href="/properties" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Add Property</h2>
|
||||||
|
<p className="text-sm text-white/40">Fill in the details for your rental property</p>
|
||||||
|
</div>
|
||||||
|
<PropertyForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { eq, desc } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { properties as propertiesTable } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Building2, MapPin, BedDouble, ArrowRight, TrendingUp } from "lucide-react"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { formatCurrency, getOccupancyRate } from "@/lib/utils"
|
||||||
|
|
||||||
|
export const metadata = { title: "Properties" }
|
||||||
|
|
||||||
|
export default async function PropertiesPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const properties = await db.query.properties.findMany({
|
||||||
|
where: eq(propertiesTable.user_id, user.id),
|
||||||
|
with: {
|
||||||
|
units: { columns: { id: true, status: true, rent_amount: true } },
|
||||||
|
},
|
||||||
|
orderBy: desc(propertiesTable.created_at),
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalMonthly = (properties ?? []).reduce((sum: number, p: any) => {
|
||||||
|
return sum + (p.units ?? []).filter((u: any) => u.status === "occupied").reduce((s: number, u: any) => s + Number(u.rent_amount), 0)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Properties</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
|
{properties?.length ?? 0} {(properties?.length ?? 0) === 1 ? "property" : "properties"}
|
||||||
|
{totalMonthly > 0 && <span className="ml-2 text-emerald-400">· {formatCurrency(totalMonthly)}/mo</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!properties?.length ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Building2}
|
||||||
|
title="No properties yet"
|
||||||
|
description="Add your first rental property to start managing tenants, rent, and maintenance."
|
||||||
|
action={{ label: "Add property", href: "/properties/new" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{(properties as any[]).map((property) => {
|
||||||
|
const units = property.units ?? []
|
||||||
|
const occupied = units.filter((u: any) => u.status === "occupied").length
|
||||||
|
const totalUnits = units.length
|
||||||
|
const occupancy = getOccupancyRate(occupied, totalUnits)
|
||||||
|
const monthlyRent = units
|
||||||
|
.filter((u: any) => u.status === "occupied")
|
||||||
|
.reduce((sum: number, u: any) => sum + Number(u.rent_amount), 0)
|
||||||
|
const potential = units.reduce((sum: number, u: any) => sum + Number(u.rent_amount), 0)
|
||||||
|
|
||||||
|
const occupancyColor =
|
||||||
|
occupancy === 100 ? "text-emerald-400" :
|
||||||
|
occupancy >= 50 ? "text-amber-400" :
|
||||||
|
"text-red-400"
|
||||||
|
|
||||||
|
const barColor =
|
||||||
|
occupancy === 100 ? "bg-emerald-500" :
|
||||||
|
occupancy >= 50 ? "bg-amber-500" :
|
||||||
|
"bg-red-500"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={property.id}
|
||||||
|
href={`/properties/${property.id}`}
|
||||||
|
className="group relative rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 transition-all duration-200 hover:border-indigo-500/30 hover:bg-[#1a1a2e] hover:shadow-xl hover:shadow-indigo-500/5"
|
||||||
|
>
|
||||||
|
{/* Top row */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-indigo-500/10 ring-1 ring-inset ring-indigo-500/20">
|
||||||
|
<Building2 className="h-5 w-5 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||||
|
occupancy === 100 ? "bg-emerald-500/10 text-emerald-400 ring-1 ring-emerald-500/20" :
|
||||||
|
occupancy >= 50 ? "bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20" :
|
||||||
|
"bg-red-500/10 text-red-400 ring-1 ring-red-500/20"
|
||||||
|
}`}>
|
||||||
|
{occupancy}% full
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + address */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white group-hover:text-indigo-200 transition-colors">{property.name}</h3>
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-xs text-white/40">
|
||||||
|
<MapPin className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{property.address_line1 ? `${property.address_line1}, ` : ""}{property.city}{property.state ? `, ${property.state}` : ""}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Occupancy bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="h-1 w-full rounded-full bg-white/[0.06]">
|
||||||
|
<div
|
||||||
|
className={`h-1 rounded-full transition-all ${barColor}`}
|
||||||
|
style={{ width: `${Math.max(occupancy, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[10px] text-white/30">
|
||||||
|
<span>{occupied} of {totalUnits} units occupied</span>
|
||||||
|
<span>{totalUnits - occupied} vacant</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-white/[0.06] pt-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/30">Collected / mo</p>
|
||||||
|
<p className="mt-0.5 text-sm font-semibold text-white">{formatCurrency(monthlyRent)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/30">Potential / mo</p>
|
||||||
|
<p className="mt-0.5 text-sm font-semibold text-white/60">{formatCurrency(potential)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover arrow */}
|
||||||
|
<div className="mt-3 flex items-center gap-1 text-xs text-indigo-400/0 group-hover:text-indigo-400/80 transition-all">
|
||||||
|
<span>View details</span>
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { ai_recommendations } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { RecommendationsClient } from "./recommendations-client"
|
||||||
|
|
||||||
|
export const metadata = { title: "AI Recommendations" }
|
||||||
|
|
||||||
|
export default async function RecommendationsPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const recommendations = await db
|
||||||
|
.select()
|
||||||
|
.from(ai_recommendations)
|
||||||
|
.where(eq(ai_recommendations.user_id, user.id))
|
||||||
|
.orderBy(desc(ai_recommendations.created_at))
|
||||||
|
|
||||||
|
return <RecommendationsClient recommendations={recommendations ?? []} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
Zap, TrendingUp, AlertTriangle, Wrench, FileText,
|
||||||
|
DollarSign, RefreshCw, CheckCircle, XCircle, Loader2,
|
||||||
|
Lightbulb, ShieldAlert,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const typeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; border: string }> = {
|
||||||
|
rent_increase: { icon: TrendingUp, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
||||||
|
vacancy_alert: { icon: AlertTriangle, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
|
||||||
|
maintenance_urgent: { icon: Wrench, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
|
||||||
|
lease_renewal: { icon: FileText, color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20" },
|
||||||
|
expense_alert: { icon: DollarSign, color: "text-orange-400", bg: "bg-orange-500/10", border: "border-orange-500/20" },
|
||||||
|
cash_flow: { icon: TrendingUp, color: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
|
||||||
|
risk_alert: { icon: ShieldAlert, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
|
||||||
|
opportunity: { icon: Lightbulb, color: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityBadge: Record<string, string> = {
|
||||||
|
high: "text-red-400 bg-red-500/10 ring-red-500/20",
|
||||||
|
medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20",
|
||||||
|
low: "text-white/40 bg-white/5 ring-white/10",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecommendationsClient({ recommendations: initial }: { recommendations: any[] }) {
|
||||||
|
const [recs, setRecs] = useState(initial)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
|
const [filter, setFilter] = useState<"pending" | "approved" | "dismissed">("pending")
|
||||||
|
|
||||||
|
const filtered = recs.filter((r) => r.status === filter)
|
||||||
|
const pendingCount = recs.filter((r) => r.status === "pending").length
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
setGenerating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/recommendations", { method: "POST" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed to generate"); return }
|
||||||
|
setRecs((prev) => [...data, ...prev.filter((r) => r.status !== "pending")])
|
||||||
|
toast.success(`${data.length} recommendations generated`)
|
||||||
|
setFilter("pending")
|
||||||
|
} catch {
|
||||||
|
toast.error("Network error — please try again")
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(id: string, status: "approved" | "dismissed") {
|
||||||
|
setActionLoading(id + status)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ai/recommendations/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed to update"); return }
|
||||||
|
setRecs((prev) => prev.map((r) => r.id === id ? data : r))
|
||||||
|
toast.success(status === "approved" ? "Recommendation approved!" : "Dismissed")
|
||||||
|
} catch {
|
||||||
|
toast.error("Network error — please try again")
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Zap className="h-5 w-5 text-violet-400" />
|
||||||
|
AI Recommendations
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
|
Smart suggestions based on your portfolio data
|
||||||
|
{pendingCount > 0 && <span className="ml-2 text-violet-400">{pendingCount} pending</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={generate}
|
||||||
|
disabled={generating}
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-violet-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-violet-500 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
{generating ? "Analyzing…" : "Generate New"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-1 rounded-xl border border-white/[0.06] bg-[#16161f] p-1 w-fit">
|
||||||
|
{(["pending", "approved", "dismissed"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilter(s)}
|
||||||
|
className={`rounded-lg px-4 py-1.5 text-xs font-medium capitalize transition ${
|
||||||
|
filter === s ? "bg-white/10 text-white" : "text-white/40 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s} ({recs.filter((r) => r.status === s).length})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-16 text-center">
|
||||||
|
<Zap className="h-8 w-8 text-white/10 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-white/30">
|
||||||
|
{filter === "pending" ? "No pending recommendations" : `No ${filter} recommendations`}
|
||||||
|
</p>
|
||||||
|
{filter === "pending" && (
|
||||||
|
<p className="text-xs text-white/20 mt-1">Click "Generate New" to analyze your portfolio</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((rec) => {
|
||||||
|
const cfg = typeConfig[rec.type] ?? typeConfig.opportunity
|
||||||
|
const Icon = cfg.icon
|
||||||
|
return (
|
||||||
|
<div key={rec.id} className={`rounded-xl border ${cfg.border} bg-[#16161f] p-5`}>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${cfg.bg}`}>
|
||||||
|
<Icon className={`h-5 w-5 ${cfg.color}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-semibold text-white">{rec.title}</p>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset ${priorityBadge[rec.priority] ?? priorityBadge.low}`}>
|
||||||
|
{rec.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/50 mt-1.5 leading-relaxed">{rec.description}</p>
|
||||||
|
{rec.impact && (
|
||||||
|
<p className={`text-xs font-medium mt-2 ${cfg.color}`}>↗ {rec.impact}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rec.status === "pending" && (
|
||||||
|
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-white/[0.06]">
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(rec.id, "approved")}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{actionLoading === rec.id + "approved"
|
||||||
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
: <CheckCircle className="h-3 w-3" />}
|
||||||
|
{rec.action_label ?? "Apply"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(rec.id, "dismissed")}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-white/40 hover:text-white disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{actionLoading === rec.id + "dismissed"
|
||||||
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
: <XCircle className="h-3 w-3" />}
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rec.status === "approved" && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-4 pt-4 border-t border-white/[0.06]">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
<span className="text-xs text-emerald-400">Approved</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rec.status === "dismissed" && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-4 pt-4 border-t border-white/[0.06]">
|
||||||
|
<XCircle className="h-3.5 w-3.5 text-white/20" />
|
||||||
|
<span className="text-xs text-white/30">Dismissed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Zap, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight, Users } from "lucide-react"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
|
function monthLabel(year: number, month: number) {
|
||||||
|
return new Date(year, month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkGenerateForm({ leases }: { leases: any[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const now = new Date()
|
||||||
|
const [year, setYear] = useState(now.getFullYear())
|
||||||
|
const [month, setMonth] = useState(now.getMonth())
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [result, setResult] = useState<{ created: number; skipped: number; message: string } | null>(null)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (month === 0) { setMonth(11); setYear(y => y - 1) } else setMonth(m => m - 1)
|
||||||
|
}
|
||||||
|
function nextMonth() {
|
||||||
|
if (month === 11) { setMonth(0); setYear(y => y + 1) } else setMonth(m => m + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRent = leases.reduce((s, l) => s + Number(l.rent_amount), 0)
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
setLoading(true)
|
||||||
|
setError("")
|
||||||
|
setResult(null)
|
||||||
|
const res = await fetch("/api/rent/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ year, month }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) { setError(data.error ?? "Something went wrong"); return }
|
||||||
|
setResult(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Month picker */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-white/30 mb-4">Select Month</p>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<button onClick={prevMonth} className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[180px] text-center text-lg font-bold text-white">
|
||||||
|
{monthLabel(year, month)}
|
||||||
|
</span>
|
||||||
|
<button onClick={nextMonth} className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-indigo-400" />
|
||||||
|
<p className="text-sm font-semibold text-white">Active Tenants ({leases.length})</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-emerald-400">{formatCurrency(totalRent)}<span className="text-xs text-white/30">/mo total</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leases.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-sm text-white/30">
|
||||||
|
No active leases found. Add leases first.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{leases.map((l: any) => (
|
||||||
|
<div key={l.id} className="flex items-center justify-between px-5 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{l.tenant?.first_name} {l.tenant?.last_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/35">
|
||||||
|
{l.property?.name}{l.unit ? ` · Unit ${l.unit.unit_number}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-white">{formatCurrency(l.rent_amount)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{result && (
|
||||||
|
<div className={`rounded-xl border px-4 py-3 flex items-start gap-3 ${
|
||||||
|
result.created > 0
|
||||||
|
? "border-emerald-500/20 bg-emerald-500/5"
|
||||||
|
: "border-amber-500/20 bg-amber-500/5"
|
||||||
|
}`}>
|
||||||
|
<CheckCircle2 className={`h-5 w-5 shrink-0 mt-0.5 ${result.created > 0 ? "text-emerald-400" : "text-amber-400"}`} />
|
||||||
|
<div>
|
||||||
|
<p className={`text-sm font-semibold ${result.created > 0 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||||
|
{result.message}
|
||||||
|
</p>
|
||||||
|
{result.skipped > 0 && (
|
||||||
|
<p className="text-xs text-white/40 mt-0.5">{result.skipped} already existed and were skipped.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-400 shrink-0" />
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/rent")}
|
||||||
|
className="rounded-xl border border-white/10 px-5 py-2.5 text-sm text-white/50 hover:text-white transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={generate}
|
||||||
|
disabled={loading || leases.length === 0}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition hover:shadow-lg hover:shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="animate-pulse">Generating…</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
Generate {leases.length} Payment{leases.length !== 1 ? "s" : ""} for {monthLabel(year, month)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(result?.created ?? 0) > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/rent")}
|
||||||
|
className="w-full text-center text-sm text-indigo-400 hover:text-indigo-300 transition"
|
||||||
|
>
|
||||||
|
View Rent Tracker →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { leases as leasesTable } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { BulkGenerateForm } from "./bulk-generate-form"
|
||||||
|
|
||||||
|
export const metadata = { title: "Generate Rent" }
|
||||||
|
|
||||||
|
export default async function GenerateRentPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const leases = await db.query.leases.findMany({
|
||||||
|
where: and(eq(leasesTable.user_id, user.id), eq(leasesTable.status, "active")),
|
||||||
|
columns: { id: true, rent_amount: true },
|
||||||
|
with: {
|
||||||
|
tenant: { columns: { first_name: true, last_name: true } },
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
unit: { columns: { unit_number: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Generate Monthly Rent</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
|
Create pending rent payments for all active tenants in one click
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BulkGenerateForm leases={leases ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { tenants as tenantsTable, properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
import { RentCsvImport } from "./rent-csv-import"
|
||||||
|
|
||||||
|
export const metadata = { title: "Import Rent Payments" }
|
||||||
|
|
||||||
|
export default async function ImportRentPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const tenants = await db.query.tenants.findMany({
|
||||||
|
where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")),
|
||||||
|
columns: { id: true, first_name: true, last_name: true },
|
||||||
|
with: {
|
||||||
|
unit: { columns: { unit_number: true } },
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const properties_ = await db
|
||||||
|
.select({ id: properties.id, name: properties.name })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.user_id, user.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<BackButton href="/rent" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Import Rent Payments</h2>
|
||||||
|
<p className="text-sm text-white/40">Upload a CSV file to add multiple rent payments at once</p>
|
||||||
|
</div>
|
||||||
|
<RentCsvImport tenants={tenants ?? []} properties={properties_ ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Upload, FileText, CheckCircle, XCircle, Download } from "lucide-react"
|
||||||
|
|
||||||
|
interface Tenant { id: string; first_name: string; last_name: string; unit?: any; property?: any }
|
||||||
|
interface Property { id: string; name: string }
|
||||||
|
|
||||||
|
interface ParsedRow {
|
||||||
|
tenant_id: string
|
||||||
|
property_id: string
|
||||||
|
amount: number
|
||||||
|
due_date: string
|
||||||
|
status: string
|
||||||
|
payment_method?: string
|
||||||
|
notes?: string
|
||||||
|
_tenantName?: string
|
||||||
|
_error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_CSV = `tenant_id,property_id,amount,due_date,status,payment_method,notes
|
||||||
|
TENANT_UUID_HERE,PROPERTY_UUID_HERE,1500.00,2026-05-01,pending,,
|
||||||
|
TENANT_UUID_HERE,PROPERTY_UUID_HERE,1500.00,2026-04-01,paid,bank_transfer,April rent`
|
||||||
|
|
||||||
|
export function RentCsvImport({ tenants, properties }: { tenants: Tenant[]; properties: Property[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [rows, setRows] = useState<ParsedRow[]>([])
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
function downloadSample() {
|
||||||
|
const blob = new Blob([SAMPLE_CSV], { type: "text/csv" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url; a.download = "rent-import-sample.csv"; a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const { parse } = await import("papaparse")
|
||||||
|
parse(file, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
complete: (result) => {
|
||||||
|
const parsed: ParsedRow[] = (result.data as any[]).map((row) => {
|
||||||
|
const tenant = tenants.find((t) => t.id === row.tenant_id?.trim())
|
||||||
|
const amount = parseFloat(row.amount)
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!tenant) errors.push("tenant not found")
|
||||||
|
if (isNaN(amount) || amount <= 0) errors.push("invalid amount")
|
||||||
|
if (!row.due_date?.trim()) errors.push("missing due_date")
|
||||||
|
if (!row.property_id?.trim()) errors.push("missing property_id")
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant_id: row.tenant_id?.trim(),
|
||||||
|
property_id: row.property_id?.trim(),
|
||||||
|
amount,
|
||||||
|
due_date: row.due_date?.trim(),
|
||||||
|
status: row.status?.trim() || "pending",
|
||||||
|
payment_method: row.payment_method?.trim() || undefined,
|
||||||
|
notes: row.notes?.trim() || undefined,
|
||||||
|
_tenantName: tenant ? `${tenant.first_name} ${tenant.last_name}` : row.tenant_id,
|
||||||
|
_error: errors.length ? errors.join(", ") : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setRows(parsed)
|
||||||
|
setDone(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
const valid = rows.filter((r) => !r._error)
|
||||||
|
if (!valid.length) { toast.error("No valid rows to import"); return }
|
||||||
|
|
||||||
|
setImporting(true)
|
||||||
|
let success = 0, failed = 0
|
||||||
|
|
||||||
|
for (const row of valid) {
|
||||||
|
const res = await fetch("/api/rent", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: row.tenant_id,
|
||||||
|
property_id: row.property_id,
|
||||||
|
amount: row.amount,
|
||||||
|
due_date: row.due_date,
|
||||||
|
status: row.status,
|
||||||
|
payment_method: row.payment_method,
|
||||||
|
notes: row.notes,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
res.ok ? success++ : failed++
|
||||||
|
}
|
||||||
|
|
||||||
|
setImporting(false)
|
||||||
|
setDone(true)
|
||||||
|
toast.success(`Imported ${success} payment${success !== 1 ? "s" : ""}${failed ? `, ${failed} failed` : ""}`)
|
||||||
|
if (success > 0) router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCount = rows.filter((r) => !r._error).length
|
||||||
|
const invalidCount = rows.filter((r) => !!r._error).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-white">How it works</h3>
|
||||||
|
<ol className="space-y-1.5 text-sm text-white/50 list-decimal list-inside">
|
||||||
|
<li>Download the sample CSV template</li>
|
||||||
|
<li>Fill in tenant_id and property_id from your dashboard</li>
|
||||||
|
<li>Upload the filled CSV file</li>
|
||||||
|
<li>Review rows and click Import</li>
|
||||||
|
</ol>
|
||||||
|
<button
|
||||||
|
onClick={downloadSample}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-white/60 hover:border-white/20 hover:text-white transition"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" /> Download Sample CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload */}
|
||||||
|
<div
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed border-white/10 bg-white/[0.02] p-10 hover:border-indigo-500/40 hover:bg-indigo-500/5 transition"
|
||||||
|
>
|
||||||
|
<Upload className="h-8 w-8 text-white/20" />
|
||||||
|
<p className="text-sm text-white/40">Click to upload CSV file</p>
|
||||||
|
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="h-4 w-4 text-indigo-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-white">{rows.length} rows parsed</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
{validCount > 0 && <span className="text-emerald-400">{validCount} valid</span>}
|
||||||
|
{invalidCount > 0 && <span className="text-red-400">{invalidCount} invalid</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-5 py-3 gap-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{row._error
|
||||||
|
? <XCircle className="h-4 w-4 shrink-0 text-red-400" />
|
||||||
|
: <CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" />
|
||||||
|
}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{row._tenantName}</p>
|
||||||
|
<p className="text-xs text-white/40">{row.due_date} · ${row.amount}</p>
|
||||||
|
{row._error && <p className="text-xs text-red-400">{row._error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-md border border-white/10 px-2 py-0.5 text-xs text-white/50 capitalize">
|
||||||
|
{row.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!done && (
|
||||||
|
<div className="border-t border-white/[0.06] p-4">
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing || validCount === 0}
|
||||||
|
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{importing ? `Importing…` : `Import ${validCount} Payment${validCount !== 1 ? "s" : ""}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{done && (
|
||||||
|
<div className="border-t border-white/[0.06] p-4 text-center text-sm text-emerald-400">
|
||||||
|
Import complete!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function RentLoading() {
|
||||||
|
return <TableSkeleton rows={8} cols={5} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { tenants as tenantsTable } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { RentPaymentForm } from "@/components/forms/rent-payment-form"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "Record Payment" }
|
||||||
|
|
||||||
|
export default async function NewRentPaymentPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const tenants = await db.query.tenants.findMany({
|
||||||
|
where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")),
|
||||||
|
columns: { id: true, first_name: true, last_name: true, property_id: true, unit_id: true },
|
||||||
|
with: {
|
||||||
|
unit: { columns: { unit_number: true, rent_amount: true } },
|
||||||
|
property: { columns: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: asc(tenantsTable.first_name),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<BackButton href="/rent" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Record Payment</h2>
|
||||||
|
<p className="text-sm text-white/40">Log a rent payment for a tenant</p>
|
||||||
|
</div>
|
||||||
|
<RentPaymentForm tenants={tenants ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { eq, desc } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { rent_payments } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { CreditCard, Plus, Upload } from "lucide-react"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import { RentTable } from "./rent-table"
|
||||||
|
import { CsvExportButton } from "@/components/forms/csv-export-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "Rent Tracker" }
|
||||||
|
|
||||||
|
export default async function RentPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const payments = await db.query.rent_payments.findMany({
|
||||||
|
where: eq(rent_payments.user_id, user.id),
|
||||||
|
with: {
|
||||||
|
tenant: { columns: { first_name: true, last_name: true } },
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
unit: { columns: { unit_number: true } },
|
||||||
|
},
|
||||||
|
orderBy: desc(rent_payments.due_date),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Rent Tracker</h2>
|
||||||
|
<p className="text-sm text-white/40">{payments?.length ?? 0} total records</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/rent/import" className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition">
|
||||||
|
<Upload className="h-4 w-4" /> Import CSV
|
||||||
|
</Link>
|
||||||
|
<CsvExportButton endpoint="/api/export/rent" filename="rent-payments.csv" label="Export CSV" />
|
||||||
|
<Link href="/rent/new" className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition">
|
||||||
|
<Plus className="h-4 w-4" /> Record Payment
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!payments?.length ? (
|
||||||
|
<EmptyState icon={CreditCard} title="No payments yet" description="Record rent payments to track collections." action={{ label: "Record payment", href: "/rent/new" }} />
|
||||||
|
) : (
|
||||||
|
<RentTable payments={payments} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown, Check, Loader2 } from "lucide-react"
|
||||||
|
import { RentStatusBadge } from "@/components/dashboard/rent-status-badge"
|
||||||
|
import { RentActions } from "@/components/forms/rent-actions"
|
||||||
|
import { RentReceiptButton } from "@/components/forms/rent-receipt-button"
|
||||||
|
import { LateNoticeButton } from "@/components/forms/late-notice-button"
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
function monthLabel(d: Date) {
|
||||||
|
return d.toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES = ["pending", "paid", "overdue"] as const
|
||||||
|
type Status = typeof STATUSES[number]
|
||||||
|
|
||||||
|
// Inline status picker — click badge to cycle through statuses
|
||||||
|
function InlineStatusEdit({ payment }: { payment: any }) {
|
||||||
|
const [status, setStatus] = useState<Status>(payment.status)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
async function updateStatus(next: Status) {
|
||||||
|
if (next === status) { setOpen(false); return }
|
||||||
|
setSaving(true)
|
||||||
|
setOpen(false)
|
||||||
|
const res = await fetch(`/api/rent/${payment.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: next }),
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
if (res.ok) {
|
||||||
|
setStatus(next)
|
||||||
|
toast.success("Status updated")
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to update status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saving) return <Loader2 className="h-3.5 w-3.5 animate-spin text-white/30" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={() => setOpen((v) => !v)} title="Click to change status">
|
||||||
|
<RentStatusBadge status={status} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 z-50 mt-1.5 w-32 overflow-hidden rounded-xl border border-white/[0.08] bg-[#1d1d2a] shadow-2xl shadow-black/60 py-1">
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => updateStatus(s)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between px-3 py-2 text-xs capitalize transition-colors",
|
||||||
|
s === status ? "text-indigo-300 bg-indigo-500/10" : "text-white/60 hover:bg-white/[0.05] hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
{s === status && <Check className="h-3 w-3 text-indigo-400" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = "tenant" | "due_date" | "amount" | "status"
|
||||||
|
type SortDir = "asc" | "desc"
|
||||||
|
|
||||||
|
function SortTh({ label, col, active, dir, onClick }: { label: string; col: SortKey; active: SortKey; dir: SortDir; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide cursor-pointer select-none hover:text-white/60 transition-colors"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{label}
|
||||||
|
{active !== col
|
||||||
|
? <ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||||
|
: dir === "asc" ? <ArrowUp className="h-3 w-3 text-indigo-400" /> : <ArrowDown className="h-3 w-3 text-indigo-400" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RentTable({ payments }: { payments: any[] }) {
|
||||||
|
const now = new Date()
|
||||||
|
const [year, setYear] = useState(now.getFullYear())
|
||||||
|
const [month, setMonth] = useState(now.getMonth())
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("due_date")
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (month === 0) { setMonth(11); setYear((y) => y - 1) }
|
||||||
|
else setMonth((m) => m - 1)
|
||||||
|
}
|
||||||
|
function nextMonth() {
|
||||||
|
if (month === 11) { setMonth(0); setYear((y) => y + 1) }
|
||||||
|
else setMonth((m) => m + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(key: SortKey) {
|
||||||
|
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"))
|
||||||
|
else { setSortKey(key); setSortDir("asc") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() =>
|
||||||
|
payments
|
||||||
|
.filter((p) => {
|
||||||
|
const d = new Date(p.due_date)
|
||||||
|
return d.getFullYear() === year && d.getMonth() === month
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
let av: string | number = ""
|
||||||
|
let bv: string | number = ""
|
||||||
|
if (sortKey === "tenant") { av = `${a.tenant?.first_name} ${a.tenant?.last_name}`; bv = `${b.tenant?.first_name} ${b.tenant?.last_name}` }
|
||||||
|
if (sortKey === "due_date") { av = a.due_date ?? ""; bv = b.due_date ?? "" }
|
||||||
|
if (sortKey === "amount") { av = Number(a.amount); bv = Number(b.amount) }
|
||||||
|
if (sortKey === "status") { av = a.status ?? ""; bv = b.status ?? "" }
|
||||||
|
if (av < bv) return sortDir === "asc" ? -1 : 1
|
||||||
|
if (av > bv) return sortDir === "asc" ? 1 : -1
|
||||||
|
return 0
|
||||||
|
}),
|
||||||
|
[payments, year, month, sortKey, sortDir]
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
collected: filtered.filter((p) => p.status === "paid").reduce((s, p) => s + Number(p.amount), 0),
|
||||||
|
pending: filtered.filter((p) => p.status === "pending").reduce((s, p) => s + Number(p.amount), 0),
|
||||||
|
overdue: filtered.filter((p) => p.status === "overdue").reduce((s, p) => s + Number(p.amount), 0),
|
||||||
|
}), [filtered])
|
||||||
|
|
||||||
|
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Month navigator */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={prevMonth}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[160px] text-center text-sm font-semibold text-white px-1">
|
||||||
|
{monthLabel(new Date(year, month, 1))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={nextMonth}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{!isCurrentMonth && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setYear(now.getFullYear()); setMonth(now.getMonth()) }}
|
||||||
|
className="ml-2 text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/30">{filtered.length} record{filtered.length !== 1 ? "s" : ""}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "Collected", value: stats.collected, color: "text-emerald-400", bar: "bg-emerald-500" },
|
||||||
|
{ label: "Pending", value: stats.pending, color: "text-amber-400", bar: "bg-amber-500" },
|
||||||
|
{ label: "Overdue", value: stats.overdue, color: "text-red-400", bar: "bg-red-500" },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
<p className="text-xs text-white/35 tracking-wide">{s.label}</p>
|
||||||
|
<p className={`mt-1.5 text-xl font-bold tabular-nums ${s.color}`}>{formatCurrency(s.value)}</p>
|
||||||
|
<div className="mt-2 h-1 w-full rounded-full bg-white/[0.06]">
|
||||||
|
<div
|
||||||
|
className={`h-1 rounded-full ${s.bar} transition-all`}
|
||||||
|
style={{ width: s.value > 0 ? `${Math.round((s.value / (stats.collected + stats.pending + stats.overdue || 1)) * 100)}%` : "0%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
|
||||||
|
<p className="text-sm text-white/30">No payments in {monthLabel(new Date(year, month, 1))}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/[0.06]">
|
||||||
|
<SortTh label="Tenant" col="tenant" active={sortKey} dir={sortDir} onClick={() => toggleSort("tenant")} />
|
||||||
|
<th className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">Property / Unit</th>
|
||||||
|
<SortTh label="Due Date" col="due_date" active={sortKey} dir={sortDir} onClick={() => toggleSort("due_date")} />
|
||||||
|
<SortTh label="Amount" col="amount" active={sortKey} dir={sortDir} onClick={() => toggleSort("amount")} />
|
||||||
|
<SortTh label="Status" col="status" active={sortKey} dir={sortDir} onClick={() => toggleSort("status")} />
|
||||||
|
<th className="px-5 py-3.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/[0.04]">
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<tr key={p.id} className="group hover:bg-white/[0.02] transition">
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<Link href={`/tenants/${p.tenant_id}`} className="text-sm font-medium text-white hover:text-indigo-300 transition">
|
||||||
|
{p.tenant?.first_name} {p.tenant?.last_name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-sm text-white/70">{p.property?.name}</p>
|
||||||
|
<p className="text-xs text-white/35">Unit {p.unit?.unit_number ?? "—"}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-sm text-white/60">{formatDate(p.due_date)}</p>
|
||||||
|
{p.paid_date && <p className="text-xs text-white/30">Paid {formatDate(p.paid_date)}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<p className="text-sm font-bold text-white tabular-nums">{formatCurrency(p.amount)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<InlineStatusEdit payment={p} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3.5 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<RentReceiptButton
|
||||||
|
payment={p}
|
||||||
|
tenant={p.tenant}
|
||||||
|
property={p.property}
|
||||||
|
unit={p.unit}
|
||||||
|
/>
|
||||||
|
<LateNoticeButton
|
||||||
|
payment={p}
|
||||||
|
tenant={p.tenant}
|
||||||
|
property={p.property}
|
||||||
|
unit={p.unit}
|
||||||
|
/>
|
||||||
|
<RentActions payment={p} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<div key={p.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link href={`/tenants/${p.tenant_id}`} className="text-sm font-semibold text-white hover:text-indigo-300">
|
||||||
|
{p.tenant?.first_name} {p.tenant?.last_name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-white/40 mt-0.5 truncate">{p.property?.name} · Unit {p.unit?.unit_number ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<InlineStatusEdit payment={p} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-white/35">Due {formatDate(p.due_date)}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-base font-bold text-white tabular-nums">{formatCurrency(p.amount)}</p>
|
||||||
|
<RentActions payment={p} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||||
|
export default function Loading() { return <TableSkeleton rows={6} cols={4} /> }
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, eq, gte } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { properties as propertiesTable, rent_payments, expenses as expensesTable } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
import { TrendingUp, TrendingDown, Building2, DollarSign, Receipt, BarChart3 } from "lucide-react"
|
||||||
|
import { ReportsClient } from "./reports-client"
|
||||||
|
|
||||||
|
export const metadata = { title: "Reports" }
|
||||||
|
|
||||||
|
export default async function ReportsPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
// Last 6 months range
|
||||||
|
const sixMonthsAgo = new Date()
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
|
||||||
|
sixMonthsAgo.setDate(1)
|
||||||
|
const rangeStart = sixMonthsAgo.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const [properties, payments, expenses] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ id: propertiesTable.id, name: propertiesTable.name })
|
||||||
|
.from(propertiesTable)
|
||||||
|
.where(eq(propertiesTable.user_id, user.id)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
amount: rent_payments.amount,
|
||||||
|
status: rent_payments.status,
|
||||||
|
due_date: rent_payments.due_date,
|
||||||
|
property_id: rent_payments.property_id,
|
||||||
|
})
|
||||||
|
.from(rent_payments)
|
||||||
|
.where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, rangeStart))),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
amount: expensesTable.amount,
|
||||||
|
expense_date: expensesTable.expense_date,
|
||||||
|
property_id: expensesTable.property_id,
|
||||||
|
category: expensesTable.category,
|
||||||
|
})
|
||||||
|
.from(expensesTable)
|
||||||
|
.where(and(eq(expensesTable.user_id, user.id), gte(expensesTable.expense_date, rangeStart))),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Build monthly buckets for last 6 months
|
||||||
|
const months: { key: string; label: string }[] = []
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date()
|
||||||
|
d.setMonth(d.getMonth() - i)
|
||||||
|
d.setDate(1)
|
||||||
|
const key = d.toISOString().slice(0, 7)
|
||||||
|
const label = d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||||
|
months.push({ key, label })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly revenue & expenses
|
||||||
|
const monthlyData = months.map(({ key, label }) => {
|
||||||
|
const revenue = (payments ?? []).filter(p => p.status === "paid" && p.due_date?.startsWith(key)).reduce((s, p) => s + Number(p.amount), 0)
|
||||||
|
const expense = (expenses ?? []).filter(e => e.expense_date?.startsWith(key)).reduce((s, e) => s + Number(e.amount), 0)
|
||||||
|
return { key, label, revenue, expense, net: revenue - expense }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Per-property P&L
|
||||||
|
const propertyPnL = (properties ?? []).map((p: any) => {
|
||||||
|
const revenue = (payments ?? []).filter(pm => pm.status === "paid" && pm.property_id === p.id).reduce((s, pm) => s + Number(pm.amount), 0)
|
||||||
|
const expense = (expenses ?? []).filter(e => e.property_id === p.id).reduce((s, e) => s + Number(e.amount), 0)
|
||||||
|
const net = revenue - expense
|
||||||
|
const margin = revenue > 0 ? Math.round((net / revenue) * 100) : 0
|
||||||
|
return { ...p, revenue, expense, net, margin }
|
||||||
|
}).sort((a, b) => b.net - a.net)
|
||||||
|
|
||||||
|
// Summary totals
|
||||||
|
const totalRevenue = (payments ?? []).filter(p => p.status === "paid").reduce((s, p) => s + Number(p.amount), 0)
|
||||||
|
const totalExpenses = (expenses ?? []).reduce((s, e) => s + Number(e.amount), 0)
|
||||||
|
const totalNet = totalRevenue - totalExpenses
|
||||||
|
const avgMargin = totalRevenue > 0 ? Math.round((totalNet / totalRevenue) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary KPI cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "Total Revenue", value: formatCurrency(totalRevenue), icon: DollarSign, color: "text-emerald-400", bg: "bg-emerald-500/10" },
|
||||||
|
{ label: "Total Expenses", value: formatCurrency(totalExpenses), icon: Receipt, color: "text-rose-400", bg: "bg-rose-500/10" },
|
||||||
|
{ label: "Net Income", value: formatCurrency(totalNet), icon: totalNet >= 0 ? TrendingUp : TrendingDown, color: totalNet >= 0 ? "text-emerald-400" : "text-red-400", bg: totalNet >= 0 ? "bg-emerald-500/10" : "bg-red-500/10" },
|
||||||
|
{ label: "Profit Margin", value: `${avgMargin}%`, icon: BarChart3, color: "text-indigo-400", bg: "bg-indigo-500/10" },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className={`flex h-7 w-7 items-center justify-center rounded-lg ${s.bg}`}>
|
||||||
|
<s.icon className={`h-3.5 w-3.5 ${s.color}`} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/35">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
<p className={`text-xl font-bold tabular-nums ${s.color}`}>{s.value}</p>
|
||||||
|
<p className="text-[10px] text-white/25 mt-0.5">Last 6 months</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client component for interactive charts */}
|
||||||
|
<ReportsClient monthlyData={monthlyData} propertyPnL={propertyPnL} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
import { TrendingUp, TrendingDown, Building2 } from "lucide-react"
|
||||||
|
|
||||||
|
interface MonthData { key: string; label: string; revenue: number; expense: number; net: number }
|
||||||
|
interface PropertyPnL { id: string; name: string; revenue: number; expense: number; net: number; margin: number }
|
||||||
|
|
||||||
|
export function ReportsClient({ monthlyData, propertyPnL }: { monthlyData: MonthData[]; propertyPnL: PropertyPnL[] }) {
|
||||||
|
const [view, setView] = useState<"revenue" | "expense" | "net">("revenue")
|
||||||
|
|
||||||
|
const maxVal = Math.max(...monthlyData.map(m =>
|
||||||
|
view === "revenue" ? m.revenue : view === "expense" ? m.expense : Math.abs(m.net)
|
||||||
|
), 1)
|
||||||
|
|
||||||
|
const barColor = view === "revenue" ? "bg-indigo-500" : view === "expense" ? "bg-rose-500" : "bg-emerald-500"
|
||||||
|
const viewLabel = view === "revenue" ? "Revenue" : view === "expense" ? "Expenses" : "Net Income"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Monthly chart */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
|
||||||
|
<p className="text-sm font-semibold text-white">Monthly {viewLabel}</p>
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border border-white/[0.06] p-1">
|
||||||
|
{(["revenue", "expense", "net"] as const).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
onClick={() => setView(v)}
|
||||||
|
className={`px-3 py-1 rounded-md text-xs font-medium transition capitalize ${
|
||||||
|
view === v ? "bg-indigo-600 text-white" : "text-white/40 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v === "net" ? "Net" : v === "revenue" ? "Revenue" : "Expenses"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-5">
|
||||||
|
<div className="flex items-end gap-2 h-40">
|
||||||
|
{monthlyData.map((m) => {
|
||||||
|
const val = view === "revenue" ? m.revenue : view === "expense" ? m.expense : m.net
|
||||||
|
const height = maxVal > 0 ? Math.max((Math.abs(val) / maxVal) * 100, val !== 0 ? 4 : 0) : 0
|
||||||
|
const isNegative = val < 0
|
||||||
|
return (
|
||||||
|
<div key={m.key} className="flex-1 flex flex-col items-center gap-1 group">
|
||||||
|
<div className="relative w-full flex items-end justify-center" style={{ height: "128px" }}>
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-t-md transition-all duration-300 ${isNegative ? "bg-red-500" : barColor} opacity-70 group-hover:opacity-100`}
|
||||||
|
style={{ height: `${height}%` }}
|
||||||
|
title={formatCurrency(val)}
|
||||||
|
/>
|
||||||
|
<div className="absolute -top-6 left-1/2 -translate-x-1/2 hidden group-hover:block whitespace-nowrap rounded-lg bg-[#1d1d2a] border border-white/10 px-2 py-1 text-xs text-white shadow-xl z-10">
|
||||||
|
{formatCurrency(val)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-white/30">{m.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart legend */}
|
||||||
|
<div className="grid grid-cols-3 divide-x divide-white/[0.04] border-t border-white/[0.06]">
|
||||||
|
{[
|
||||||
|
{ label: "Total Revenue", value: monthlyData.reduce((s, m) => s + m.revenue, 0), color: "text-emerald-400" },
|
||||||
|
{ label: "Total Expenses", value: monthlyData.reduce((s, m) => s + m.expense, 0), color: "text-rose-400" },
|
||||||
|
{ label: "Net Income", value: monthlyData.reduce((s, m) => s + m.net, 0), color: "text-indigo-400" },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="px-4 py-3 text-center">
|
||||||
|
<p className={`text-base font-bold tabular-nums ${s.color}`}>{formatCurrency(s.value)}</p>
|
||||||
|
<p className="text-[10px] text-white/30 mt-0.5">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-property P&L */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-white/[0.06]">
|
||||||
|
<p className="text-sm font-semibold text-white">Property P&L</p>
|
||||||
|
<p className="text-xs text-white/35 mt-0.5">Income vs expenses per property (last 6 months)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{propertyPnL.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-white/30">No property data available</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{propertyPnL.map((p) => (
|
||||||
|
<div key={p.id} className="px-5 py-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/10">
|
||||||
|
<Building2 className="h-4 w-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{p.name}</p>
|
||||||
|
<p className="text-xs text-white/35">{p.margin}% profit margin</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-base font-bold tabular-nums ${p.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||||
|
{formatCurrency(p.net)}
|
||||||
|
</p>
|
||||||
|
<div className={`flex items-center gap-1 justify-end text-xs ${p.net >= 0 ? "text-emerald-400/60" : "text-red-400/60"}`}>
|
||||||
|
{p.net >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||||
|
Net income
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-xl bg-white/[0.03] border border-white/[0.04] px-3 py-2.5">
|
||||||
|
<p className="text-[10px] text-white/30 mb-1">Revenue</p>
|
||||||
|
<p className="text-sm font-bold text-emerald-400 tabular-nums">{formatCurrency(p.revenue)}</p>
|
||||||
|
<div className="mt-1.5 h-1 w-full rounded-full bg-white/[0.06]">
|
||||||
|
<div className="h-1 rounded-full bg-emerald-500" style={{ width: p.revenue > 0 ? "100%" : "0%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white/[0.03] border border-white/[0.04] px-3 py-2.5">
|
||||||
|
<p className="text-[10px] text-white/30 mb-1">Expenses</p>
|
||||||
|
<p className="text-sm font-bold text-rose-400 tabular-nums">{formatCurrency(p.expense)}</p>
|
||||||
|
<div className="mt-1.5 h-1 w-full rounded-full bg-white/[0.06]">
|
||||||
|
<div className="h-1 rounded-full bg-rose-500" style={{ width: p.revenue > 0 ? `${Math.min((p.expense / p.revenue) * 100, 100)}%` : "0%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, eq, sql } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { profiles, properties, tenants } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { CheckoutButton } from "@/components/forms/checkout-button"
|
||||||
|
import { PortalButton } from "@/components/forms/portal-button"
|
||||||
|
import { getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
import type { Plan } from "@/types"
|
||||||
|
|
||||||
|
export const metadata = { title: "Billing" }
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{
|
||||||
|
key: "starter" as Plan,
|
||||||
|
name: "Starter",
|
||||||
|
price: "$0",
|
||||||
|
interval: "forever",
|
||||||
|
description: "For landlords just getting started",
|
||||||
|
features: ["1 property", "3 tenants", "Maintenance tracking", "Rent tracker"],
|
||||||
|
cta: "Current plan",
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pro" as Plan,
|
||||||
|
name: "Pro",
|
||||||
|
price: "$29",
|
||||||
|
interval: "/month",
|
||||||
|
description: "For active landlords growing their portfolio",
|
||||||
|
features: ["10 properties", "Unlimited tenants", "50 AI calls/month", "5GB storage", "Email notifications", "Stripe rent collection"],
|
||||||
|
cta: "Upgrade to Pro",
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "landlord" as Plan,
|
||||||
|
name: "Landlord",
|
||||||
|
price: "$59",
|
||||||
|
interval: "/month",
|
||||||
|
description: "For property managers at scale",
|
||||||
|
features: ["Unlimited properties", "Unlimited tenants", "200 AI calls/month", "25GB storage", "Team access", "White-label"],
|
||||||
|
cta: "Upgrade to Landlord",
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lifetime" as Plan,
|
||||||
|
name: "Lifetime",
|
||||||
|
price: "$199",
|
||||||
|
interval: "one-time",
|
||||||
|
description: "Everything in Landlord, forever",
|
||||||
|
features: ["Everything in Landlord", "Lifetime updates", "Priority support", "Flippa-ready asset"],
|
||||||
|
cta: "Get Lifetime Deal",
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function BillingPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ success?: string; canceled?: string }>
|
||||||
|
}) {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const profile = await db.query.profiles.findFirst({
|
||||||
|
where: eq(profiles.id, user.id),
|
||||||
|
columns: {
|
||||||
|
plan: true,
|
||||||
|
subscription_status: true,
|
||||||
|
plan_expires_at: true,
|
||||||
|
stripe_customer_id: true,
|
||||||
|
stripe_subscription_id: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const currentPlan = (profile?.plan ?? "starter") as Plan
|
||||||
|
const hasStripeAccount = !!profile?.stripe_customer_id
|
||||||
|
const limits = PLAN_LIMITS[currentPlan]
|
||||||
|
|
||||||
|
const [[{ count: propertiesUsed }], [{ count: tenantsUsed }]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.user_id, user.id)),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(tenants)
|
||||||
|
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Billing</h2>
|
||||||
|
<p className="text-sm text-white/40">Manage your subscription and plan</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{params.success && (
|
||||||
|
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-5 py-4 text-sm text-emerald-400">
|
||||||
|
Payment successful! Your plan has been upgraded.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{params.canceled && (
|
||||||
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-5 py-4 text-sm text-amber-400">
|
||||||
|
Checkout canceled — no charge was made.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current plan */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/40">Current Plan</p>
|
||||||
|
<p className="mt-1 text-xl font-bold text-white">{getPlanLabel(currentPlan)}</p>
|
||||||
|
{profile?.subscription_status && (
|
||||||
|
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
|
||||||
|
<PortalButton />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plans grid */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{PLANS.map((plan) => {
|
||||||
|
const isCurrent = currentPlan === plan.key
|
||||||
|
const isDowngrade = (
|
||||||
|
currentPlan === "landlord" && (plan.key === "pro" || plan.key === "starter") ||
|
||||||
|
currentPlan === "lifetime"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={plan.key}
|
||||||
|
className={`relative flex flex-col rounded-xl border p-5 ${
|
||||||
|
plan.highlight
|
||||||
|
? "border-indigo-500/40 bg-indigo-600/5"
|
||||||
|
: "border-white/[0.06] bg-[#16161f]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.highlight && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="rounded-full bg-indigo-600 px-3 py-0.5 text-xs font-semibold text-white">
|
||||||
|
Most Popular
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{plan.name}</p>
|
||||||
|
<div className="mt-1 flex items-baseline gap-1">
|
||||||
|
<span className="text-2xl font-bold text-white">{plan.price}</span>
|
||||||
|
<span className="text-xs text-white/40">{plan.interval}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-white/40">{plan.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-4 flex-1 space-y-2">
|
||||||
|
{plan.features.map((f) => (
|
||||||
|
<li key={f} className="flex items-center gap-2 text-xs text-white/60">
|
||||||
|
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{isCurrent ? (
|
||||||
|
<div className="w-full rounded-lg border border-white/10 py-2 text-center text-xs font-medium text-white/40">
|
||||||
|
Current Plan
|
||||||
|
</div>
|
||||||
|
) : plan.key === "starter" || isDowngrade ? (
|
||||||
|
<div className="w-full rounded-lg border border-white/10 py-2 text-center text-xs font-medium text-white/30">
|
||||||
|
{plan.key === "starter" ? "Free" : "Downgrade via portal"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CheckoutButton plan={plan.key} label={plan.cta} highlight={plan.highlight} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage overview */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-5">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Usage</h3>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: "Properties",
|
||||||
|
used: propertiesUsed ?? 0,
|
||||||
|
max: limits.maxProperties,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active Tenants",
|
||||||
|
used: tenantsUsed ?? 0,
|
||||||
|
max: limits.maxTenants,
|
||||||
|
},
|
||||||
|
].map(({ label, used, max }) => {
|
||||||
|
const unlimited = max === Infinity
|
||||||
|
const pct = unlimited ? 0 : Math.min(100, Math.round((used / max) * 100))
|
||||||
|
const nearLimit = !unlimited && pct >= 80
|
||||||
|
return (
|
||||||
|
<div key={label}>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-white/60">{label}</span>
|
||||||
|
<span className={nearLimit ? "text-amber-400 font-medium" : "text-white/40"}>
|
||||||
|
{used} / {unlimited ? "Unlimited" : max}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!unlimited && (
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-white/[0.06]">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-1 sm:grid-cols-2 border-t border-white/[0.06]">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/40">AI Calls / mo</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">{limits.maxAiCalls === 0 ? "Not included" : limits.maxAiCalls}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/40">Storage</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">{limits.maxStorageMB >= 1024 ? `${limits.maxStorageMB / 1024}GB` : `${limits.maxStorageMB}MB`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { seedDemoData, clearDemoData, setTestPlan } from "@/app/actions/seed-demo"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { profiles } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { redirect, notFound } from "next/navigation"
|
||||||
|
import {
|
||||||
|
Building2, Users, CreditCard, Wrench,
|
||||||
|
FileText, Receipt, Sparkles, Trash2, CheckCircle2, Zap, Crown, Infinity
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const DEMO_CONTENTS = [
|
||||||
|
{ icon: Building2, color: "text-indigo-400 bg-indigo-500/10", label: "3 Properties", detail: "Maple Court, Riverdale Flats, Crestwood Villa" },
|
||||||
|
{ icon: Building2, color: "text-violet-400 bg-violet-500/10", label: "7 Units", detail: "Mix of 1, 2 & 3-bedroom units across all properties" },
|
||||||
|
{ icon: Users, color: "text-blue-400 bg-blue-500/10", label: "6 Tenants", detail: "Sarah, Marcus, Priya, David, Emily & James — with contacts" },
|
||||||
|
{ icon: FileText, color: "text-emerald-400 bg-emerald-500/10", label: "6 Leases", detail: "Active leases, 2 expiring soon to trigger alerts" },
|
||||||
|
{ icon: CreditCard, color: "text-teal-400 bg-teal-500/10", label: "30+ Payments", detail: "6 months of history — paid, pending & overdue statuses" },
|
||||||
|
{ icon: Wrench, color: "text-amber-400 bg-amber-500/10", label: "6 Maintenance Requests", detail: "Open, in-progress & resolved — with priorities" },
|
||||||
|
{ icon: Receipt, color: "text-rose-400 bg-rose-500/10", label: "8 Expenses", detail: "Repairs, insurance, utilities, taxes — with vendors" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{ value: "starter", label: "Starter", icon: Zap, color: "text-white/60 border-white/10 hover:border-white/20", desc: "Free — no AI" },
|
||||||
|
{ value: "pro", label: "Pro", icon: Sparkles, color: "text-indigo-300 border-indigo-500/30 hover:border-indigo-500/60 bg-indigo-500/5", desc: "50 AI calls/mo" },
|
||||||
|
{ value: "landlord", label: "Landlord", icon: Crown, color: "text-violet-300 border-violet-500/30 hover:border-violet-500/60 bg-violet-500/5", desc: "200 AI calls/mo" },
|
||||||
|
{ value: "lifetime", label: "Lifetime", icon: Infinity, color: "text-amber-300 border-amber-500/30 hover:border-amber-500/60 bg-amber-500/5", desc: "Unlimited — all features" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export default async function DemoDataPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production") notFound()
|
||||||
|
|
||||||
|
const profile = await db.query.profiles.findFirst({
|
||||||
|
where: eq(profiles.id, user.id),
|
||||||
|
columns: { plan: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentPlan = profile?.plan ?? "starter"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
{/* Plan switcher — most prominent */}
|
||||||
|
<div className="rounded-2xl border border-indigo-500/20 bg-gradient-to-br from-indigo-600/10 via-[#16161f] to-violet-600/5 overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-white/[0.06]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-indigo-400" />
|
||||||
|
<p className="text-sm font-semibold text-white">Test Plan</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/40 mt-0.5">
|
||||||
|
Switch plans instantly to test different features — current: <span className="text-indigo-300 font-semibold capitalize">{currentPlan}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 p-4">
|
||||||
|
{PLANS.map((plan) => {
|
||||||
|
const isActive = currentPlan === plan.value
|
||||||
|
return (
|
||||||
|
<form key={plan.value} action={setTestPlan.bind(null, plan.value)}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`w-full flex flex-col items-center gap-1.5 rounded-xl border px-3 py-3 transition-all ${plan.color} ${isActive ? "ring-2 ring-indigo-500/50 ring-offset-1 ring-offset-[#16161f]" : ""}`}
|
||||||
|
>
|
||||||
|
<plan.icon className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-semibold">{plan.label}</span>
|
||||||
|
<span className="text-[10px] opacity-60">{plan.desc}</span>
|
||||||
|
{isActive && <span className="text-[9px] font-bold text-emerald-400 uppercase tracking-wider">Active</span>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="px-5 pb-4">
|
||||||
|
<p className="text-[10px] text-white/25 text-center">
|
||||||
|
For testing only — does not affect real billing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo data section */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-white/[0.06]">
|
||||||
|
<p className="text-sm font-semibold text-white">Demo Data</p>
|
||||||
|
<p className="text-xs text-white/40 mt-0.5">Populate your account with realistic sample data</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{DEMO_CONTENTS.map((item) => (
|
||||||
|
<div key={item.label} className="flex items-center gap-4 px-5 py-3">
|
||||||
|
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${item.color}`}>
|
||||||
|
<item.icon className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white">{item.label}</p>
|
||||||
|
<p className="text-xs text-white/40 truncate">{item.detail}</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-400/40 shrink-0 ml-auto" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3">
|
||||||
|
<p className="text-xs text-amber-400/80 leading-relaxed">
|
||||||
|
<span className="font-semibold text-amber-400">Note:</span> "Load demo data" adds records to your account. Use "Clear all data" to wipe everything when done testing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<form action={seedDemoData} className="flex-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-lg hover:shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Load demo data
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action={clearDemoData}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full sm:w-auto flex items-center justify-center gap-2 rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-3 text-sm font-semibold text-red-400 hover:bg-red-500/10 hover:border-red-500/40 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Clear all data
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Skeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6 space-y-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-1.5">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-10 w-32 rounded-xl mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
redirect("/settings/profile")
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { profiles } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { ProfileForm } from "@/components/forms/profile-form"
|
||||||
|
|
||||||
|
export const metadata = { title: "Profile Settings" }
|
||||||
|
|
||||||
|
export default async function ProfileSettingsPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const profile = await db.query.profiles.findFirst({
|
||||||
|
where: eq(profiles.id, user.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Profile Settings</h2>
|
||||||
|
<p className="text-sm text-white/40">Update your personal information</p>
|
||||||
|
</div>
|
||||||
|
<ProfileForm profile={profile} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation"
|
||||||
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { tenants as tenantsTable, properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { TenantForm } from "@/components/forms/tenant-form"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "Edit Tenant" }
|
||||||
|
|
||||||
|
export default async function EditTenantPage({ params }: { params: Promise<{ tenantId: string }> }) {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const { tenantId } = await params
|
||||||
|
|
||||||
|
const [tenant, properties_] = await Promise.all([
|
||||||
|
db.query.tenants.findFirst({
|
||||||
|
where: and(eq(tenantsTable.id, tenantId), eq(tenantsTable.user_id, user.id)),
|
||||||
|
}),
|
||||||
|
db.query.properties.findMany({
|
||||||
|
where: eq(properties.user_id, user.id),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
with: {
|
||||||
|
units: { columns: { id: true, unit_number: true, status: true } },
|
||||||
|
},
|
||||||
|
orderBy: asc(properties.name),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!tenant) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<BackButton href={`/tenants/${tenantId}`} />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Edit Tenant</h2>
|
||||||
|
<p className="text-sm text-white/40">{tenant.first_name} {tenant.last_name}</p>
|
||||||
|
</div>
|
||||||
|
<TenantForm properties={properties_ ?? []} tenant={tenant} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation"
|
||||||
|
import { and, eq, desc } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { tenants as tenantsTable, rent_payments, maintenance_requests, leases as leasesTable } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/utils"
|
||||||
|
import { RentStatusBadge } from "@/components/dashboard/rent-status-badge"
|
||||||
|
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
|
||||||
|
import { Mail, Phone } from "lucide-react"
|
||||||
|
import { CopyButton } from "@/components/shared/copy-button"
|
||||||
|
import { SendReminderButton } from "@/components/shared/send-reminder-button"
|
||||||
|
|
||||||
|
export default async function TenantDetailPage({ params }: { params: Promise<{ tenantId: string }> }) {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const { tenantId } = await params
|
||||||
|
|
||||||
|
const tenant = await db.query.tenants.findFirst({
|
||||||
|
where: and(eq(tenantsTable.id, tenantId), eq(tenantsTable.user_id, user.id)),
|
||||||
|
with: {
|
||||||
|
unit: { columns: { unit_number: true, rent_amount: true, bedrooms: true, bathrooms: true } },
|
||||||
|
property: { columns: { name: true, address_line1: true, city: true, state: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tenant) notFound()
|
||||||
|
|
||||||
|
const [payments, maintenanceRequests, leases] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(rent_payments)
|
||||||
|
.where(and(eq(rent_payments.user_id, user.id), eq(rent_payments.tenant_id, tenantId)))
|
||||||
|
.orderBy(desc(rent_payments.due_date))
|
||||||
|
.limit(6),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(maintenance_requests)
|
||||||
|
.where(and(eq(maintenance_requests.user_id, user.id), eq(maintenance_requests.tenant_id, tenantId)))
|
||||||
|
.orderBy(desc(maintenance_requests.created_at))
|
||||||
|
.limit(5),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(leasesTable)
|
||||||
|
.where(and(eq(leasesTable.user_id, user.id), eq(leasesTable.tenant_id, tenantId)))
|
||||||
|
.orderBy(desc(leasesTable.created_at))
|
||||||
|
.limit(1),
|
||||||
|
])
|
||||||
|
|
||||||
|
const activeLease = leases?.[0]
|
||||||
|
const portalUrl = `${process.env.NEXT_PUBLIC_APP_URL}/tenant-portal/${tenant.portal_token}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-white/40">
|
||||||
|
<Link href="/tenants" className="hover:text-white transition">Tenants</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white/70">{tenant.first_name} {tenant.last_name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600/20 text-lg font-bold text-indigo-400">
|
||||||
|
{tenant.first_name[0]}{tenant.last_name[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">{tenant.first_name} {tenant.last_name}</h2>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
{tenant.email && <span className="flex items-center gap-1 text-sm text-white/50"><Mail className="h-3.5 w-3.5" />{tenant.email}</span>}
|
||||||
|
{tenant.phone && <span className="flex items-center gap-1 text-sm text-white/50"><Phone className="h-3.5 w-3.5" />{tenant.phone}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/tenants/${tenantId}/edit`}
|
||||||
|
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Rent payments */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Rent Payments</h3>
|
||||||
|
<Link href="/rent/new" className="text-xs text-indigo-400 hover:text-indigo-300">+ Record</Link>
|
||||||
|
</div>
|
||||||
|
{!payments?.length ? (
|
||||||
|
<p className="px-5 py-6 text-sm text-white/30">No payments recorded.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{payments.map((p: any) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between px-5 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white">{formatDate(p.due_date)}</p>
|
||||||
|
{p.paid_date && <p className="text-xs text-white/40">Paid {formatDate(p.paid_date)}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-semibold text-white">{formatCurrency(p.amount)}</span>
|
||||||
|
<RentStatusBadge status={p.status} />
|
||||||
|
{(p.status === "pending" || p.status === "overdue") && tenant.email && (
|
||||||
|
<SendReminderButton tenantId={tenant.id} paymentId={p.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maintenance */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Maintenance Requests</h3>
|
||||||
|
<Link href="/maintenance/new" className="text-xs text-indigo-400 hover:text-indigo-300">+ New</Link>
|
||||||
|
</div>
|
||||||
|
{!maintenanceRequests?.length ? (
|
||||||
|
<p className="px-5 py-6 text-sm text-white/30">No maintenance requests.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{maintenanceRequests.map((r: any) => (
|
||||||
|
<Link key={r.id} href={`/maintenance/${r.id}`} className="flex items-center justify-between px-5 py-3 hover:bg-white/[0.02] transition">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white">{r.title}</p>
|
||||||
|
<p className="text-xs text-white/40">{formatDate(r.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PriorityBadge priority={r.priority} />
|
||||||
|
<MaintenanceStatusBadge status={r.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Unit info */}
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Unit</h3>
|
||||||
|
<p className="text-sm font-medium text-white">{tenant.property?.name}</p>
|
||||||
|
{tenant.unit && <p className="text-sm text-white/60">Unit {tenant.unit.unit_number} · {tenant.unit.bedrooms}bd/{tenant.unit.bathrooms}ba</p>}
|
||||||
|
{tenant.unit?.rent_amount && <p className="text-sm font-bold text-white">{formatCurrency(tenant.unit.rent_amount)}<span className="text-xs text-white/40">/mo</span></p>}
|
||||||
|
{tenant.move_in_date && <p className="text-xs text-white/40">Moved in {formatDate(tenant.move_in_date)}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active lease */}
|
||||||
|
{activeLease && (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Lease</h3>
|
||||||
|
<p className="text-xs text-white/50">{formatDate(activeLease.lease_start)} → {formatDate(activeLease.lease_end)}</p>
|
||||||
|
<p className="text-xs text-white/50 capitalize">{activeLease.status}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tenant portal */}
|
||||||
|
<div className="rounded-xl border border-indigo-500/20 bg-indigo-500/5 p-5 space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wider text-indigo-400/60">Tenant Portal</h3>
|
||||||
|
<p className="text-xs text-white/40">Share this link with the tenant to submit maintenance requests.</p>
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2">
|
||||||
|
<span className="flex-1 truncate text-xs text-white/60">/tenant-portal/{tenant.portal_token?.slice(0, 12)}…</span>
|
||||||
|
<CopyButton text={portalUrl} />
|
||||||
|
<a href={portalUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-indigo-400 hover:text-indigo-300">Open</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emergency contact */}
|
||||||
|
{tenant.emergency_contact_name && (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Emergency Contact</h3>
|
||||||
|
<p className="text-sm text-white">{tenant.emergency_contact_name}</p>
|
||||||
|
{tenant.emergency_contact_phone && <p className="text-xs text-white/50">{tenant.emergency_contact_phone}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||||
|
|
||||||
|
export default function TenantsLoading() {
|
||||||
|
return <TableSkeleton rows={6} cols={5} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { asc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { TenantForm } from "@/components/forms/tenant-form"
|
||||||
|
import { BackButton } from "@/components/ui/back-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "Add Tenant" }
|
||||||
|
|
||||||
|
export default async function NewTenantPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const properties_ = await db.query.properties.findMany({
|
||||||
|
where: eq(properties.user_id, user.id),
|
||||||
|
columns: { id: true, name: true },
|
||||||
|
with: {
|
||||||
|
units: { columns: { id: true, unit_number: true, status: true } },
|
||||||
|
},
|
||||||
|
orderBy: asc(properties.name),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<BackButton href="/tenants" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Add Tenant</h2>
|
||||||
|
<p className="text-sm text-white/40">Add a tenant and assign them to a unit</p>
|
||||||
|
</div>
|
||||||
|
<TenantForm properties={properties_ ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { and, eq, desc } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { tenants as tenantsTable } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { Users, Plus } from "lucide-react"
|
||||||
|
import { EmptyState } from "@/components/shared/empty-state"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { TenantsTable } from "./tenants-table"
|
||||||
|
import { CsvExportButton } from "@/components/forms/csv-export-button"
|
||||||
|
|
||||||
|
export const metadata = { title: "Tenants" }
|
||||||
|
|
||||||
|
export default async function TenantsPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const tenants = await db.query.tenants.findMany({
|
||||||
|
where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")),
|
||||||
|
with: {
|
||||||
|
unit: { columns: { unit_number: true, rent_amount: true } },
|
||||||
|
property: { columns: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: desc(tenantsTable.created_at),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Tenants</h2>
|
||||||
|
<p className="text-sm text-white/40">{tenants?.length ?? 0} active tenants</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CsvExportButton endpoint="/api/export/tenants" filename="tenants.csv" label="Export CSV" />
|
||||||
|
<Link
|
||||||
|
href="/tenants/new"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> Add Tenant
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!tenants?.length ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="No tenants yet"
|
||||||
|
description="Add tenants and assign them to units to start tracking rent and maintenance."
|
||||||
|
action={{ label: "Add tenant", href: "/tenants/new" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TenantsTable tenants={tenants} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Mail, Search, ArrowRight, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"
|
||||||
|
import { formatDate, formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
|
type SortKey = "name" | "property" | "move_in" | "rent"
|
||||||
|
type SortDir = "asc" | "desc"
|
||||||
|
|
||||||
|
function SortIcon({ col, active, dir }: { col: SortKey; active: SortKey; dir: SortDir }) {
|
||||||
|
if (active !== col) return <ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||||
|
return dir === "asc"
|
||||||
|
? <ArrowUp className="h-3 w-3 text-indigo-400" />
|
||||||
|
: <ArrowDown className="h-3 w-3 text-indigo-400" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenantsTable({ tenants }: { tenants: any[] }) {
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("name")
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||||||
|
|
||||||
|
function toggleSort(key: SortKey) {
|
||||||
|
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"))
|
||||||
|
else { setSortKey(key); setSortDir("asc") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = tenants
|
||||||
|
.filter((t) => {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (
|
||||||
|
!q ||
|
||||||
|
`${t.first_name} ${t.last_name}`.toLowerCase().includes(q) ||
|
||||||
|
t.email?.toLowerCase().includes(q) ||
|
||||||
|
t.property?.name?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
let av: string | number = ""
|
||||||
|
let bv: string | number = ""
|
||||||
|
if (sortKey === "name") { av = `${a.first_name} ${a.last_name}`; bv = `${b.first_name} ${b.last_name}` }
|
||||||
|
if (sortKey === "property") { av = a.property?.name ?? ""; bv = b.property?.name ?? "" }
|
||||||
|
if (sortKey === "move_in") { av = a.move_in_date ?? ""; bv = b.move_in_date ?? "" }
|
||||||
|
if (sortKey === "rent") { av = a.unit?.rent_amount ?? 0; bv = b.unit?.rent_amount ?? 0 }
|
||||||
|
if (av < bv) return sortDir === "asc" ? -1 : 1
|
||||||
|
if (av > bv) return sortDir === "asc" ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const th = (label: string, key: SortKey) => (
|
||||||
|
<th
|
||||||
|
className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide cursor-pointer select-none hover:text-white/60 transition-colors"
|
||||||
|
onClick={() => toggleSort(key)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{label}
|
||||||
|
<SortIcon col={key} active={sortKey} dir={sortDir} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-white/25" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name, email or property…"
|
||||||
|
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] py-2.5 pl-10 pr-4 text-sm text-white placeholder-white/25 outline-none transition focus:border-indigo-500/50 focus:bg-white/[0.05] focus:ring-1 focus:ring-indigo-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
|
||||||
|
<p className="text-sm text-white/30">No tenants match “{search}”</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden sm:block rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/[0.06]">
|
||||||
|
{th("Tenant", "name")}
|
||||||
|
{th("Property / Unit", "property")}
|
||||||
|
{th("Move In", "move_in")}
|
||||||
|
{th("Rent / mo", "rent")}
|
||||||
|
<th className="px-5 py-3.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/[0.04]">
|
||||||
|
{filtered.map((tenant) => (
|
||||||
|
<tr key={tenant.id} className="group hover:bg-white/[0.02] transition-colors">
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500/20 to-violet-500/20 text-xs font-bold text-indigo-300 ring-1 ring-inset ring-indigo-500/20">
|
||||||
|
{tenant.first_name?.[0]}{tenant.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{tenant.first_name} {tenant.last_name}</p>
|
||||||
|
{tenant.email && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-white/35">
|
||||||
|
<Mail className="h-3 w-3" />{tenant.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<p className="text-sm text-white/80">{tenant.property?.name ?? "—"}</p>
|
||||||
|
<p className="text-xs text-white/35">Unit {tenant.unit?.unit_number ?? "—"}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<p className="text-sm text-white/60">{tenant.move_in_date ? formatDate(tenant.move_in_date) : "—"}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<p className="text-sm font-semibold text-white">
|
||||||
|
{tenant.unit?.rent_amount ? formatCurrency(tenant.unit.rent_amount) : "—"}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/tenants/${tenant.id}`}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-white/30 transition group-hover:text-indigo-400"
|
||||||
|
>
|
||||||
|
View <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{filtered.map((tenant) => (
|
||||||
|
<Link
|
||||||
|
key={tenant.id}
|
||||||
|
href={`/tenants/${tenant.id}`}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-white/[0.06] bg-[#16161f] p-4 transition hover:border-indigo-500/20 hover:bg-[#1a1a2e]"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500/20 to-violet-500/20 text-sm font-bold text-indigo-300">
|
||||||
|
{tenant.first_name?.[0]}{tenant.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-white">{tenant.first_name} {tenant.last_name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-white/40">
|
||||||
|
{tenant.property?.name && <span className="truncate">{tenant.property.name}</span>}
|
||||||
|
{tenant.unit?.unit_number && <span>· Unit {tenant.unit.unit_number}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
{tenant.unit?.rent_amount && (
|
||||||
|
<p className="text-sm font-semibold text-white">{formatCurrency(tenant.unit.rent_amount)}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-white/30">per mo</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||||
|
export default function Loading() { return <TableSkeleton rows={5} cols={4} /> }
|
||||||
Vendored
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { asc, eq } from "drizzle-orm"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { vendors, properties } from "@/lib/db/schema"
|
||||||
|
import { getSessionUser } from "@/lib/session"
|
||||||
|
import { VendorManager } from "./vendor-manager"
|
||||||
|
|
||||||
|
export const metadata = { title: "Vendors" }
|
||||||
|
|
||||||
|
export default async function VendorsPage() {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) redirect("/login")
|
||||||
|
|
||||||
|
const [vendorList, propertyList] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(vendors)
|
||||||
|
.where(eq(vendors.user_id, user.id))
|
||||||
|
.orderBy(asc(vendors.name)),
|
||||||
|
db
|
||||||
|
.select({ id: properties.id, name: properties.name })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.user_id, user.id)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Vendor Directory</h2>
|
||||||
|
<p className="text-sm text-white/40 mt-0.5">Save contractor and vendor contacts for quick access</p>
|
||||||
|
</div>
|
||||||
|
<VendorManager vendors={vendorList ?? []} properties={propertyList ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Plus, Trash2, Phone, Mail, Wrench, X, Pencil, Check } from "lucide-react"
|
||||||
|
import { Select } from "@/components/ui/select"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
const TRADES = [
|
||||||
|
{ value: "plumber", label: "Plumber" },
|
||||||
|
{ value: "electrician", label: "Electrician" },
|
||||||
|
{ value: "hvac", label: "HVAC" },
|
||||||
|
{ value: "handyman", label: "Handyman" },
|
||||||
|
{ value: "cleaner", label: "Cleaner" },
|
||||||
|
{ value: "landscaper", label: "Landscaper" },
|
||||||
|
{ value: "roofer", label: "Roofer" },
|
||||||
|
{ value: "painter", label: "Painter" },
|
||||||
|
{ value: "contractor", label: "Contractor" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tradeColors: Record<string, string> = {
|
||||||
|
plumber: "text-blue-400 bg-blue-500/10",
|
||||||
|
electrician: "text-yellow-400 bg-yellow-500/10",
|
||||||
|
hvac: "text-cyan-400 bg-cyan-500/10",
|
||||||
|
handyman: "text-orange-400 bg-orange-500/10",
|
||||||
|
cleaner: "text-green-400 bg-green-500/10",
|
||||||
|
landscaper: "text-emerald-400 bg-emerald-500/10",
|
||||||
|
roofer: "text-amber-400 bg-amber-500/10",
|
||||||
|
painter: "text-purple-400 bg-purple-500/10",
|
||||||
|
contractor: "text-indigo-400 bg-indigo-500/10",
|
||||||
|
other: "text-white/40 bg-white/5",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorManager({ vendors: initial, properties }: { vendors: any[]; properties: any[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [vendors, setVendors] = useState(initial)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState<any>({})
|
||||||
|
const [form, setForm] = useState({ name: "", trade: "handyman", phone: "", email: "", notes: "", property_id: "" })
|
||||||
|
|
||||||
|
const propertyOptions = [
|
||||||
|
{ value: "", label: "All properties" },
|
||||||
|
...properties.map((p: any) => ({ value: p.id, label: p.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
async function addVendor() {
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch("/api/vendors", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed to add vendor"); return }
|
||||||
|
setVendors(v => [...v, data])
|
||||||
|
setForm({ name: "", trade: "handyman", phone: "", email: "", notes: "", property_id: "" })
|
||||||
|
setShowForm(false)
|
||||||
|
toast.success("Vendor added")
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(v: any) {
|
||||||
|
setEditingId(v.id)
|
||||||
|
setEditForm({ name: v.name, trade: v.trade, phone: v.phone ?? "", email: v.email ?? "", notes: v.notes ?? "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(id: string) {
|
||||||
|
const res = await fetch(`/api/vendors/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(editForm),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { toast.error(data.error ?? "Failed to update"); return }
|
||||||
|
setVendors(v => v.map(x => x.id === id ? { ...x, ...data } : x))
|
||||||
|
setEditingId(null)
|
||||||
|
toast.success("Vendor updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVendor(id: string) {
|
||||||
|
await fetch(`/api/vendors/${id}`, { method: "DELETE" })
|
||||||
|
setVendors(v => v.filter(x => x.id !== id))
|
||||||
|
toast.success("Vendor removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Add button */}
|
||||||
|
{!showForm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 transition hover:shadow-lg hover:shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> Add Vendor
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="rounded-2xl border border-indigo-500/20 bg-[#16161f] p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-white">New Vendor</p>
|
||||||
|
<button onClick={() => setShowForm(false)} className="text-white/30 hover:text-white transition">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Name *</label>
|
||||||
|
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="John's Plumbing" className={cls} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Trade *</label>
|
||||||
|
<Select value={form.trade} onChange={v => setForm(f => ({ ...f, trade: v }))} options={TRADES} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Phone</label>
|
||||||
|
<input value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1 555 000 0000" className={cls} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Email</label>
|
||||||
|
<input value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} placeholder="vendor@email.com" className={cls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Property (optional)</label>
|
||||||
|
<Select value={form.property_id} onChange={v => setForm(f => ({ ...f, property_id: v }))} options={propertyOptions} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-white/40 mb-1 block">Notes</label>
|
||||||
|
<input value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="Reliable, good rates..." className={cls} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<button onClick={() => setShowForm(false)} className="rounded-xl border border-white/10 px-4 py-2 text-sm text-white/40 hover:text-white transition">Cancel</button>
|
||||||
|
<button onClick={addVendor} disabled={loading || !form.name.trim()} className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||||
|
{loading ? "Adding…" : "Add Vendor"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vendor list */}
|
||||||
|
{vendors.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
|
||||||
|
<Wrench className="h-8 w-8 text-white/10 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-white/30">No vendors yet</p>
|
||||||
|
<p className="text-xs text-white/20 mt-1">Add contractors and service providers for quick access</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{vendors.map((v: any) => (
|
||||||
|
<div key={v.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||||
|
{editingId === v.id ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input value={editForm.name} onChange={e => setEditForm((f: any) => ({ ...f, name: e.target.value }))} placeholder="Name" className={cls} />
|
||||||
|
<Select value={editForm.trade} onChange={(val: string) => setEditForm((f: any) => ({ ...f, trade: val }))} options={TRADES} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input value={editForm.phone} onChange={e => setEditForm((f: any) => ({ ...f, phone: e.target.value }))} placeholder="Phone" className={cls} />
|
||||||
|
<input value={editForm.email} onChange={e => setEditForm((f: any) => ({ ...f, email: e.target.value }))} placeholder="Email" className={cls} />
|
||||||
|
</div>
|
||||||
|
<input value={editForm.notes} onChange={e => setEditForm((f: any) => ({ ...f, notes: e.target.value }))} placeholder="Notes" className={cls} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setEditingId(null)} className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-white/40 hover:text-white transition">Cancel</button>
|
||||||
|
<button onClick={() => saveEdit(v.id)} className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-500 transition">
|
||||||
|
<Check className="h-3 w-3" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-xs font-bold uppercase ${tradeColors[v.trade] ?? tradeColors.other}`}>
|
||||||
|
{v.name[0]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-white">{v.name}</p>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${tradeColors[v.trade] ?? tradeColors.other}`}>{v.trade}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 flex-wrap">
|
||||||
|
{v.phone && <span className="flex items-center gap-1 text-xs text-white/40"><Phone className="h-3 w-3" />{v.phone}</span>}
|
||||||
|
{v.email && <span className="flex items-center gap-1 text-xs text-white/40"><Mail className="h-3 w-3" />{v.email}</span>}
|
||||||
|
</div>
|
||||||
|
{v.notes && <p className="text-xs text-white/25 mt-1 italic">{v.notes}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button onClick={() => startEdit(v)} className="p-1.5 text-white/20 hover:text-indigo-400 transition rounded-lg hover:bg-indigo-500/10">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteVendor(v.id)} className="p-1.5 text-white/20 hover:text-red-400 transition rounded-lg hover:bg-red-500/10">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowRight, Code2, Lock, Zap, BookOpen } from "lucide-react"
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "API Docs — Property Management Network",
|
||||||
|
description: "Property Management Network REST API documentation for developers.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENDPOINTS = [
|
||||||
|
{ method: "GET", path: "/api/properties", desc: "List all properties for the authenticated landlord" },
|
||||||
|
{ method: "POST", path: "/api/properties", desc: "Create a new property" },
|
||||||
|
{ method: "GET", path: "/api/tenants", desc: "List all tenants with lease status" },
|
||||||
|
{ method: "GET", path: "/api/payments", desc: "List rent payments with filters (status, date range)" },
|
||||||
|
{ method: "POST", path: "/api/payments", desc: "Record a new payment manually" },
|
||||||
|
{ method: "GET", path: "/api/maintenance", desc: "List all maintenance requests" },
|
||||||
|
{ method: "POST", path: "/api/maintenance", desc: "Create a maintenance request" },
|
||||||
|
{ method: "PATCH", path: "/api/maintenance/:id", desc: "Update request status or assign to contractor" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const METHOD_COLORS: Record<string, string> = {
|
||||||
|
GET: "text-emerald-400 bg-emerald-500/10",
|
||||||
|
POST: "text-blue-400 bg-blue-500/10",
|
||||||
|
PATCH: "text-amber-400 bg-amber-500/10",
|
||||||
|
DELETE: "text-red-400 bg-red-500/10",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiDocsPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#09090b] text-white min-h-screen">
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="relative overflow-hidden pt-32 pb-16">
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 h-[400px] w-[600px] rounded-full bg-violet-600/12 blur-[100px] -z-10" />
|
||||||
|
<div className="mx-auto max-w-4xl px-6">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-violet-500/30 bg-violet-500/10 px-4 py-1.5 text-xs font-medium text-violet-300 mb-6">
|
||||||
|
<Code2 className="h-3.5 w-3.5" />
|
||||||
|
REST API · v1
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold sm:text-5xl mb-4">API Documentation</h1>
|
||||||
|
<p className="text-lg text-white/50 max-w-2xl leading-relaxed">
|
||||||
|
Build on top of Property Management Network. Automate your workflows, sync with external tools, or build custom dashboards using our REST API.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5">
|
||||||
|
<span className="text-xs font-mono text-white/40">Base URL:</span>
|
||||||
|
<code className="text-xs font-mono text-indigo-300">https://api.propertymanagement.network/v1</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-4xl px-6 pb-24 space-y-12">
|
||||||
|
{/* Auth */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Lock className="h-5 w-5 text-indigo-400" /> Authentication
|
||||||
|
</h2>
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] p-6">
|
||||||
|
<p className="text-sm text-white/60 mb-4 leading-relaxed">
|
||||||
|
All API requests require a Bearer token in the Authorization header. Generate your API key from
|
||||||
|
the <Link href="/login" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">dashboard settings</Link>.
|
||||||
|
</p>
|
||||||
|
<div className="rounded-xl bg-[#0a0a12] border border-white/[0.06] p-4 font-mono text-xs text-emerald-300">
|
||||||
|
<p className="text-white/30 mb-1"># Example request</p>
|
||||||
|
<p>curl https://api.propertymanagement.network/v1/properties \</p>
|
||||||
|
<p className="pl-4">-H "Authorization: Bearer YOUR_API_KEY"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Endpoints */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Zap className="h-5 w-5 text-amber-400" /> Endpoints
|
||||||
|
</h2>
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden">
|
||||||
|
{ENDPOINTS.map((ep, i) => (
|
||||||
|
<div key={i} className={`flex items-start gap-4 px-6 py-4 ${i !== ENDPOINTS.length - 1 ? "border-b border-white/[0.04]" : ""}`}>
|
||||||
|
<span className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] font-bold font-mono ${METHOD_COLORS[ep.method]}`}>
|
||||||
|
{ep.method}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<code className="text-xs font-mono text-white/80">{ep.path}</code>
|
||||||
|
<p className="text-xs text-white/40 mt-0.5">{ep.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Response format */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<BookOpen className="h-5 w-5 text-blue-400" /> Response Format
|
||||||
|
</h2>
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] p-6">
|
||||||
|
<p className="text-sm text-white/60 mb-4">All responses are JSON. Successful responses return a <code className="text-indigo-300 font-mono text-xs">data</code> field. Errors return an <code className="text-red-300 font-mono text-xs">error</code> field with a message and code.</p>
|
||||||
|
<div className="rounded-xl bg-[#0a0a12] border border-white/[0.06] p-4 font-mono text-xs leading-relaxed">
|
||||||
|
<p className="text-white/30">// Success</p>
|
||||||
|
<p className="text-emerald-300">{"{"} "data": [...], "count": 12 {"}"}</p>
|
||||||
|
<br />
|
||||||
|
<p className="text-white/30">// Error</p>
|
||||||
|
<p className="text-red-300">{"{"} "error": {"{"} "code": 401, "message": "Unauthorized" {"}"} {"}"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coming soon banner */}
|
||||||
|
<div className="rounded-2xl border border-indigo-500/20 bg-indigo-500/5 p-6 text-center">
|
||||||
|
<p className="text-sm font-semibold text-indigo-300 mb-1">Full SDK coming soon</p>
|
||||||
|
<p className="text-xs text-white/40 mb-4">We're building official JavaScript and Python SDKs. Join the waitlist to be notified.</p>
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2 text-xs font-semibold text-white hover:bg-indigo-500 transition"
|
||||||
|
>
|
||||||
|
Join waitlist <ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: "Cookie Policy — Property Management Network",
|
||||||
|
description: "How Property Management Network uses cookies and similar tracking technologies.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
title: "What are cookies?",
|
||||||
|
body: "Cookies are small text files stored on your device by your browser when you visit a website. They help websites remember your preferences, keep you logged in, and understand how the site is being used.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cookies we use",
|
||||||
|
body: "We use the following types of cookies: (1) Essential cookies — required for the application to function, such as session authentication tokens. Without these, you cannot log in. (2) Analytics cookies — we use Vercel Analytics (privacy-preserving, no personal data stored) to understand page performance. (3) Preference cookies — we store your dashboard preferences (dark/light mode, column visibility) in browser localStorage.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Third-party cookies",
|
||||||
|
body: "Stripe may set cookies when you make a payment for fraud prevention and PCI compliance purposes. We do not use advertising, retargeting, or social media tracking cookies.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "How to control cookies",
|
||||||
|
body: "You can manage cookies through your browser settings. Most browsers allow you to block or delete cookies. Note that blocking essential cookies will prevent you from logging in to Property Management Network. For analytics cookies, you can opt out by enabling the Do Not Track header in your browser.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cookie retention",
|
||||||
|
body: "Session cookies expire when you close your browser. Authentication tokens are refreshed automatically and expire after 7 days of inactivity. Analytics data is retained for 90 days in aggregate, with no individual identifiers stored.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Changes to this policy",
|
||||||
|
body: "We may update this Cookie Policy as we add new features. Significant changes will be announced via the in-app notification banner. The date at the bottom of this page reflects the most recent update.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Contact",
|
||||||
|
body: "For questions about cookies or this policy, contact us at privacy@propertymanagement.network.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CookiePolicyPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#09090b] text-white min-h-screen">
|
||||||
|
<div className="mx-auto max-w-3xl px-6 pt-32 pb-24">
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Cookie Policy</h1>
|
||||||
|
<p className="text-xs text-white/30">Last updated: April 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<div key={s.title}>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">{s.title}</h2>
|
||||||
|
<p className="text-sm text-white/50 leading-relaxed">{s.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cookie types summary table */}
|
||||||
|
<div className="mt-10 rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden">
|
||||||
|
<div className="px-5 py-3.5 border-b border-white/[0.04]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-white/30">Cookie Summary</p>
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{ name: "pf_session", type: "Essential", purpose: "Authentication session token", expires: "7 days" },
|
||||||
|
{ name: "pf_prefs", type: "Preference", purpose: "Dashboard layout preferences", expires: "1 year" },
|
||||||
|
{ name: "_vercel_*", type: "Analytics", purpose: "Anonymous page performance data", expires: "90 days" },
|
||||||
|
{ name: "__stripe_*", type: "Third-party", purpose: "Stripe payment fraud prevention", expires: "Session" },
|
||||||
|
].map((row, i, arr) => (
|
||||||
|
<div key={row.name} className={`grid grid-cols-4 gap-4 px-5 py-3.5 text-xs ${i !== arr.length - 1 ? "border-b border-white/[0.04]" : ""}`}>
|
||||||
|
<code className="font-mono text-indigo-300">{row.name}</code>
|
||||||
|
<span className="text-white/60">{row.type}</span>
|
||||||
|
<span className="text-white/40 col-span-1">{row.purpose}</span>
|
||||||
|
<span className="text-white/40">{row.expires}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "GDPR Compliance — Property Management Network",
|
||||||
|
description: "Property Management Network's commitment to GDPR compliance and your data rights as a data subject.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const RIGHTS = [
|
||||||
|
{ right: "Right of access", desc: "You can request a full export of all data we hold about you at any time from your account settings." },
|
||||||
|
{ right: "Right to rectification", desc: "You can update your personal information directly in your account settings, or contact us to correct inaccurate data." },
|
||||||
|
{ right: "Right to erasure", desc: "You can permanently delete your account and all associated data from the settings page. Deletion is irreversible and processed within 30 days." },
|
||||||
|
{ right: "Right to data portability", desc: "You can export your data in machine-readable JSON or CSV format from the dashboard at any time." },
|
||||||
|
{ right: "Right to restriction", desc: "You may request that we restrict processing of your data while a dispute is being resolved." },
|
||||||
|
{ right: "Right to object", desc: "You may object to processing where we rely on legitimate interest. You can opt out of analytics tracking by enabling Do Not Track in your browser." },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function GdprPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#09090b] text-white min-h-screen">
|
||||||
|
<div className="mx-auto max-w-3xl px-6 pt-32 pb-24">
|
||||||
|
<div className="mb-10">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-300 mb-4">
|
||||||
|
EU GDPR Compliant
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">GDPR Compliance</h1>
|
||||||
|
<p className="text-xs text-white/30">Last updated: April 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8 text-sm text-white/50 leading-relaxed">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">Who we are</h2>
|
||||||
|
<p>
|
||||||
|
Property Management Network ("we", "us", "our") is the data controller for personal data collected through our platform.
|
||||||
|
We are committed to complying with the General Data Protection Regulation (EU) 2016/679 (GDPR)
|
||||||
|
and the UK GDPR where applicable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">What data we process</h2>
|
||||||
|
<p className="mb-3">We process the following categories of personal data:</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{[
|
||||||
|
"Account data: name, email address, password hash",
|
||||||
|
"Property data: addresses, rental amounts, lease terms you enter",
|
||||||
|
"Tenant data: names, emails, phone numbers you provide as a landlord",
|
||||||
|
"Payment data: payment amounts, dates, and status (card details handled by Stripe, not us)",
|
||||||
|
"Usage data: pages visited, features used — anonymised via Vercel Analytics",
|
||||||
|
].map((item) => (
|
||||||
|
<li key={item} className="flex items-start gap-2">
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-indigo-400" />
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">Legal basis for processing</h2>
|
||||||
|
<p>
|
||||||
|
We process personal data on the following legal bases: (1) Contract — data necessary to provide the service you signed up for.
|
||||||
|
(2) Legitimate interest — anonymous analytics to improve the product. (3) Legal obligation — where required by applicable law.
|
||||||
|
We do not process data on the basis of consent for core functionality.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">Data storage and transfers</h2>
|
||||||
|
<p>
|
||||||
|
Your data is stored in Supabase (PostgreSQL), with servers located in the EU (Frankfurt, Germany) by default.
|
||||||
|
Row-level security (RLS) policies ensure only you can access your data. We do not transfer personal data outside
|
||||||
|
the EEA except where strictly necessary for integrated services (e.g. Stripe for payment processing,
|
||||||
|
which is covered by Standard Contractual Clauses).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">Your rights under GDPR</h2>
|
||||||
|
<p className="mb-5">As a data subject, you have the following rights:</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{RIGHTS.map((r) => (
|
||||||
|
<div key={r.right} className="rounded-xl border border-white/[0.06] bg-[#111118] p-4">
|
||||||
|
<p className="text-sm font-semibold text-white mb-1">{r.right}</p>
|
||||||
|
<p className="text-xs text-white/50 leading-relaxed">{r.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">Data retention</h2>
|
||||||
|
<p>
|
||||||
|
We retain account data for as long as your account is active. Upon deletion, all personal data is purged within 30 days,
|
||||||
|
except where retention is required by law (e.g. financial records may be retained for up to 7 years for tax compliance).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">Data breach notification</h2>
|
||||||
|
<p>
|
||||||
|
In the event of a data breach affecting your personal data, we will notify affected users within 72 hours of becoming aware,
|
||||||
|
in accordance with GDPR Article 33 obligations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">Sub-processors</h2>
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden">
|
||||||
|
{[
|
||||||
|
{ name: "Supabase", purpose: "Database & file storage", location: "EU (Frankfurt)" },
|
||||||
|
{ name: "Stripe", purpose: "Payment processing", location: "US (SCCs in place)" },
|
||||||
|
{ name: "Resend", purpose: "Transactional email", location: "US (SCCs in place)" },
|
||||||
|
{ name: "Vercel", purpose: "Hosting & edge network", location: "Global (anonymised data only)" },
|
||||||
|
].map((sp, i, arr) => (
|
||||||
|
<div key={sp.name} className={`grid grid-cols-3 gap-4 px-5 py-3.5 text-xs ${i !== arr.length - 1 ? "border-b border-white/[0.04]" : ""}`}>
|
||||||
|
<span className="font-semibold text-white">{sp.name}</span>
|
||||||
|
<span className="text-white/50">{sp.purpose}</span>
|
||||||
|
<span className="text-white/40">{sp.location}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-2">Contact & complaints</h2>
|
||||||
|
<p>
|
||||||
|
To exercise any of your rights or to raise a data protection concern, contact our Data Protection lead at{" "}
|
||||||
|
<a href="mailto:privacy@propertymanagement.network" className="text-indigo-400 hover:text-indigo-300">
|
||||||
|
privacy@propertymanagement.network
|
||||||
|
</a>
|
||||||
|
. You also have the right to lodge a complaint with your local supervisory authority (e.g. the ICO in the UK,
|
||||||
|
or your national DPA in the EU).
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
See also our{" "}
|
||||||
|
<Link href="/privacy" className="text-indigo-400 hover:text-indigo-300">Privacy Policy</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link href="/cookie-policy" className="text-indigo-400 hover:text-indigo-300">Cookie Policy</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Navbar } from "@/components/marketing/navbar"
|
||||||
|
import { Footer } from "@/components/marketing/footer"
|
||||||
|
|
||||||
|
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#09090b] text-white">
|
||||||
|
<Navbar />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Hero } from "@/components/marketing/hero"
|
||||||
|
import { Marquee } from "@/components/marketing/marquee"
|
||||||
|
import { Problem } from "@/components/marketing/problem"
|
||||||
|
import { Features } from "@/components/marketing/features"
|
||||||
|
import { HowItWorks } from "@/components/marketing/how-it-works"
|
||||||
|
import { Testimonials } from "@/components/marketing/testimonials"
|
||||||
|
import { PricingSection } from "@/components/marketing/pricing-section"
|
||||||
|
import { FAQ } from "@/components/marketing/faq"
|
||||||
|
import { CtaBanner } from "@/components/marketing/cta-banner"
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Property Management Network — Property management without the chaos",
|
||||||
|
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised. Built for independent landlords.",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
<Marquee />
|
||||||
|
<Problem />
|
||||||
|
<Features />
|
||||||
|
<HowItWorks />
|
||||||
|
<Testimonials />
|
||||||
|
<PricingSection />
|
||||||
|
<FAQ />
|
||||||
|
<CtaBanner />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const metadata = { title: "Privacy Policy — Property Management Network" }
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-6 py-24">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-8">Privacy Policy</h1>
|
||||||
|
<p className="text-white/50 text-sm leading-relaxed">
|
||||||
|
This Privacy Policy describes how Property Management Network collects, uses, and protects your information.
|
||||||
|
We collect only the data necessary to provide the service (account info, property data you enter,
|
||||||
|
and usage analytics). Your data is stored securely in Supabase with row-level security —
|
||||||
|
no other user can access your records. We do not sell your data to third parties.
|
||||||
|
Files you upload are stored in private buckets and accessible only to you.
|
||||||
|
For questions, contact us at support@propertymanagement.network.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: "System Status — Property Management Network",
|
||||||
|
description: "Real-time status of all Property Management Network services.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ name: "Web Application", status: "operational", uptime: "99.98%" },
|
||||||
|
{ name: "API Gateway", status: "operational", uptime: "99.97%" },
|
||||||
|
{ name: "Database (Supabase)", status: "operational", uptime: "99.99%" },
|
||||||
|
{ name: "Payment Processing (Stripe)", status: "operational", uptime: "99.95%" },
|
||||||
|
{ name: "Email Delivery (Resend)", status: "operational", uptime: "99.96%" },
|
||||||
|
{ name: "File Storage", status: "operational", uptime: "99.99%" },
|
||||||
|
{ name: "Tenant Portal", status: "operational", uptime: "99.97%" },
|
||||||
|
{ name: "Webhook Delivery", status: "operational", uptime: "99.90%" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const INCIDENTS = [
|
||||||
|
{
|
||||||
|
date: "2026-03-28",
|
||||||
|
title: "Resolved: Delayed email notifications",
|
||||||
|
detail: "Email notifications were delayed by up to 12 minutes due to a Resend upstream issue. Fully resolved at 14:32 UTC.",
|
||||||
|
severity: "minor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-02-14",
|
||||||
|
title: "Resolved: Slow dashboard load times",
|
||||||
|
detail: "Database query optimisations were deployed to fix a slow index scan affecting dashboards with 50+ units. Resolved in 45 minutes.",
|
||||||
|
severity: "minor",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function StatusPage() {
|
||||||
|
const allOperational = SERVICES.every((s) => s.status === "operational")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#09090b] text-white min-h-screen">
|
||||||
|
<div className="mx-auto max-w-3xl px-6 pt-32 pb-24">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">System Status</h1>
|
||||||
|
<p className="text-white/40 text-sm">Real-time health of all Property Management Network services.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall status */}
|
||||||
|
<div className={`flex items-center gap-3 rounded-2xl border p-5 mb-8 ${allOperational ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}`}>
|
||||||
|
<div className={`relative flex h-3 w-3`}>
|
||||||
|
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${allOperational ? "bg-emerald-400" : "bg-red-400"}`} />
|
||||||
|
<span className={`relative inline-flex rounded-full h-3 w-3 ${allOperational ? "bg-emerald-400" : "bg-red-400"}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`font-semibold text-sm ${allOperational ? "text-emerald-300" : "text-red-300"}`}>
|
||||||
|
{allOperational ? "All systems operational" : "Partial outage detected"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/30 mt-0.5">Last checked: just now</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden mb-10">
|
||||||
|
<div className="px-5 py-3.5 border-b border-white/[0.04]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-white/30">Services</p>
|
||||||
|
</div>
|
||||||
|
{SERVICES.map((svc, i) => (
|
||||||
|
<div key={svc.name} className={`flex items-center justify-between px-4 sm:px-5 py-3.5 sm:py-4 gap-3 ${i !== SERVICES.length - 1 ? "border-b border-white/[0.04]" : ""}`}>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className={`shrink-0 h-2 w-2 rounded-full ${svc.status === "operational" ? "bg-emerald-400" : svc.status === "degraded" ? "bg-amber-400" : "bg-red-400"}`} />
|
||||||
|
<span className="text-sm text-white/80 truncate">{svc.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 sm:gap-6 shrink-0">
|
||||||
|
<span className="hidden sm:block text-xs text-white/30 tabular-nums">{svc.uptime} uptime</span>
|
||||||
|
<span className={`text-xs font-medium capitalize ${svc.status === "operational" ? "text-emerald-400" : svc.status === "degraded" ? "text-amber-400" : "text-red-400"}`}>
|
||||||
|
{svc.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uptime graph placeholder */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] p-5 mb-10">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-white/30 mb-4">90-day uptime</p>
|
||||||
|
<div className="flex gap-px sm:gap-0.5 h-6 sm:h-8 items-end">
|
||||||
|
{Array.from({ length: 90 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-sm bg-emerald-500/70"
|
||||||
|
style={{ height: `${Math.random() > 0.03 ? 100 : Math.floor(Math.random() * 60 + 20)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2 text-[10px] text-white/20">
|
||||||
|
<span>90 days ago</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incidents */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4">Past Incidents</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{INCIDENTS.map((inc) => (
|
||||||
|
<div key={inc.date} className="rounded-2xl border border-white/[0.06] bg-[#111118] p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-400 uppercase">
|
||||||
|
{inc.severity}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/30">{inc.date}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-white mb-1">{inc.title}</p>
|
||||||
|
<p className="text-xs text-white/50 leading-relaxed">{inc.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Building2, CheckCircle2, Smartphone, Bell, FileText, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Tenant Portal — Property Management Network",
|
||||||
|
description: "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{ icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20", title: "Online Rent Payment", desc: "Tenants pay rent securely via Stripe — card or bank transfer. Auto-receipts sent by email." },
|
||||||
|
{ icon: FileText, color: "text-indigo-400", bg: "bg-indigo-500/10 border-indigo-500/20", title: "Lease Documents", desc: "View and download lease agreements, addendums, and move-in checklists anytime." },
|
||||||
|
{ icon: Building2, color: "text-violet-400", bg: "bg-violet-500/10 border-violet-500/20", title: "Maintenance Requests", desc: "Submit maintenance issues with photos. Track status from open → in progress → resolved." },
|
||||||
|
{ icon: Bell, color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20", title: "Smart Notifications", desc: "Rent due reminders, request updates, and landlord messages via email or WhatsApp." },
|
||||||
|
{ icon: Smartphone, color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20", title: "Mobile Friendly", desc: "Works perfectly on any device — no app download needed, just a secure link." },
|
||||||
|
{ icon: FileText, color: "text-rose-400", bg: "bg-rose-500/10 border-rose-500/20", title: "Payment History", desc: "Full history of all payments and receipts. Great for tenant records and disputes." },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TenantPortalInfoPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#09090b] text-white min-h-screen">
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="relative overflow-hidden pt-32 pb-20">
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 h-[400px] w-[700px] rounded-full bg-indigo-600/15 blur-[100px] -z-10" />
|
||||||
|
<div className="mx-auto max-w-4xl px-6 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-indigo-500/30 bg-indigo-500/10 px-4 py-1.5 text-xs font-medium text-indigo-300 mb-6">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-indigo-400" />
|
||||||
|
For Tenants & Landlords
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight mb-6">
|
||||||
|
A dedicated portal<br />
|
||||||
|
<span className="bg-gradient-to-r from-indigo-400 via-violet-400 to-indigo-400 bg-clip-text text-transparent">
|
||||||
|
your tenants will actually use
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-white/50 max-w-2xl mx-auto leading-relaxed mb-10">
|
||||||
|
Send tenants a secure link — no account required. They can pay rent, submit maintenance requests,
|
||||||
|
and access their lease documents in seconds.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-lg hover:shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-6 py-3 text-sm font-medium text-white/70 hover:text-white hover:bg-white/[0.08] transition-all"
|
||||||
|
>
|
||||||
|
Sign in to dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features grid */}
|
||||||
|
<div className="mx-auto max-w-6xl px-6 pb-24">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-2xl font-bold text-white sm:text-3xl">Everything tenants need, nothing they don't</h2>
|
||||||
|
<p className="mt-3 text-white/40">Clean, fast, and works on any device.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<div key={f.title} className={`rounded-2xl border p-6 ${f.bg} transition-all hover:scale-[1.02]`}>
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-xl bg-black/20 mb-4`}>
|
||||||
|
<f.icon className={`h-5 w-5 ${f.color}`} />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-white mb-2">{f.title}</h3>
|
||||||
|
<p className="text-sm text-white/50 leading-relaxed">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How it works */}
|
||||||
|
<div className="border-t border-white/[0.06] py-24">
|
||||||
|
<div className="mx-auto max-w-4xl px-6">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-2xl font-bold text-white sm:text-3xl">How it works</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
{[
|
||||||
|
{ step: "01", title: "Add your tenant", desc: "Enter your tenant's name and email in your Property Management Network dashboard." },
|
||||||
|
{ step: "02", title: "Send the link", desc: "Property Management Network generates a secure, unique portal link and emails it automatically." },
|
||||||
|
{ step: "03", title: "Tenant accesses portal", desc: "No sign-up needed. Tenant clicks the link and has instant access to their portal." },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.step} className="relative">
|
||||||
|
<div className="text-5xl font-black text-white/[0.04] mb-3">{s.step}</div>
|
||||||
|
<h3 className="font-semibold text-white mb-2">{s.title}</h3>
|
||||||
|
<p className="text-sm text-white/50 leading-relaxed">{s.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="border-t border-white/[0.06] py-20">
|
||||||
|
<div className="mx-auto max-w-2xl px-6 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">Ready to give tenants a better experience?</h2>
|
||||||
|
<p className="text-white/40 mb-8">Free plan includes up to 2 properties and unlimited tenant portal access.</p>
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-8 py-3.5 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-lg hover:shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
Start for free <ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const metadata = { title: "Terms of Service — Property Management Network" }
|
||||||
|
|
||||||
|
export default function TermsPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-6 py-24">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-8">Terms of Service</h1>
|
||||||
|
<p className="text-white/50 text-sm leading-relaxed">
|
||||||
|
By using Property Management Network you agree to these terms. Property Management Network is provided as-is for property
|
||||||
|
management purposes. You are responsible for the accuracy of data you enter. Subscription fees
|
||||||
|
are billed monthly or as a one-time charge through Stripe. You may cancel at any time —
|
||||||
|
cancellation takes effect at the end of your billing period. Lifetime plans are non-refundable
|
||||||
|
after 14 days. We reserve the right to suspend accounts that violate these terms.
|
||||||
|
For questions, contact us at support@propertymanagement.network.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user