From 171c0607b531851bcea6cc388a50a1d820773a40 Mon Sep 17 00:00:00 2001 From: lynaDev2 Date: Sun, 29 Mar 2026 01:25:48 +0000 Subject: [PATCH] newpage --- frontend/app/invoices/[id]/page.tsx | 433 ++++++++++++++++++ frontend/app/invoices/page.tsx | 458 ++++++++++++++++++++ frontend/hooks/invoices/useGetInvoice.ts | 34 ++ frontend/hooks/invoices/useGetMyInvoices.ts | 53 +++ frontend/lib/hooks/invoice.ts | 43 ++ frontend/lib/react-query/keys/queryKeys.ts | 26 ++ 6 files changed, 1047 insertions(+) create mode 100644 frontend/app/invoices/[id]/page.tsx create mode 100644 frontend/app/invoices/page.tsx create mode 100644 frontend/hooks/invoices/useGetInvoice.ts create mode 100644 frontend/hooks/invoices/useGetMyInvoices.ts create mode 100644 frontend/lib/hooks/invoice.ts create mode 100644 frontend/lib/react-query/keys/queryKeys.ts diff --git a/frontend/app/invoices/[id]/page.tsx b/frontend/app/invoices/[id]/page.tsx new file mode 100644 index 00000000..f1df6bcc --- /dev/null +++ b/frontend/app/invoices/[id]/page.tsx @@ -0,0 +1,433 @@ +"use client"; + +// frontend/app/invoices/[id]/page.tsx + +import { use } from "react"; +import Link from "next/link"; +import { useGetInvoice } from "@/lib/react-query/hooks/invoices/useGetInvoice"; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:6001/api"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatNaira(amount: number) { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 2, + }).format(amount); +} + +function formatDate(iso?: string) { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("en-NG", { + day: "numeric", + month: "long", + year: "numeric", + }); +} + +// ── Skeleton ────────────────────────────────────────────────────────────────── + +function InvoiceSkeleton() { + return ( +
+ {[120, 80, 200, 160, 140, 180].map((w, i) => ( + + ))} +
+ ); +} + +// ── Detail Row ──────────────────────────────────────────────────────────────── + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default function InvoiceDetailPage({ params }: PageProps) { + // Next.js 15 App Router: params is a Promise + const { id } = use(params); + + const { data: invoice, isLoading, isError } = useGetInvoice(id); + + return ( + <> + + + {/* Wrap in DashboardLayout in production */} +
+ + ← Back to Invoices + + +
+ {isLoading ? ( + + ) : isError || !invoice ? ( + /* 404 / error state */ +
+

404

+

Invoice not found

+

+ This invoice doesn't exist or you don't have access to it. +

+ + ← Back to Invoices + +
+ ) : ( + <> + {/* ── Branded Header ── */} +
+
+

ManageHub

+

Smart Hub & Workspace Management

+
+
+

Invoice

+

{invoice.invoiceNumber}

+

Issued {formatDate(invoice.issueDate)}

+ {invoice.status === "PAID" && ( +
+ + Paid +
+ )} +
+
+ + {/* ── Body ── */} +
+ {/* Bill To */} +
+

Bill To

+ + +
+ +
+ + {/* Booking / Workspace */} +
+

Booking Details

+ + + + + +
+ +
+ + {/* Payment */} +
+

Payment

+ + PAID ✓ + : {invoice.status} + } /> +
+ + {/* Total */} +
+ Total Amount + {formatNaira(invoice.amount)} +
+
+ + {/* ── Footer Actions ── */} +
+
+ + ↓ Download PDF + + + ← Back to Invoices + +
+ + )} +
+
+ + ); +} \ No newline at end of file diff --git a/frontend/app/invoices/page.tsx b/frontend/app/invoices/page.tsx new file mode 100644 index 00000000..50dafa2e --- /dev/null +++ b/frontend/app/invoices/page.tsx @@ -0,0 +1,458 @@ +"use client"; + +// frontend/app/invoices/page.tsx + +import { useState } from "react"; +import Link from "next/link"; +import { useGetMyInvoices } from "@/lib/react-query/hooks/invoices/useGetMyInvoices"; +import type { Invoice, InvoiceStatus } from "@/lib/types/invoice"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type FilterTab = "ALL" | InvoiceStatus; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:6001/api"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatNaira(amount: number) { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 2, + }).format(amount); +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-NG", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +function downloadInvoice(id: string) { + window.open(`${API_BASE_URL}/invoices/${id}/download`, "_blank"); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: InvoiceStatus }) { + const config: Record = { + PAID: { label: "Paid", cls: "mh-badge mh-badge--paid" }, + PENDING: { label: "Pending", cls: "mh-badge mh-badge--pending" }, + CANCELLED: { label: "Cancelled", cls: "mh-badge mh-badge--cancelled" }, + }; + const { label, cls } = config[status] ?? { label: status, cls: "mh-badge" }; + return {label}; +} + +function SkeletonRow() { + return ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ); +} + +function EmptyState({ filtered }: { filtered: boolean }) { + return ( +
+
🧾
+

