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:
Dennis
2026-03-07 01:04:37 +01:00
parent 50e25e3ee8
commit 379d9aa13c
18 changed files with 2956 additions and 67 deletions
+173
View File
@@ -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 });
}
}
+146
View File
@@ -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>
);
}
+31 -1
View File
@@ -16,7 +16,15 @@ import {
UserX,
UserCheck,
ChevronDown,
CreditCard,
Ticket,
Wallet,
FileText,
} 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 {
users: number;
@@ -54,7 +62,7 @@ interface AdminOrg {
export default function AdminDashboard() {
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 [users, setUsers] = useState<AdminUser[]>([]);
const [orgs, setOrgs] = useState<AdminOrg[]>([]);
@@ -180,6 +188,10 @@ export default function AdminDashboard() {
{ id: "overview" as const, label: "Overview", icon: BarChart3 },
{ id: "users" as const, label: "Users", icon: Users },
{ 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) => (
<button
key={tab.id}
@@ -399,6 +411,24 @@ export default function AdminDashboard() {
</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>
</DashboardLayout>
);