feat: add admin billing system, SMTP email, rename to CloudLense
- Add payments, coupons, credits, invoices management to admin dashboard - Add 7 new admin tabs: Overview, Users, Orgs, Payments, Coupons, Credits, Invoices - Replace Resend with SMTP email via nodemailer (info@dk0.dev / mail.dk0.dev) - Add professional branded email templates (alerts, welcome, invoice, credit, password reset) - Add database migration for payments, coupons, coupon_redemptions, credit_transactions, invoices tables - Add credit_balance column to organizations - Add RLS policies for all new tables - Add 4 new API routes: /api/admin/{payments,coupons,credits,invoices} - Rename project from website-monitoring to CloudLense - Update all package.json names and README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# 🔍 Website Monitoring Platform
|
# ⬡ CloudLense
|
||||||
|
|
||||||
Full-stack website monitoring platform that uses **Google Lighthouse** to audit performance, SEO, accessibility, and best practices — with real-time progress tracking, team collaboration, and alerting.
|
Full-stack website monitoring & performance auditing platform powered by **Google Lighthouse** — with real-time progress tracking, team collaboration, billing, and alerting.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ Website Monitoring │
|
│ CloudLense │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||||
│ │ Frontend │───▶│ Backend │───▶│ PostgreSQL (DB) │ │
|
│ │ Frontend │───▶│ Backend │───▶│ PostgreSQL (DB) │ │
|
||||||
@@ -32,9 +32,9 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit
|
|||||||
| **SEO Analysis** | ✅ Real | SEO score tracking and recommendations |
|
| **SEO Analysis** | ✅ Real | SEO score tracking and recommendations |
|
||||||
| **Uptime Monitoring** | ✅ Real | HTTP HEAD checks every 5 min, response time + SSL tracking |
|
| **Uptime Monitoring** | ✅ Real | HTTP HEAD checks every 5 min, response time + SSL tracking |
|
||||||
| **Alert Engine** | ✅ Real | Evaluates scans against thresholds, auto-resolves on recovery |
|
| **Alert Engine** | ✅ Real | Evaluates scans against thresholds, auto-resolves on recovery |
|
||||||
| **Notifications** | ✅ Real | Email (Resend) + webhook delivery with debouncing |
|
| **Notifications** | ✅ Real | SMTP email (info@dk0.dev) + webhook delivery with debouncing |
|
||||||
| **Admin Dashboard** | ✅ Real | System stats, user CRUD, org management (role-protected) |
|
| **Admin Dashboard** | ✅ Real | System stats, user CRUD, org management, payments, coupons, credits, invoices |
|
||||||
| **Billing & Usage** | ✅ Real | 4 tiers (free/starter/pro/enterprise), usage bars, limit enforcement |
|
| **Billing & Usage** | ✅ Real | 4 tiers (free/starter/pro/enterprise), usage bars, limit enforcement, coupon system |
|
||||||
| **Competitor Analysis** | ✅ Real | Lighthouse comparison + response time benchmarking |
|
| **Competitor Analysis** | ✅ Real | Lighthouse comparison + response time benchmarking |
|
||||||
| **Team/Organization** | ✅ Real | Multi-user orgs with 4-level RBAC |
|
| **Team/Organization** | ✅ Real | Multi-user orgs with 4-level RBAC |
|
||||||
| **Authentication** | ✅ Real | Supabase Auth (email, OAuth) |
|
| **Authentication** | ✅ Real | Supabase Auth (email, OAuth) |
|
||||||
@@ -55,7 +55,7 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit
|
|||||||
| Containers | Docker + Docker Compose | Free |
|
| Containers | Docker + Docker Compose | Free |
|
||||||
| Linting | ESLint + Prettier | Free (OSS) |
|
| Linting | ESLint + Prettier | Free (OSS) |
|
||||||
| Testing | Jest + Supertest + Testing Library | Free (OSS) |
|
| Testing | Jest + Supertest + Testing Library | Free (OSS) |
|
||||||
| Email | Resend | Free tier (3000/mo) |
|
| Email | SMTP (nodemailer) | Self-hosted |
|
||||||
| Pre-commit | Husky | Free (OSS) |
|
| Pre-commit | Husky | Free (OSS) |
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
@@ -71,7 +71,7 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit
|
|||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# Clone the repo
|
||||||
git clone <repo-url>
|
git clone <repo-url>
|
||||||
cd website-monitoring
|
cd cloudlense
|
||||||
|
|
||||||
# Install root dependencies (Husky, concurrently)
|
# Install root dependencies (Husky, concurrently)
|
||||||
npm install
|
npm install
|
||||||
@@ -108,7 +108,7 @@ npm run docker:up
|
|||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
website-monitoring/
|
cloudlense/
|
||||||
├── backend/ # Express.js API + Lighthouse engine
|
├── backend/ # Express.js API + Lighthouse engine
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── index.ts # Server entry, health check, routing
|
│ │ ├── index.ts # Server entry, health check, routing
|
||||||
@@ -173,6 +173,10 @@ npm run test:frontend
|
|||||||
| `/api/admin/stats` | GET | System-wide stats (admin only) |
|
| `/api/admin/stats` | GET | System-wide stats (admin only) |
|
||||||
| `/api/admin/users` | GET/PATCH/DELETE | User management (admin only) |
|
| `/api/admin/users` | GET/PATCH/DELETE | User management (admin only) |
|
||||||
| `/api/admin/organizations` | GET/PATCH | Organization management (admin only) |
|
| `/api/admin/organizations` | GET/PATCH | Organization management (admin only) |
|
||||||
|
| `/api/admin/payments` | GET/POST | Payment tracking & recording (admin only) |
|
||||||
|
| `/api/admin/coupons` | GET/POST/PATCH/DELETE | Coupon management (admin only) |
|
||||||
|
| `/api/admin/credits` | GET/POST | Account credit management (admin only) |
|
||||||
|
| `/api/admin/invoices` | GET/POST/PATCH | Invoice creation, sending & management (admin only) |
|
||||||
| `/api/billing/usage` | GET | Current org usage vs tier limits |
|
| `/api/billing/usage` | GET | Current org usage vs tier limits |
|
||||||
| `/api/competitor-analysis` | GET/POST | Competitor benchmarking |
|
| `/api/competitor-analysis` | GET/POST | Competitor benchmarking |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "website-monitoring-backend",
|
"name": "cloudlense-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
Generated
+21
@@ -28,6 +28,7 @@
|
|||||||
"lighthouse": "^12.6.1",
|
"lighthouse": "^12.6.1",
|
||||||
"lucide-react": "^0.477.0",
|
"lucide-react": "^0.477.0",
|
||||||
"next": "^15.2.4",
|
"next": "^15.2.4",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"puppeteer": "^24.7.0",
|
"puppeteer": "^24.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.0.1",
|
"@testing-library/react": "^16.0.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
@@ -3754,6 +3756,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "7.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||||
|
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/phoenix": {
|
"node_modules/@types/phoenix": {
|
||||||
"version": "1.6.6",
|
"version": "1.6.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||||
@@ -11705,6 +11717,15 @@
|
|||||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "website-monitoring",
|
"name": "cloudlense-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"lighthouse": "^12.6.1",
|
"lighthouse": "^12.6.1",
|
||||||
"lucide-react": "^0.477.0",
|
"lucide-react": "^0.477.0",
|
||||||
"next": "^15.2.4",
|
"next": "^15.2.4",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"puppeteer": "^24.7.0",
|
"puppeteer": "^24.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.0.1",
|
"@testing-library/react": "^16.0.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSupabaseAdmin } from "@/lib/admin";
|
||||||
|
import { requireAdmin } from "@/lib/apiAuth";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "20");
|
||||||
|
const activeOnly = searchParams.get("activeOnly") === "true";
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = supabase
|
||||||
|
.from("coupons")
|
||||||
|
.select("*, creator:users!coupons_created_by_fkey(id, email, name)", { count: "exact" })
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.range(offset, offset + limit - 1);
|
||||||
|
|
||||||
|
if (activeOnly) query = query.eq("is_active", true);
|
||||||
|
|
||||||
|
const { data: coupons, count, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
coupons: coupons || [],
|
||||||
|
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching coupons:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch coupons" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
description,
|
||||||
|
discount_type,
|
||||||
|
discount_value,
|
||||||
|
currency,
|
||||||
|
max_redemptions,
|
||||||
|
applicable_tiers,
|
||||||
|
valid_from,
|
||||||
|
valid_until,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!code || !discount_type || discount_value === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "code, discount_type, and discount_value are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discount_type === "percentage" && (discount_value < 1 || discount_value > 100)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Percentage discount must be between 1 and 100" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: coupon, error } = await supabase
|
||||||
|
.from("coupons")
|
||||||
|
.insert({
|
||||||
|
code: code.toUpperCase().trim(),
|
||||||
|
description,
|
||||||
|
discount_type,
|
||||||
|
discount_value: Math.round(discount_value),
|
||||||
|
currency: currency || "EUR",
|
||||||
|
max_redemptions: max_redemptions || null,
|
||||||
|
applicable_tiers: applicable_tiers || ["free", "starter", "professional"],
|
||||||
|
valid_from: valid_from || new Date().toISOString(),
|
||||||
|
valid_until: valid_until || null,
|
||||||
|
created_by: authResult.userId,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return NextResponse.json({ error: "A coupon with this code already exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ coupon });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating coupon:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create coupon" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, ...updates } = body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Coupon id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedFields = [
|
||||||
|
"description",
|
||||||
|
"discount_type",
|
||||||
|
"discount_value",
|
||||||
|
"max_redemptions",
|
||||||
|
"applicable_tiers",
|
||||||
|
"valid_from",
|
||||||
|
"valid_until",
|
||||||
|
"is_active",
|
||||||
|
];
|
||||||
|
|
||||||
|
const safeUpdates: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||||
|
for (const key of allowedFields) {
|
||||||
|
if (key in updates) safeUpdates[key] = updates[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.code) safeUpdates.code = updates.code.toUpperCase().trim();
|
||||||
|
|
||||||
|
const { data: coupon, error } = await supabase
|
||||||
|
.from("coupons")
|
||||||
|
.update(safeUpdates)
|
||||||
|
.eq("id", id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ coupon });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating coupon:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to update coupon" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Coupon id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from("coupons").delete().eq("id", id);
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting coupon:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to delete coupon" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSupabaseAdmin } from "@/lib/admin";
|
||||||
|
import { requireAdmin } from "@/lib/apiAuth";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const orgId = searchParams.get("orgId");
|
||||||
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "20");
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (orgId) {
|
||||||
|
// Get specific org's credit balance and transaction history
|
||||||
|
const [orgResult, transactionsResult] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from("organizations")
|
||||||
|
.select("id, name, credit_balance")
|
||||||
|
.eq("id", orgId)
|
||||||
|
.single(),
|
||||||
|
supabase
|
||||||
|
.from("credit_transactions")
|
||||||
|
.select(
|
||||||
|
"*, creator:users!credit_transactions_created_by_fkey(id, email, name)",
|
||||||
|
{ count: "exact" }
|
||||||
|
)
|
||||||
|
.eq("organization_id", orgId)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.range(offset, offset + limit - 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (orgResult.error) throw orgResult.error;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
organization: orgResult.data,
|
||||||
|
transactions: transactionsResult.data || [],
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: transactionsResult.count || 0,
|
||||||
|
pages: Math.ceil((transactionsResult.count || 0) / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all organizations with credit balances
|
||||||
|
const { data: orgs, count, error } = await supabase
|
||||||
|
.from("organizations")
|
||||||
|
.select("id, name, credit_balance, subscription_tier", { count: "exact" })
|
||||||
|
.order("credit_balance", { ascending: false })
|
||||||
|
.range(offset, offset + limit - 1);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
organizations: orgs || [],
|
||||||
|
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching credits:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch credits" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { organization_id, amount, type, reason, notes } = body;
|
||||||
|
|
||||||
|
if (!organization_id || amount === undefined || !type || !reason) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "organization_id, amount, type, and reason are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["credit", "debit"].includes(type)) {
|
||||||
|
return NextResponse.json({ error: "type must be 'credit' or 'debit'" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current balance
|
||||||
|
const { data: org, error: orgError } = await supabase
|
||||||
|
.from("organizations")
|
||||||
|
.select("id, name, credit_balance")
|
||||||
|
.eq("id", organization_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (orgError || !org) {
|
||||||
|
return NextResponse.json({ error: "Organization not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const absAmount = Math.abs(Math.round(amount));
|
||||||
|
const signedAmount = type === "credit" ? absAmount : -absAmount;
|
||||||
|
const currentBalance = typeof org.credit_balance === "number" ? org.credit_balance : 0;
|
||||||
|
const newBalance = currentBalance + signedAmount;
|
||||||
|
|
||||||
|
if (newBalance < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Insufficient balance. Current: ${currentBalance}, debit: ${absAmount}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert transaction and update balance atomically
|
||||||
|
const { data: transaction, error: txError } = await supabase
|
||||||
|
.from("credit_transactions")
|
||||||
|
.insert({
|
||||||
|
organization_id,
|
||||||
|
amount: signedAmount,
|
||||||
|
balance_after: newBalance,
|
||||||
|
type,
|
||||||
|
reason,
|
||||||
|
notes,
|
||||||
|
created_by: authResult.userId,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (txError) throw txError;
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("organizations")
|
||||||
|
.update({ credit_balance: newBalance })
|
||||||
|
.eq("id", organization_id);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
transaction,
|
||||||
|
new_balance: newBalance,
|
||||||
|
organization: { id: org.id, name: org.name },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing credit transaction:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to process credit transaction" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSupabaseAdmin } from "@/lib/admin";
|
||||||
|
import { requireAdmin } from "@/lib/apiAuth";
|
||||||
|
import { sendEmail } from "@/lib/email";
|
||||||
|
import { invoiceEmail } from "@/lib/email-templates";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "20");
|
||||||
|
const orgId = searchParams.get("orgId");
|
||||||
|
const status = searchParams.get("status");
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = supabase
|
||||||
|
.from("invoices")
|
||||||
|
.select(
|
||||||
|
"*, organization:organizations(id, name, billing_email), creator:users!invoices_created_by_fkey(id, email, name)",
|
||||||
|
{ count: "exact" }
|
||||||
|
)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.range(offset, offset + limit - 1);
|
||||||
|
|
||||||
|
if (orgId) query = query.eq("organization_id", orgId);
|
||||||
|
if (status) query = query.eq("status", status);
|
||||||
|
|
||||||
|
const { data: invoices, count, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
invoices: invoices || [],
|
||||||
|
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching invoices:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch invoices" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { organization_id, amount, currency, items, due_date, notes, status: invoiceStatus } = body;
|
||||||
|
|
||||||
|
if (!organization_id || amount === undefined || !items?.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "organization_id, amount, and items are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate invoice number: INV-YYYYMM-XXXX
|
||||||
|
const now = new Date();
|
||||||
|
const prefix = `INV-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const { count } = await supabase
|
||||||
|
.from("invoices")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.like("invoice_number", `${prefix}%`);
|
||||||
|
const seq = String((count || 0) + 1).padStart(4, "0");
|
||||||
|
|
||||||
|
const { data: invoice, error } = await supabase
|
||||||
|
.from("invoices")
|
||||||
|
.insert({
|
||||||
|
invoice_number: `${prefix}-${seq}`,
|
||||||
|
organization_id,
|
||||||
|
amount: Math.round(amount),
|
||||||
|
currency: currency || "EUR",
|
||||||
|
status: invoiceStatus || "draft",
|
||||||
|
items,
|
||||||
|
due_date: due_date || null,
|
||||||
|
notes,
|
||||||
|
created_by: authResult.userId,
|
||||||
|
})
|
||||||
|
.select("*, organization:organizations(id, name, billing_email)")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ invoice });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating invoice:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create invoice" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, action, ...updates } = body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Invoice id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send invoice via email
|
||||||
|
if (action === "send") {
|
||||||
|
const { data: invoice } = await supabase
|
||||||
|
.from("invoices")
|
||||||
|
.select("*, organization:organizations(id, name, billing_email)")
|
||||||
|
.eq("id", id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = invoice.organization as { id: string; name: string; billing_email?: string } | null;
|
||||||
|
const recipientEmail = org?.billing_email;
|
||||||
|
|
||||||
|
if (!recipientEmail) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Organization has no billing email configured" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||||
|
const template = invoiceEmail({
|
||||||
|
organizationName: org.name,
|
||||||
|
invoiceNumber: invoice.invoice_number,
|
||||||
|
amount: invoice.amount,
|
||||||
|
currency: invoice.currency,
|
||||||
|
dueDate: invoice.due_date,
|
||||||
|
items: (invoice.items as Array<{ description: string; amount: number }>) || [],
|
||||||
|
dashboardUrl: `${appUrl}/dashboard/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = await sendEmail({
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: template.subject,
|
||||||
|
html: template.html,
|
||||||
|
text: template.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sent) {
|
||||||
|
return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("invoices")
|
||||||
|
.update({ status: "sent", updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", id);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "Invoice sent" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as paid
|
||||||
|
if (action === "mark_paid") {
|
||||||
|
const { data: invoice, error } = await supabase
|
||||||
|
.from("invoices")
|
||||||
|
.update({ status: "paid", paid_at: new Date().toISOString(), updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return NextResponse.json({ invoice });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic update
|
||||||
|
const allowedFields = ["status", "amount", "items", "due_date", "notes"];
|
||||||
|
const safeUpdates: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||||
|
for (const key of allowedFields) {
|
||||||
|
if (key in updates) safeUpdates[key] = updates[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: invoice, error } = await supabase
|
||||||
|
.from("invoices")
|
||||||
|
.update(safeUpdates)
|
||||||
|
.eq("id", id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({ invoice });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating invoice:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to update invoice" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSupabaseAdmin } from "@/lib/admin";
|
||||||
|
import { requireAdmin } from "@/lib/apiAuth";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "20");
|
||||||
|
const orgId = searchParams.get("orgId");
|
||||||
|
const status = searchParams.get("status");
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = supabase
|
||||||
|
.from("payments")
|
||||||
|
.select(
|
||||||
|
"*, organization:organizations(id, name), creator:users!payments_created_by_fkey(id, email, name)",
|
||||||
|
{ count: "exact" }
|
||||||
|
)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.range(offset, offset + limit - 1);
|
||||||
|
|
||||||
|
if (orgId) query = query.eq("organization_id", orgId);
|
||||||
|
if (status) query = query.eq("status", status);
|
||||||
|
|
||||||
|
const { data: payments, count, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
payments: payments || [],
|
||||||
|
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching payments:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch payments" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authResult = await requireAdmin(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const supabase = getSupabaseAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { organization_id, amount, currency, status, method, description, notes } = body;
|
||||||
|
|
||||||
|
if (!organization_id || amount === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "organization_id and amount are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: payment, error } = await supabase
|
||||||
|
.from("payments")
|
||||||
|
.insert({
|
||||||
|
organization_id,
|
||||||
|
amount: Math.round(amount),
|
||||||
|
currency: currency || "EUR",
|
||||||
|
status: status || "completed",
|
||||||
|
method: method || "manual",
|
||||||
|
description,
|
||||||
|
notes,
|
||||||
|
created_by: authResult.userId,
|
||||||
|
})
|
||||||
|
.select("*, organization:organizations(id, name)")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// If payment is completed, optionally add credits
|
||||||
|
if (payment.status === "completed" && body.add_as_credit) {
|
||||||
|
const { data: org } = await supabase
|
||||||
|
.from("organizations")
|
||||||
|
.select("credit_balance")
|
||||||
|
.eq("id", organization_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const currentBalance = typeof org?.credit_balance === "number" ? org.credit_balance : 0;
|
||||||
|
const newBalance = currentBalance + Math.round(amount);
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("credit_transactions")
|
||||||
|
.insert({
|
||||||
|
organization_id,
|
||||||
|
amount: Math.round(amount),
|
||||||
|
balance_after: newBalance,
|
||||||
|
type: "credit",
|
||||||
|
reason: "payment",
|
||||||
|
reference_id: payment.id,
|
||||||
|
created_by: authResult.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("organizations")
|
||||||
|
.update({ credit_balance: newBalance })
|
||||||
|
.eq("id", organization_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ payment });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating payment:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create payment" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Ticket,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Coupon {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
description: string | null;
|
||||||
|
discount_type: string;
|
||||||
|
discount_value: number;
|
||||||
|
currency: string;
|
||||||
|
max_redemptions: number | null;
|
||||||
|
current_redemptions: number;
|
||||||
|
applicable_tiers: string[];
|
||||||
|
valid_from: string;
|
||||||
|
valid_until: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
creator: { id: string; email: string; name: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CouponsTab() {
|
||||||
|
const [coupons, setCoupons] = useState<Coupon[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingCoupon, setEditingCoupon] = useState<Coupon | null>(null);
|
||||||
|
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
code: "",
|
||||||
|
description: "",
|
||||||
|
discount_type: "percentage" as string,
|
||||||
|
discount_value: "",
|
||||||
|
currency: "EUR",
|
||||||
|
max_redemptions: "",
|
||||||
|
applicable_tiers: ["free", "starter", "professional"] as string[],
|
||||||
|
valid_until: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchCoupons = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/coupons?page=${page}&limit=15`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setCoupons(data.coupons);
|
||||||
|
setTotalPages(data.pagination.pages);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch coupons:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCoupons();
|
||||||
|
}, [fetchCoupons]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
code: "",
|
||||||
|
description: "",
|
||||||
|
discount_type: "percentage",
|
||||||
|
discount_value: "",
|
||||||
|
currency: "EUR",
|
||||||
|
max_redemptions: "",
|
||||||
|
applicable_tiers: ["free", "starter", "professional"],
|
||||||
|
valid_until: "",
|
||||||
|
});
|
||||||
|
setEditingCoupon(null);
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
discount_value:
|
||||||
|
formData.discount_type === "fixed_amount"
|
||||||
|
? Math.round(parseFloat(formData.discount_value) * 100)
|
||||||
|
: parseInt(formData.discount_value),
|
||||||
|
max_redemptions: formData.max_redemptions ? parseInt(formData.max_redemptions) : null,
|
||||||
|
valid_until: formData.valid_until || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCoupon) {
|
||||||
|
const res = await fetch("/api/admin/coupons", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: editingCoupon.id, ...payload }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update");
|
||||||
|
} else {
|
||||||
|
const res = await fetch("/api/admin/coupons", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || "Failed to create coupon");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
fetchCoupons();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save coupon:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Delete this coupon? This cannot be undone.")) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/admin/coupons?id=${id}`, { method: "DELETE" });
|
||||||
|
fetchCoupons();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to delete coupon:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (coupon: Coupon) => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/admin/coupons", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: coupon.id, is_active: !coupon.is_active }),
|
||||||
|
});
|
||||||
|
fetchCoupons();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to toggle coupon:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (coupon: Coupon) => {
|
||||||
|
setFormData({
|
||||||
|
code: coupon.code,
|
||||||
|
description: coupon.description || "",
|
||||||
|
discount_type: coupon.discount_type,
|
||||||
|
discount_value:
|
||||||
|
coupon.discount_type === "fixed_amount"
|
||||||
|
? String(coupon.discount_value / 100)
|
||||||
|
: String(coupon.discount_value),
|
||||||
|
currency: coupon.currency,
|
||||||
|
max_redemptions: coupon.max_redemptions ? String(coupon.max_redemptions) : "",
|
||||||
|
applicable_tiers: coupon.applicable_tiers || ["free", "starter", "professional"],
|
||||||
|
valid_until: coupon.valid_until ? coupon.valid_until.slice(0, 16) : "",
|
||||||
|
});
|
||||||
|
setEditingCoupon(coupon);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyCode = (code: string) => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopiedCode(code);
|
||||||
|
setTimeout(() => setCopiedCode(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDiscount = (coupon: Coupon) => {
|
||||||
|
if (coupon.discount_type === "percentage") return `${coupon.discount_value}%`;
|
||||||
|
if (coupon.discount_type === "fixed_amount") {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: coupon.currency,
|
||||||
|
}).format(coupon.discount_value / 100);
|
||||||
|
}
|
||||||
|
return "Tier Upgrade";
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierToggle = (tier: string) => {
|
||||||
|
const tiers = formData.applicable_tiers.includes(tier)
|
||||||
|
? formData.applicable_tiers.filter((t) => t !== tier)
|
||||||
|
: [...formData.applicable_tiers, tier];
|
||||||
|
setFormData({ ...formData, applicable_tiers: tiers });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setShowForm(!showForm);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Coupon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
|
{editingCoupon ? "Edit Coupon" : "Create New Coupon"}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Code *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="e.g. WELCOME20"
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 font-mono uppercase focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Discount Type *</label>
|
||||||
|
<select
|
||||||
|
value={formData.discount_type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, discount_type: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="percentage">Percentage (%)</option>
|
||||||
|
<option value="fixed_amount">Fixed Amount (€)</option>
|
||||||
|
<option value="tier_upgrade">Tier Upgrade</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{formData.discount_type === "percentage" ? "Discount (%)" : "Amount (€)"} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={formData.discount_type === "percentage" ? "1" : "0.01"}
|
||||||
|
max={formData.discount_type === "percentage" ? "100" : undefined}
|
||||||
|
step={formData.discount_type === "percentage" ? "1" : "0.01"}
|
||||||
|
value={formData.discount_value}
|
||||||
|
onChange={(e) => setFormData({ ...formData, discount_value: e.target.value })}
|
||||||
|
placeholder={formData.discount_type === "percentage" ? "20" : "9.99"}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Max Redemptions</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formData.max_redemptions}
|
||||||
|
onChange={(e) => setFormData({ ...formData, max_redemptions: e.target.value })}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Expires At</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.valid_until}
|
||||||
|
onChange={(e) => setFormData({ ...formData, valid_until: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Applicable Tiers</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{["free", "starter", "professional", "enterprise"].map((tier) => (
|
||||||
|
<button
|
||||||
|
key={tier}
|
||||||
|
type="button"
|
||||||
|
onClick={() => tierToggle(tier)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||||
|
formData.applicable_tiers.includes(tier)
|
||||||
|
? "bg-blue-100 border-blue-300 text-blue-800"
|
||||||
|
: "bg-gray-50 border-gray-200 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tier}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="e.g. Welcome discount for new users"
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetForm}
|
||||||
|
className="px-4 py-2 text-sm text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{editingCoupon ? "Update Coupon" : "Create Coupon"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coupons Table */}
|
||||||
|
<div className="bg-white rounded-lg border overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Code</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Discount</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Redemptions</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Tiers</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Expires</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Status</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{coupons.map((coupon) => {
|
||||||
|
const isExpired = coupon.valid_until && new Date(coupon.valid_until) < new Date();
|
||||||
|
const isMaxed =
|
||||||
|
coupon.max_redemptions !== null &&
|
||||||
|
coupon.current_redemptions >= coupon.max_redemptions;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={coupon.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-sm font-mono font-semibold bg-gray-100 px-2 py-0.5 rounded">
|
||||||
|
{coupon.code}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyCode(coupon.code)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
title="Copy code"
|
||||||
|
>
|
||||||
|
{copiedCode === coupon.code ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{coupon.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{coupon.description}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
|
||||||
|
{formatDiscount(coupon)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{coupon.current_redemptions}
|
||||||
|
{coupon.max_redemptions !== null ? ` / ${coupon.max_redemptions}` : " / ∞"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{(coupon.applicable_tiers || []).map((tier) => (
|
||||||
|
<span key={tier} className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
|
||||||
|
{tier}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{coupon.valid_until
|
||||||
|
? new Date(coupon.valid_until).toLocaleDateString()
|
||||||
|
: "Never"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(coupon)}
|
||||||
|
className={`inline-flex px-2 py-1 text-xs font-medium rounded-full cursor-pointer ${
|
||||||
|
!coupon.is_active || isExpired || isMaxed
|
||||||
|
? "bg-red-100 text-red-800"
|
||||||
|
: "bg-green-100 text-green-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!coupon.is_active
|
||||||
|
? "Inactive"
|
||||||
|
: isExpired
|
||||||
|
? "Expired"
|
||||||
|
: isMaxed
|
||||||
|
? "Maxed Out"
|
||||||
|
: "Active"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(coupon)}
|
||||||
|
className="p-1 text-gray-400 hover:text-blue-600"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(coupon.id)}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading && coupons.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
<Ticket className="h-8 w-8 text-gray-300 mx-auto mb-2" />
|
||||||
|
No coupons created yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600">Page {page} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Wallet,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface OrgCredit {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
credit_balance: number;
|
||||||
|
subscription_tier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditTransaction {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
amount: number;
|
||||||
|
balance_after: number;
|
||||||
|
type: string;
|
||||||
|
reason: string;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
creator: { id: string; email: string; name: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminOrg {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreditsTab({ organizations }: { organizations: AdminOrg[] }) {
|
||||||
|
const [orgCredits, setOrgCredits] = useState<OrgCredit[]>([]);
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
|
||||||
|
const [transactions, setTransactions] = useState<CreditTransaction[]>([]);
|
||||||
|
const [selectedOrgData, setSelectedOrgData] = useState<{ id: string; name: string; credit_balance: number } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
organization_id: "",
|
||||||
|
amount: "",
|
||||||
|
type: "credit" as "credit" | "debit",
|
||||||
|
reason: "manual_credit",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchCredits = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (selectedOrg) {
|
||||||
|
const res = await fetch(`/api/admin/credits?orgId=${selectedOrg}&page=${page}&limit=20`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSelectedOrgData(data.organization);
|
||||||
|
setTransactions(data.transactions);
|
||||||
|
setTotalPages(data.pagination.pages);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/admin/credits?page=${page}&limit=20`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setOrgCredits(data.organizations);
|
||||||
|
setTotalPages(data.pagination.pages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch credits:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedOrg, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredits();
|
||||||
|
}, [fetchCredits]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/credits", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...formData,
|
||||||
|
amount: Math.round(parseFloat(formData.amount) * 100),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setShowForm(false);
|
||||||
|
setFormData({
|
||||||
|
organization_id: selectedOrg || "",
|
||||||
|
amount: "",
|
||||||
|
type: "credit",
|
||||||
|
reason: "manual_credit",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
fetchCredits();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || "Failed to process transaction");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to process credit:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(amount / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierColors: Record<string, string> = {
|
||||||
|
free: "bg-gray-100 text-gray-700",
|
||||||
|
starter: "bg-blue-100 text-blue-700",
|
||||||
|
professional: "bg-purple-100 text-purple-700",
|
||||||
|
enterprise: "bg-amber-100 text-amber-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredOrgs = searchTerm
|
||||||
|
? orgCredits.filter((o) => o.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
: orgCredits;
|
||||||
|
|
||||||
|
// Detail view for a single org
|
||||||
|
if (selectedOrg && selectedOrgData) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Back + Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedOrg(null); setPage(1); }}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{selectedOrgData.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Balance: <span className="font-semibold text-gray-900">{formatAmount(selectedOrgData.credit_balance)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, organization_id: selectedOrg, type: "credit" });
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Credit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, organization_id: selectedOrg, type: "debit" });
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg text-sm hover:bg-orange-700"
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
Deduct Credit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
|
{formData.type === "credit" ? "Add Credit" : "Deduct Credit"}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (EUR) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
required
|
||||||
|
value={formData.amount}
|
||||||
|
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Reason *</label>
|
||||||
|
<select
|
||||||
|
value={formData.reason}
|
||||||
|
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{formData.type === "credit" ? (
|
||||||
|
<>
|
||||||
|
<option value="manual_credit">Manual Credit</option>
|
||||||
|
<option value="refund">Refund</option>
|
||||||
|
<option value="coupon_redemption">Coupon Redemption</option>
|
||||||
|
<option value="compensation">Compensation</option>
|
||||||
|
<option value="promotion">Promotion</option>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="usage_charge">Usage Charge</option>
|
||||||
|
<option value="adjustment">Adjustment</option>
|
||||||
|
<option value="overage_fee">Overage Fee</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Optional notes..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="px-4 py-2 text-sm text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`px-4 py-2 text-sm text-white rounded-lg ${
|
||||||
|
formData.type === "credit"
|
||||||
|
? "bg-green-600 hover:bg-green-700"
|
||||||
|
: "bg-orange-600 hover:bg-orange-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formData.type === "credit" ? "Add Credit" : "Deduct Credit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transactions */}
|
||||||
|
<div className="bg-white rounded-lg border overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Type</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Amount</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Balance After</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Reason</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">By</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<tr key={tx.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{tx.type === "credit" ? (
|
||||||
|
<span className="flex items-center gap-1 text-sm text-green-700">
|
||||||
|
<ArrowUpRight className="h-4 w-4" /> Credit
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-sm text-red-700">
|
||||||
|
<ArrowDownRight className="h-4 w-4" /> Debit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`px-6 py-4 text-sm font-semibold ${tx.amount >= 0 ? "text-green-700" : "text-red-700"}`}>
|
||||||
|
{tx.amount >= 0 ? "+" : ""}{formatAmount(tx.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">{formatAmount(tx.balance_after)}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-sm text-gray-600">{tx.reason.replace(/_/g, " ")}</span>
|
||||||
|
{tx.notes && <p className="text-xs text-gray-400 mt-0.5">{tx.notes}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{tx.creator?.name || tx.creator?.email || "System"}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">{new Date(tx.created_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && transactions.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
No transactions yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50">Previous</button>
|
||||||
|
<span className="text-sm text-gray-600">Page {page} of {totalPages}</span>
|
||||||
|
<button onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page === totalPages} className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50">Next</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview: all organizations with credit balances
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search organizations..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, type: "credit", organization_id: "" });
|
||||||
|
setShowForm(!showForm);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Credit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Add Credit form */}
|
||||||
|
{showForm && !selectedOrg && (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Add / Deduct Credit</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Organization *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={formData.organization_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, organization_id: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select organization...</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as "credit" | "debit" })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="credit">Credit (Add)</option>
|
||||||
|
<option value="debit">Debit (Deduct)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (EUR) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
required
|
||||||
|
value={formData.amount}
|
||||||
|
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Reason</label>
|
||||||
|
<select
|
||||||
|
value={formData.reason}
|
||||||
|
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="manual_credit">Manual Credit</option>
|
||||||
|
<option value="refund">Refund</option>
|
||||||
|
<option value="coupon_redemption">Coupon Redemption</option>
|
||||||
|
<option value="compensation">Compensation</option>
|
||||||
|
<option value="promotion">Promotion</option>
|
||||||
|
<option value="usage_charge">Usage Charge</option>
|
||||||
|
<option value="adjustment">Adjustment</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Optional notes..."
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-sm text-gray-700 border rounded-lg hover:bg-gray-50">Cancel</button>
|
||||||
|
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">Process</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Org Balances Table */}
|
||||||
|
<div className="bg-white rounded-lg border overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Organization</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Tier</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Credit Balance</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{filteredOrgs.map((org) => (
|
||||||
|
<tr key={org.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{org.name}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${tierColors[org.subscription_tier] || tierColors.free}`}>
|
||||||
|
{org.subscription_tier}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`text-sm font-semibold ${org.credit_balance > 0 ? "text-green-700" : "text-gray-600"}`}>
|
||||||
|
{formatAmount(org.credit_balance)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedOrg(org.id); setPage(1); }}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||||
|
>
|
||||||
|
View Transactions →
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && filteredOrgs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
<Wallet className="h-8 w-8 text-gray-300 mx-auto mb-2" />
|
||||||
|
No organizations found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50">Previous</button>
|
||||||
|
<span className="text-sm text-gray-600">Page {page} of {totalPages}</span>
|
||||||
|
<button onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page === totalPages} className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50">Next</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Send,
|
||||||
|
CheckCircle,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
invoice_number: string;
|
||||||
|
organization_id: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
items: Array<{ description: string; quantity: number; unit_price: number; total: number }>;
|
||||||
|
due_date: string | null;
|
||||||
|
paid_at: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
organization: { id: string; name: string; billing_email?: string } | null;
|
||||||
|
creator: { id: string; email: string; name: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminOrg {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoicesTab({ organizations }: { organizations: AdminOrg[] }) {
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [filterStatus, setFilterStatus] = useState("");
|
||||||
|
const [filterOrg, setFilterOrg] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
organization_id: "",
|
||||||
|
currency: "EUR",
|
||||||
|
due_date: "",
|
||||||
|
notes: "",
|
||||||
|
items: [{ description: "", quantity: 1, unit_price: "" }] as Array<{
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: string;
|
||||||
|
}>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchInvoices = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: "15" });
|
||||||
|
if (filterOrg) params.set("orgId", filterOrg);
|
||||||
|
if (filterStatus) params.set("status", filterStatus);
|
||||||
|
const res = await fetch(`/api/admin/invoices?${params}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setInvoices(data.invoices);
|
||||||
|
setTotalPages(data.pagination.pages);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch invoices:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, filterOrg, filterStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInvoices();
|
||||||
|
}, [fetchInvoices]);
|
||||||
|
|
||||||
|
const addLineItem = () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: [...formData.items, { description: "", quantity: 1, unit_price: "" }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLineItem = (index: number) => {
|
||||||
|
if (formData.items.length <= 1) return;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: formData.items.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLineItem = (index: number, field: string, value: string | number) => {
|
||||||
|
const items = [...formData.items];
|
||||||
|
items[index] = { ...items[index], [field]: value };
|
||||||
|
setFormData({ ...formData, items });
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = () => {
|
||||||
|
return formData.items.reduce((sum, item) => {
|
||||||
|
return sum + item.quantity * (parseFloat(item.unit_price) || 0);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const items = formData.items.map((item) => ({
|
||||||
|
description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: Math.round(parseFloat(item.unit_price) * 100),
|
||||||
|
total: Math.round(item.quantity * parseFloat(item.unit_price) * 100),
|
||||||
|
amount: Math.round(item.quantity * parseFloat(item.unit_price) * 100),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await fetch("/api/admin/invoices", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
organization_id: formData.organization_id,
|
||||||
|
amount: Math.round(calculateTotal() * 100),
|
||||||
|
currency: formData.currency,
|
||||||
|
items,
|
||||||
|
due_date: formData.due_date || null,
|
||||||
|
notes: formData.notes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setShowForm(false);
|
||||||
|
setFormData({
|
||||||
|
organization_id: "",
|
||||||
|
currency: "EUR",
|
||||||
|
due_date: "",
|
||||||
|
notes: "",
|
||||||
|
items: [{ description: "", quantity: 1, unit_price: "" }],
|
||||||
|
});
|
||||||
|
fetchInvoices();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create invoice:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (id: string, action: string) => {
|
||||||
|
if (action === "send" && !confirm("Send this invoice via email?")) return;
|
||||||
|
if (action === "mark_paid" && !confirm("Mark this invoice as paid?")) return;
|
||||||
|
|
||||||
|
setActionLoading(id);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/invoices", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id, action }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchInvoices();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || "Action failed");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Action failed:", e);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: "bg-gray-100 text-gray-700",
|
||||||
|
sent: "bg-blue-100 text-blue-700",
|
||||||
|
paid: "bg-green-100 text-green-700",
|
||||||
|
overdue: "bg-red-100 text-red-700",
|
||||||
|
cancelled: "bg-gray-100 text-gray-500",
|
||||||
|
refunded: "bg-yellow-100 text-yellow-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={filterOrg}
|
||||||
|
onChange={(e) => { setFilterOrg(e.target.value); setPage(1); }}
|
||||||
|
className="text-sm border rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Organizations</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
|
||||||
|
className="text-sm border rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="sent">Sent</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
<option value="overdue">Overdue</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Invoice Form */}
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Create New Invoice</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Organization *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={formData.organization_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, organization_id: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select organization...</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.due_date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Optional notes..."
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Line Items</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{formData.items.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => updateLineItem(index, "description", e.target.value)}
|
||||||
|
placeholder="Description"
|
||||||
|
className="flex-1 text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateLineItem(index, "quantity", parseInt(e.target.value) || 1)}
|
||||||
|
className="w-20 text-sm border rounded-md px-3 py-2 text-center focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Qty"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
value={item.unit_price}
|
||||||
|
onChange={(e) => updateLineItem(index, "unit_price", e.target.value)}
|
||||||
|
className="w-28 text-sm border rounded-md px-3 py-2 text-right focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Price (€)"
|
||||||
|
/>
|
||||||
|
<span className="w-24 text-sm font-medium text-gray-700 text-right">
|
||||||
|
€{(item.quantity * (parseFloat(item.unit_price) || 0)).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeLineItem(index)}
|
||||||
|
disabled={formData.items.length <= 1}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addLineItem}
|
||||||
|
className="mt-2 text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||||
|
>
|
||||||
|
+ Add line item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t">
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
Total: €{calculateTotal().toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="px-4 py-2 text-sm text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Create Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invoices Table */}
|
||||||
|
<div className="bg-white rounded-lg border overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Invoice</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Organization</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Amount</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Status</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Due Date</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Created</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<tr key={invoice.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="text-sm font-mono font-semibold text-gray-900">{invoice.invoice_number}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{invoice.organization?.name || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
|
||||||
|
{formatAmount(invoice.amount, invoice.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${statusColors[invoice.status] || "bg-gray-100 text-gray-800"}`}>
|
||||||
|
{invoice.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{invoice.due_date
|
||||||
|
? new Date(invoice.due_date).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{new Date(invoice.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{(invoice.status === "draft" || invoice.status === "sent") && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(invoice.id, "send")}
|
||||||
|
disabled={actionLoading === invoice.id}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-blue-600 disabled:opacity-50"
|
||||||
|
title="Send via email"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{invoice.status !== "paid" && invoice.status !== "cancelled" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(invoice.id, "mark_paid")}
|
||||||
|
disabled={actionLoading === invoice.id}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-green-600 disabled:opacity-50"
|
||||||
|
title="Mark as paid"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && invoices.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
<FileText className="h-8 w-8 text-gray-300 mx-auto mb-2" />
|
||||||
|
No invoices found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600">Page {page} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Payment {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
method: string | null;
|
||||||
|
description: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
stripe_payment_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
organization: { id: string; name: string } | null;
|
||||||
|
creator: { id: string; email: string; name: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminOrg {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentsTab({ organizations }: { organizations: AdminOrg[] }) {
|
||||||
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [filterOrg, setFilterOrg] = useState("");
|
||||||
|
const [filterStatus, setFilterStatus] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
organization_id: "",
|
||||||
|
amount: "",
|
||||||
|
currency: "EUR",
|
||||||
|
status: "completed",
|
||||||
|
method: "manual",
|
||||||
|
description: "",
|
||||||
|
notes: "",
|
||||||
|
add_as_credit: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchPayments = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: "15" });
|
||||||
|
if (filterOrg) params.set("orgId", filterOrg);
|
||||||
|
if (filterStatus) params.set("status", filterStatus);
|
||||||
|
const res = await fetch(`/api/admin/payments?${params}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPayments(data.payments);
|
||||||
|
setTotalPages(data.pagination.pages);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch payments:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, filterOrg, filterStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPayments();
|
||||||
|
}, [fetchPayments]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/payments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...formData,
|
||||||
|
amount: Math.round(parseFloat(formData.amount) * 100),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setShowForm(false);
|
||||||
|
setFormData({
|
||||||
|
organization_id: "",
|
||||||
|
amount: "",
|
||||||
|
currency: "EUR",
|
||||||
|
status: "completed",
|
||||||
|
method: "manual",
|
||||||
|
description: "",
|
||||||
|
notes: "",
|
||||||
|
add_as_credit: false,
|
||||||
|
});
|
||||||
|
fetchPayments();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create payment:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
}).format(amount / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
completed: "bg-green-100 text-green-800",
|
||||||
|
pending: "bg-yellow-100 text-yellow-800",
|
||||||
|
failed: "bg-red-100 text-red-800",
|
||||||
|
refunded: "bg-gray-100 text-gray-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={filterOrg}
|
||||||
|
onChange={(e) => { setFilterOrg(e.target.value); setPage(1); }}
|
||||||
|
className="text-sm border rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Organizations</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
|
||||||
|
className="text-sm border rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
<option value="refunded">Refunded</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Record Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Payment Form */}
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Record New Payment</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Organization *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={formData.organization_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, organization_id: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select organization...</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Amount ({formData.currency}) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
value={formData.amount}
|
||||||
|
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Method</label>
|
||||||
|
<select
|
||||||
|
value={formData.method}
|
||||||
|
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
<option value="bank_transfer">Bank Transfer</option>
|
||||||
|
<option value="stripe">Stripe</option>
|
||||||
|
<option value="paypal">PayPal</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
<option value="refunded">Refunded</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Payment description..."
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Internal Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Admin notes (not visible to customer)..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.add_as_credit}
|
||||||
|
onChange={(e) => setFormData({ ...formData, add_as_credit: e.target.checked })}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
Also add this amount as account credit
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="px-4 py-2 text-sm text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Record Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payments Table */}
|
||||||
|
<div className="bg-white rounded-lg border overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Organization</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Amount</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Status</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Method</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Description</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{payments.map((payment) => (
|
||||||
|
<tr key={payment.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||||||
|
{payment.organization?.name || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
|
||||||
|
{formatAmount(payment.amount, payment.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${statusColors[payment.status] || "bg-gray-100 text-gray-800"}`}>
|
||||||
|
{payment.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">{payment.method || "—"}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{payment.description || "—"}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{new Date(payment.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && payments.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
<CreditCard className="h-8 w-8 text-gray-300 mx-auto mb-2" />
|
||||||
|
No payments found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600">Page {page} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,15 @@ import {
|
|||||||
UserX,
|
UserX,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
CreditCard,
|
||||||
|
Ticket,
|
||||||
|
Wallet,
|
||||||
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { PaymentsTab } from "./_components/PaymentsTab";
|
||||||
|
import { CouponsTab } from "./_components/CouponsTab";
|
||||||
|
import { CreditsTab } from "./_components/CreditsTab";
|
||||||
|
import { InvoicesTab } from "./_components/InvoicesTab";
|
||||||
|
|
||||||
interface SystemStats {
|
interface SystemStats {
|
||||||
users: number;
|
users: number;
|
||||||
@@ -54,7 +62,7 @@ interface AdminOrg {
|
|||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const { userDetails } = useDashboardData({ requireOrganization: false });
|
const { userDetails } = useDashboardData({ requireOrganization: false });
|
||||||
const [activeTab, setActiveTab] = useState<"overview" | "users" | "organizations">("overview");
|
const [activeTab, setActiveTab] = useState<"overview" | "users" | "organizations" | "payments" | "coupons" | "credits" | "invoices">("overview");
|
||||||
const [stats, setStats] = useState<SystemStats | null>(null);
|
const [stats, setStats] = useState<SystemStats | null>(null);
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [orgs, setOrgs] = useState<AdminOrg[]>([]);
|
const [orgs, setOrgs] = useState<AdminOrg[]>([]);
|
||||||
@@ -180,6 +188,10 @@ export default function AdminDashboard() {
|
|||||||
{ id: "overview" as const, label: "Overview", icon: BarChart3 },
|
{ id: "overview" as const, label: "Overview", icon: BarChart3 },
|
||||||
{ id: "users" as const, label: "Users", icon: Users },
|
{ id: "users" as const, label: "Users", icon: Users },
|
||||||
{ id: "organizations" as const, label: "Organizations", icon: Building2 },
|
{ id: "organizations" as const, label: "Organizations", icon: Building2 },
|
||||||
|
{ id: "payments" as const, label: "Payments", icon: CreditCard },
|
||||||
|
{ id: "coupons" as const, label: "Coupons", icon: Ticket },
|
||||||
|
{ id: "credits" as const, label: "Credits", icon: Wallet },
|
||||||
|
{ id: "invoices" as const, label: "Invoices", icon: FileText },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -399,6 +411,24 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Payments Tab */}
|
||||||
|
{activeTab === "payments" && (
|
||||||
|
<PaymentsTab organizations={orgs.map((o) => ({ id: o.id, name: o.name }))} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coupons Tab */}
|
||||||
|
{activeTab === "coupons" && <CouponsTab />}
|
||||||
|
|
||||||
|
{/* Credits Tab */}
|
||||||
|
{activeTab === "credits" && (
|
||||||
|
<CreditsTab organizations={orgs.map((o) => ({ id: o.id, name: o.name }))} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invoices Tab */}
|
||||||
|
{activeTab === "invoices" && (
|
||||||
|
<InvoicesTab organizations={orgs.map((o) => ({ id: o.id, name: o.name }))} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
const BRAND_COLOR = "#2563eb";
|
||||||
|
const BRAND_NAME = "CloudLense";
|
||||||
|
const FOOTER_TEXT = `© ${new Date().getFullYear()} ${BRAND_NAME}. All rights reserved.`;
|
||||||
|
|
||||||
|
function baseLayout(content: string, preheader?: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${BRAND_NAME}</title>
|
||||||
|
${preheader ? `<span style="display:none;font-size:1px;color:#fff;max-height:0;overflow:hidden">${preheader}</span>` : ""}
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 32px;text-align:center;">
|
||||||
|
<span style="font-size:24px;font-weight:700;color:${BRAND_COLOR};letter-spacing:-0.5px;">⬡ ${BRAND_NAME}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;border-radius:12px;padding:40px 32px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||||
|
${content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 32px;text-align:center;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#9ca3af;">${FOOTER_TEXT}</p>
|
||||||
|
<p style="margin:4px 0 0;font-size:12px;color:#9ca3af;">
|
||||||
|
<a href="\${appUrl}" style="color:#6b7280;text-decoration:none;">Dashboard</a>
|
||||||
|
·
|
||||||
|
<a href="\${appUrl}/dashboard/settings" style="color:#6b7280;text-decoration:none;">Settings</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(text: string, url: string): string {
|
||||||
|
return `<a href="${url}" style="display:inline-block;padding:12px 28px;background:${BRAND_COLOR};color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px;margin:8px 0;">${text}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ALERT EMAIL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface AlertEmailData {
|
||||||
|
severity: string;
|
||||||
|
websiteName: string;
|
||||||
|
websiteUrl: string;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertEmail(data: AlertEmailData): { subject: string; html: string; text: string } {
|
||||||
|
const severityColors: Record<string, string> = {
|
||||||
|
critical: "#dc2626",
|
||||||
|
high: "#ea580c",
|
||||||
|
medium: "#f59e0b",
|
||||||
|
low: "#3b82f6",
|
||||||
|
};
|
||||||
|
const color = severityColors[data.severity] || severityColors.medium;
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div style="background:${color};color:#fff;padding:12px 20px;border-radius:8px;margin-bottom:24px;">
|
||||||
|
<strong style="font-size:14px;text-transform:uppercase;letter-spacing:0.5px;">${data.severity} Alert</strong>
|
||||||
|
</div>
|
||||||
|
<h2 style="margin:0 0 8px;font-size:20px;color:#111827;">${data.type}</h2>
|
||||||
|
<p style="margin:0 0 24px;color:#6b7280;font-size:15px;">${data.message}</p>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f3f4f6;">
|
||||||
|
<span style="color:#6b7280;font-size:13px;">Website</span><br>
|
||||||
|
<strong style="color:#111827;font-size:14px;">${data.websiteName}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f3f4f6;">
|
||||||
|
<span style="color:#6b7280;font-size:13px;">URL</span><br>
|
||||||
|
<a href="${data.websiteUrl}" style="color:${BRAND_COLOR};font-size:14px;text-decoration:none;">${data.websiteUrl}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;">
|
||||||
|
<span style="color:#6b7280;font-size:13px;">Time</span><br>
|
||||||
|
<strong style="color:#111827;font-size:14px;">${new Date(data.timestamp).toLocaleString()}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
${button("View in Dashboard", data.dashboardUrl)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `[${data.severity.toUpperCase()}] ${data.type}: ${data.websiteName}`,
|
||||||
|
html: baseLayout(content, `${data.severity.toUpperCase()} alert for ${data.websiteName}`),
|
||||||
|
text: `${data.severity.toUpperCase()} ALERT: ${data.type}\n\nWebsite: ${data.websiteName}\nURL: ${data.websiteUrl}\nMessage: ${data.message}\nTime: ${data.timestamp}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WELCOME EMAIL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface WelcomeEmailData {
|
||||||
|
userName: string;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function welcomeEmail(data: WelcomeEmailData): { subject: string; html: string; text: string } {
|
||||||
|
const content = `
|
||||||
|
<h1 style="margin:0 0 8px;font-size:24px;color:#111827;">Welcome to ${BRAND_NAME}!</h1>
|
||||||
|
<p style="margin:0 0 24px;color:#6b7280;font-size:15px;line-height:1.6;">
|
||||||
|
Hi ${data.userName || "there"}, thanks for signing up. Your account is ready to go.
|
||||||
|
</p>
|
||||||
|
<div style="background:#f8fafc;border-radius:8px;padding:20px;margin-bottom:24px;">
|
||||||
|
<h3 style="margin:0 0 12px;font-size:15px;color:#111827;">Get started in 3 steps:</h3>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><td style="padding:6px 0;color:#374151;font-size:14px;">1. Add your first website to monitor</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#374151;font-size:14px;">2. Run your first performance scan</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#374151;font-size:14px;">3. Set up alerts to stay informed</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
${button("Go to Dashboard", data.dashboardUrl)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Welcome to ${BRAND_NAME}!`,
|
||||||
|
html: baseLayout(content, `Welcome to ${BRAND_NAME} — let's get started`),
|
||||||
|
text: `Welcome to ${BRAND_NAME}!\n\nHi ${data.userName}, your account is ready.\n\n1. Add your first website\n2. Run your first scan\n3. Set up alerts\n\nDashboard: ${data.dashboardUrl}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INVOICE EMAIL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface InvoiceEmailData {
|
||||||
|
organizationName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
dueDate?: string;
|
||||||
|
items: Array<{ description: string; amount: number }>;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invoiceEmail(data: InvoiceEmailData): { subject: string; html: string; text: string } {
|
||||||
|
const formatAmount = (cents: number) =>
|
||||||
|
new Intl.NumberFormat("en-US", { style: "currency", currency: data.currency }).format(cents / 100);
|
||||||
|
|
||||||
|
const itemRows = data.items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#374151;font-size:14px;">${item.description}</td>
|
||||||
|
<td style="padding:8px 0;border-bottom:1px solid #f3f4f6;color:#111827;font-size:14px;text-align:right;font-weight:600;">${formatAmount(item.amount)}</td>
|
||||||
|
</tr>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<h1 style="margin:0 0 4px;font-size:20px;color:#111827;">Invoice ${data.invoiceNumber}</h1>
|
||||||
|
<p style="margin:0 0 24px;color:#6b7280;font-size:14px;">${data.organizationName}${data.dueDate ? ` · Due ${new Date(data.dueDate).toLocaleDateString()}` : ""}</p>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:16px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;border-bottom:2px solid #e5e7eb;color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;">Description</td>
|
||||||
|
<td style="padding:8px 0;border-bottom:2px solid #e5e7eb;color:#6b7280;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;text-align:right;">Amount</td>
|
||||||
|
</tr>
|
||||||
|
${itemRows}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;color:#111827;font-size:16px;font-weight:700;">Total</td>
|
||||||
|
<td style="padding:12px 0;color:#111827;font-size:16px;font-weight:700;text-align:right;">${formatAmount(data.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
${button("View Invoice", data.dashboardUrl)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Invoice ${data.invoiceNumber} — ${formatAmount(data.amount)}`,
|
||||||
|
html: baseLayout(content, `Invoice ${data.invoiceNumber} for ${data.organizationName}`),
|
||||||
|
text: `Invoice ${data.invoiceNumber}\n\nOrganization: ${data.organizationName}\nTotal: ${formatAmount(data.amount)}\n${data.dueDate ? `Due: ${data.dueDate}\n` : ""}\nView: ${data.dashboardUrl}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CREDIT NOTIFICATION EMAIL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CreditEmailData {
|
||||||
|
organizationName: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
type: "credit" | "debit";
|
||||||
|
reason: string;
|
||||||
|
newBalance: number;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creditEmail(data: CreditEmailData): { subject: string; html: string; text: string } {
|
||||||
|
const formatAmount = (cents: number) =>
|
||||||
|
new Intl.NumberFormat("en-US", { style: "currency", currency: data.currency }).format(cents / 100);
|
||||||
|
|
||||||
|
const isCredit = data.type === "credit";
|
||||||
|
const content = `
|
||||||
|
<h1 style="margin:0 0 8px;font-size:20px;color:#111827;">
|
||||||
|
${isCredit ? "Credit Added" : "Balance Deducted"}
|
||||||
|
</h1>
|
||||||
|
<p style="margin:0 0 24px;color:#6b7280;font-size:15px;">${data.organizationName}</p>
|
||||||
|
<div style="background:${isCredit ? "#f0fdf4" : "#fef2f2"};border-radius:8px;padding:20px;text-align:center;margin-bottom:24px;">
|
||||||
|
<p style="margin:0;font-size:28px;font-weight:700;color:${isCredit ? "#16a34a" : "#dc2626"};">
|
||||||
|
${isCredit ? "+" : "-"}${formatAmount(Math.abs(data.amount))}
|
||||||
|
</p>
|
||||||
|
<p style="margin:4px 0 0;font-size:13px;color:#6b7280;">${data.reason.replace(/_/g, " ")}</p>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f8fafc;border-radius:8px;padding:16px;text-align:center;margin-bottom:24px;">
|
||||||
|
<p style="margin:0;font-size:13px;color:#6b7280;">New Balance</p>
|
||||||
|
<p style="margin:4px 0 0;font-size:20px;font-weight:700;color:#111827;">${formatAmount(data.newBalance)}</p>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
${button("View Account", data.dashboardUrl)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `${isCredit ? "Credit added" : "Balance deducted"}: ${formatAmount(Math.abs(data.amount))}`,
|
||||||
|
html: baseLayout(content),
|
||||||
|
text: `${isCredit ? "Credit Added" : "Balance Deducted"}\n\n${isCredit ? "+" : "-"}${formatAmount(Math.abs(data.amount))}\nReason: ${data.reason}\nNew Balance: ${formatAmount(data.newBalance)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PASSWORD RESET EMAIL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PasswordResetEmailData {
|
||||||
|
resetUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordResetEmail(data: PasswordResetEmailData): { subject: string; html: string; text: string } {
|
||||||
|
const content = `
|
||||||
|
<h1 style="margin:0 0 8px;font-size:20px;color:#111827;">Reset Your Password</h1>
|
||||||
|
<p style="margin:0 0 24px;color:#6b7280;font-size:15px;line-height:1.6;">
|
||||||
|
We received a request to reset your password. Click the button below to choose a new one.
|
||||||
|
This link expires in 1 hour.
|
||||||
|
</p>
|
||||||
|
<div style="text-align:center;margin-bottom:24px;">
|
||||||
|
${button("Reset Password", data.resetUrl)}
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;color:#9ca3af;font-size:13px;">
|
||||||
|
If you didn't request this, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Reset your ${BRAND_NAME} password`,
|
||||||
|
html: baseLayout(content, "Reset your password"),
|
||||||
|
text: `Reset your ${BRAND_NAME} password\n\nClick here: ${data.resetUrl}\n\nThis link expires in 1 hour. If you didn't request this, ignore this email.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
export interface EmailOptions {
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transporter: nodemailer.Transporter | null = null;
|
||||||
|
|
||||||
|
function getTransporter(): nodemailer.Transporter {
|
||||||
|
if (transporter) return transporter;
|
||||||
|
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = parseInt(process.env.SMTP_PORT || "465");
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASSWORD;
|
||||||
|
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
console.warn("[EmailService] SMTP not configured — emails will be logged to console");
|
||||||
|
// Return a mock transport for dev
|
||||||
|
transporter = nodemailer.createTransport({ jsonTransport: true });
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: port === 465,
|
||||||
|
auth: { user, pass },
|
||||||
|
tls: { rejectUnauthorized: process.env.NODE_ENV === "production" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email via SMTP (info@dk0.dev / mail.dk0.dev).
|
||||||
|
*/
|
||||||
|
export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
||||||
|
const from = process.env.SMTP_FROM || "CloudLense <info@dk0.dev>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = getTransporter();
|
||||||
|
const info = await transport.sendMail({
|
||||||
|
from,
|
||||||
|
to: Array.isArray(options.to) ? options.to.join(", ") : options.to,
|
||||||
|
subject: options.subject,
|
||||||
|
html: options.html,
|
||||||
|
text: options.text,
|
||||||
|
replyTo: options.replyTo || from,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.SMTP_HOST) {
|
||||||
|
console.log(`[EmailService] Sent to ${options.to}: ${info.messageId}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log(`[EmailService] (dev mode) Would send to ${options.to}: ${options.subject}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[EmailService] Failed to send email:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify SMTP connection is working.
|
||||||
|
*/
|
||||||
|
export async function verifySmtpConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const transport = getTransporter();
|
||||||
|
await transport.verify();
|
||||||
|
console.log("[EmailService] SMTP connection verified");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[EmailService] SMTP verification failed:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { getSupabaseAdmin } from "@/lib/admin";
|
import { getSupabaseAdmin } from "@/lib/admin";
|
||||||
|
import { sendEmail } from "@/lib/email";
|
||||||
|
import { alertEmail } from "@/lib/email-templates";
|
||||||
|
|
||||||
interface AlertPayload {
|
interface AlertPayload {
|
||||||
alertId: string;
|
alertId: string;
|
||||||
@@ -11,60 +13,21 @@ interface AlertPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an email notification via Resend (free tier: 3000 emails/month).
|
* Sends an alert email notification via SMTP.
|
||||||
* If RESEND_API_KEY is not set, logs the alert to console instead.
|
|
||||||
*/
|
*/
|
||||||
async function sendEmail(to: string, alert: AlertPayload): Promise<boolean> {
|
async function sendAlertEmail(to: string, alert: AlertPayload): Promise<boolean> {
|
||||||
const apiKey = process.env.RESEND_API_KEY;
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||||
|
const template = alertEmail({
|
||||||
|
...alert,
|
||||||
|
dashboardUrl: `${appUrl}/dashboard/alerts`,
|
||||||
|
});
|
||||||
|
|
||||||
if (!apiKey) {
|
return sendEmail({
|
||||||
console.warn(
|
to,
|
||||||
`[NotificationService] RESEND_API_KEY not set — would email ${to}: ${alert.message}`
|
subject: template.subject,
|
||||||
);
|
html: template.html,
|
||||||
return false;
|
text: template.text,
|
||||||
}
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("https://api.resend.com/emails", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: process.env.RESEND_FROM_EMAIL || "alerts@monitoring.local",
|
|
||||||
to: [to],
|
|
||||||
subject: `[${alert.severity.toUpperCase()}] ${alert.type}: ${alert.websiteName}`,
|
|
||||||
html: `
|
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
|
||||||
<div style="background: ${alert.severity === "critical" ? "#dc2626" : alert.severity === "high" ? "#ea580c" : "#f59e0b"}; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
|
|
||||||
<h2 style="margin: 0;">${alert.severity.toUpperCase()} Alert</h2>
|
|
||||||
</div>
|
|
||||||
<div style="border: 1px solid #e5e7eb; padding: 24px; border-radius: 0 0 8px 8px;">
|
|
||||||
<p><strong>Website:</strong> ${alert.websiteName}</p>
|
|
||||||
<p><strong>URL:</strong> <a href="${alert.websiteUrl}">${alert.websiteUrl}</a></p>
|
|
||||||
<p><strong>Type:</strong> ${alert.type}</p>
|
|
||||||
<p><strong>Message:</strong> ${alert.message}</p>
|
|
||||||
<p><strong>Time:</strong> ${new Date(alert.timestamp).toLocaleString()}</p>
|
|
||||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;">
|
|
||||||
<p style="color: #6b7280; font-size: 12px;">Website Monitoring Platform</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.text();
|
|
||||||
console.error(`[NotificationService] Resend error: ${errorBody}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[NotificationService] Email failed:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,7 +129,7 @@ export class NotificationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (userData?.user?.email) {
|
if (userData?.user?.email) {
|
||||||
const sent = await sendEmail(userData.user.email, payload);
|
const sent = await sendAlertEmail(userData.user.email, payload);
|
||||||
if (sent) result.emailsSent++;
|
if (sent) result.emailsSent++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
-- Admin Dashboard: Payments, Coupons & Credits
|
||||||
|
-- Adds tables for payment tracking, coupon/discount codes, and account credit system.
|
||||||
|
-- Stripe fields are nullable (prepared for future integration).
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PAYMENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.payments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
||||||
|
amount INTEGER NOT NULL, -- amount in cents
|
||||||
|
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
status TEXT NOT NULL DEFAULT 'completed' CHECK (status IN ('pending', 'completed', 'failed', 'refunded')),
|
||||||
|
method TEXT CHECK (method IN ('manual', 'stripe', 'bank_transfer', 'paypal', 'other')),
|
||||||
|
stripe_payment_id TEXT, -- nullable, for future Stripe integration
|
||||||
|
description TEXT,
|
||||||
|
notes TEXT, -- internal admin notes
|
||||||
|
created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_payments_organization_id ON public.payments(organization_id);
|
||||||
|
CREATE INDEX idx_payments_created_at ON public.payments(created_at DESC);
|
||||||
|
CREATE INDEX idx_payments_stripe_payment_id ON public.payments(stripe_payment_id) WHERE stripe_payment_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- COUPONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.coupons (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
discount_type TEXT NOT NULL CHECK (discount_type IN ('percentage', 'fixed_amount', 'tier_upgrade')),
|
||||||
|
discount_value INTEGER NOT NULL, -- percentage (0-100) or amount in cents
|
||||||
|
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
max_redemptions INTEGER, -- NULL = unlimited
|
||||||
|
current_redemptions INTEGER NOT NULL DEFAULT 0,
|
||||||
|
applicable_tiers TEXT[] DEFAULT ARRAY['free', 'starter', 'professional'], -- which tiers can use this
|
||||||
|
valid_from TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
valid_until TIMESTAMPTZ, -- NULL = no expiry
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_coupons_code ON public.coupons(code);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- COUPON REDEMPTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.coupon_redemptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
coupon_id UUID NOT NULL REFERENCES public.coupons(id) ON DELETE CASCADE,
|
||||||
|
organization_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
||||||
|
redeemed_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
|
||||||
|
discount_applied INTEGER NOT NULL, -- actual discount in cents
|
||||||
|
redeemed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_coupon_redemptions_coupon_id ON public.coupon_redemptions(coupon_id);
|
||||||
|
CREATE INDEX idx_coupon_redemptions_organization_id ON public.coupon_redemptions(organization_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CREDIT TRANSACTIONS (ledger-style)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.credit_transactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
||||||
|
amount INTEGER NOT NULL, -- positive = credit, negative = debit (in cents)
|
||||||
|
balance_after INTEGER NOT NULL, -- running balance after this transaction
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('credit', 'debit')),
|
||||||
|
reason TEXT NOT NULL, -- e.g. 'manual_credit', 'coupon_redemption', 'payment', 'usage_charge', 'refund', 'adjustment'
|
||||||
|
reference_id UUID, -- optional reference to payment/coupon/invoice
|
||||||
|
notes TEXT,
|
||||||
|
created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_credit_transactions_organization_id ON public.credit_transactions(organization_id);
|
||||||
|
CREATE INDEX idx_credit_transactions_created_at ON public.credit_transactions(created_at DESC);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INVOICES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_number TEXT NOT NULL UNIQUE,
|
||||||
|
organization_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
||||||
|
amount INTEGER NOT NULL, -- total in cents
|
||||||
|
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'sent', 'paid', 'overdue', 'cancelled', 'refunded')),
|
||||||
|
items JSONB NOT NULL DEFAULT '[]'::jsonb, -- line items: [{description, quantity, unit_price, total}]
|
||||||
|
stripe_invoice_id TEXT, -- nullable, for future Stripe integration
|
||||||
|
due_date TIMESTAMPTZ,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
notes TEXT,
|
||||||
|
created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoices_organization_id ON public.invoices(organization_id);
|
||||||
|
CREATE INDEX idx_invoices_status ON public.invoices(status);
|
||||||
|
CREATE INDEX idx_invoices_invoice_number ON public.invoices(invoice_number);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Add credit_balance column to organizations
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE public.organizations
|
||||||
|
ADD COLUMN IF NOT EXISTS credit_balance INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- RLS Policies (admin-only access)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE public.payments ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.coupons ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.coupon_redemptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.credit_transactions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Admins (service role) can do everything; these policies allow authenticated admin access
|
||||||
|
CREATE POLICY "Admin full access on payments" ON public.payments
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role IN ('owner', 'admin'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admin full access on coupons" ON public.coupons
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role IN ('owner', 'admin'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admin full access on coupon_redemptions" ON public.coupon_redemptions
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role IN ('owner', 'admin'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admin full access on credit_transactions" ON public.credit_transactions
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role IN ('owner', 'admin'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admin full access on invoices" ON public.invoices
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role IN ('owner', 'admin'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Org members can view their own organization's data (read-only)
|
||||||
|
CREATE POLICY "Org members can view own payments" ON public.payments
|
||||||
|
FOR SELECT USING (
|
||||||
|
organization_id IN (
|
||||||
|
SELECT organization_id FROM public.users WHERE id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Org members can view own invoices" ON public.invoices
|
||||||
|
FOR SELECT USING (
|
||||||
|
organization_id IN (
|
||||||
|
SELECT organization_id FROM public.users WHERE id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Org members can view own credit transactions" ON public.credit_transactions
|
||||||
|
FOR SELECT USING (
|
||||||
|
organization_id IN (
|
||||||
|
SELECT organization_id FROM public.users WHERE id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Anyone can view active coupons (for redemption)
|
||||||
|
CREATE POLICY "Anyone can view active coupons" ON public.coupons
|
||||||
|
FOR SELECT USING (is_active = true);
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "website-monitoring",
|
"name": "cloudlense",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Full-stack website monitoring platform with Lighthouse performance auditing",
|
"description": "CloudLense — Full-stack website monitoring & performance auditing platform",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"setup": "bash dev-setup.sh",
|
"setup": "bash dev-setup.sh",
|
||||||
"start": "cd frontend && supabase start && cd .. && concurrently -n db,backend,frontend -c blue,yellow,green \"echo Supabase ready\" \"npm run dev:backend\" \"npm run dev:frontend\"",
|
"start": "cd frontend && supabase start && cd .. && concurrently -n db,backend,frontend -c blue,yellow,green \"echo Supabase ready\" \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||||
|
|||||||
Reference in New Issue
Block a user