No invoices found

+

+ {filtered + ? "Try switching to a different status filter." + : "Your invoices will appear here once a booking is confirmed."} +

+
+ ); +} + +function Pagination({ + page, + totalPages, + onPageChange, +}: { + page: number; + totalPages: number; + onPageChange: (p: number) => void; +}) { + if (totalPages <= 1) return null; + return ( +
+ + + Page {page} of {totalPages} + + +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +const TABS: { label: string; value: FilterTab }[] = [ + { label: "All", value: "ALL" }, + { label: "Paid", value: "PAID" }, + { label: "Pending", value: "PENDING" }, +]; + +const LIMIT = 10; + +export default function InvoicesPage() { + const [activeTab, setActiveTab] = useState("ALL"); + const [page, setPage] = useState(1); + + const { data, isLoading, isError } = useGetMyInvoices({ + page, + limit: LIMIT, + status: activeTab, + }); + + const invoices = data?.data ?? []; + const meta = data?.meta; + const totalPages = meta?.totalPages ?? 1; + + function handleTabChange(tab: FilterTab) { + setActiveTab(tab); + setPage(1); // reset to first page on filter change + } + + return ( + <> + + + {/* Wrap in DashboardLayout in production — left unwrapped here for portability */} +
+ {/* Header */} +
+
+

Invoices

+

Your billing history and payment records

+
+
+ + {/* Filter Tabs */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Table Card */} +
+ {isError ? ( +

Failed to load invoices. Please try again.

+ ) : ( +
+ + + + + + + + + + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ) + : invoices.length === 0 + ? ( + + + + ) + : invoices.map((inv: Invoice) => ( + + + + + + + + + ))} + +
Invoice #WorkspaceAmountStatusIssue DateActions
+ +
+ {inv.invoiceNumber} + {inv.booking?.workspaceName ?? "—"} + {formatNaira(inv.amount)} + {formatDate(inv.issueDate)} +
+ + View + + +
+
+
+ )} + + +
+
+ + ); +} \ No newline at end of file diff --git a/frontend/hooks/invoices/useGetInvoice.ts b/frontend/hooks/invoices/useGetInvoice.ts new file mode 100644 index 00000000..bdad549d --- /dev/null +++ b/frontend/hooks/invoices/useGetInvoice.ts @@ -0,0 +1,34 @@ +"use client"; + +// frontend/lib/react-query/hooks/invoices/useGetInvoice.ts + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import type { Invoice } from "@/lib/types/invoice"; + +/** + * useGetInvoice + * + * Fetches a single invoice by ID with full relations (member, booking). + * Maps to: GET /invoices/:id + * + * The query is disabled automatically when `id` is undefined/empty, + * so it is safe to call this hook before the route param is resolved. + * + * @example + * const { data: invoice, isLoading } = useGetInvoice(id); + */ +export function useGetInvoice(id: string | undefined) { + return useQuery({ + queryKey: queryKeys.invoices.detail(id ?? ""), + queryFn: () => apiClient.get(`/invoices/${id}`), + enabled: !!id, + staleTime: 60_000, // 1 min — a single invoice rarely changes after issue + retry: (failureCount, error) => { + // Don't retry on 404 — invoice simply doesn't exist + if (error instanceof Error && error.message.includes("404")) return false; + return failureCount < 2; + }, + }); +} \ No newline at end of file diff --git a/frontend/hooks/invoices/useGetMyInvoices.ts b/frontend/hooks/invoices/useGetMyInvoices.ts new file mode 100644 index 00000000..dcde63a7 --- /dev/null +++ b/frontend/hooks/invoices/useGetMyInvoices.ts @@ -0,0 +1,53 @@ +"use client"; + +// frontend/lib/react-query/hooks/invoices/useGetMyInvoices.ts + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import type { InvoiceListResponse, InvoiceStatus } from "@/lib/types/invoice"; + +export interface UseGetMyInvoicesParams { + page?: number; + limit?: number; + status?: InvoiceStatus | "ALL"; +} + +/** + * useGetMyInvoices + * + * Fetches the authenticated member's paginated invoice list. + * Maps to: GET /invoices?page=&limit=&status= + * + * @example + * const { data, isLoading } = useGetMyInvoices({ page: 1, limit: 10, status: "PAID" }); + * data?.data // Invoice[] + * data?.meta // { total, page, limit, totalPages } + */ +export function useGetMyInvoices({ + page = 1, + limit = 10, + status, +}: UseGetMyInvoicesParams = {}) { + // Normalise "ALL" → omit the param so the backend returns everything + const resolvedStatus = + !status || status === "ALL" ? undefined : status; + + const params = { page, limit, ...(resolvedStatus && { status: resolvedStatus }) }; + + return useQuery({ + queryKey: queryKeys.invoices.list(params), + queryFn: async () => { + const qs = new URLSearchParams({ + page: String(page), + limit: String(limit), + ...(resolvedStatus && { status: resolvedStatus }), + }).toString(); + + return apiClient.get(`/invoices?${qs}`); + }, + // Keep previous page data visible while the next page loads + placeholderData: (prev) => prev, + staleTime: 30_000, // 30 s — invoices don't change frequently + }); +} \ No newline at end of file diff --git a/frontend/lib/hooks/invoice.ts b/frontend/lib/hooks/invoice.ts new file mode 100644 index 00000000..98d02931 --- /dev/null +++ b/frontend/lib/hooks/invoice.ts @@ -0,0 +1,43 @@ +// frontend/lib/types/invoice.ts +// Shared Invoice types used by hooks and pages. + +export type InvoiceStatus = "PAID" | "PENDING" | "CANCELLED"; + +export interface Invoice { + id: string; + invoiceNumber: string; + status: InvoiceStatus; + amount: number; + currency: string; + issueDate: string; // ISO date string + paymentDate?: string; // ISO date string, present when PAID + createdAt: string; + updatedAt: string; + + // Relations (populated in detail view) + member?: { + id: string; + name: string; + email: string; + }; + booking?: { + id: string; + workspaceName: string; + planType: string; + startDate: string; + endDate: string; + seatCount: number; + }; +} + +export interface InvoiceMeta { + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface InvoiceListResponse { + data: Invoice[]; + meta: InvoiceMeta; +} \ No newline at end of file diff --git a/frontend/lib/react-query/keys/queryKeys.ts b/frontend/lib/react-query/keys/queryKeys.ts new file mode 100644 index 00000000..e4abbbcd --- /dev/null +++ b/frontend/lib/react-query/keys/queryKeys.ts @@ -0,0 +1,26 @@ +// frontend/lib/react-query/keys/queryKeys.ts +// Add the invoiceKeys block below to your existing queryKeys object. +// If the file doesn't exist yet, use the full export below as a starting point. + +export const queryKeys = { + // ── existing keys (keep whatever is already here) ────────────────────────── + + // ── #42 Invoice keys ──────────────────────────────────────────────────────── + invoices: { + /** Base key — used for invalidating all invoice queries at once */ + all: ["invoices"] as const, + + /** + * Paginated list key. + * @example queryKeys.invoices.list({ page: 1, limit: 10, status: "PAID" }) + */ + list: (params: { page?: number; limit?: number; status?: string }) => + ["invoices", "list", params] as const, + + /** + * Single invoice key. + * @example queryKeys.invoices.detail("abc-123") + */ + detail: (id: string) => ["invoices", "detail", id] as const, + }, +}; \ No newline at end of file