From cbffb23ec3ff8295a4fd6a7f2a3eab7f89e9cc32 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 26 Jul 2026 11:04:29 +0330 Subject: [PATCH] Ship invoice templates, public viewer, and print-ready show page. Move template/issue editors to full pages, add public invoice SPA with PDF print, and local-aware public URLs. Co-authored-by: Cursor --- apps/super-admin/.env.example | 3 + apps/super-admin/src/App.tsx | 11 + .../src/components/InvoiceDraftFields.tsx | 314 ++++++++++ .../src/components/Modal.module.css | 8 + .../src/components/RichTextEditor.module.css | 75 +++ .../src/components/RichTextEditor.tsx | 103 ++++ apps/super-admin/src/lib/config.ts | 20 +- .../src/pages/BusinessInvoicesPage.module.css | 159 +++++ .../src/pages/BusinessInvoicesPage.tsx | 566 ++++-------------- .../src/pages/InvoiceTemplateEditorPage.tsx | 199 ++++++ .../src/pages/IssueInvoicePage.tsx | 278 +++++++++ .../src/pages/PublicInvoicePage.module.css | 393 ++++++++++++ .../src/pages/PublicInvoicePage.tsx | 191 ++++++ .../src/pages/SettingsPage.module.css | 11 + apps/super-admin/src/pages/SettingsPage.tsx | 341 +++++++---- .../src/services/invoiceService.ts | 54 +- apps/super-admin/src/types/invoice.ts | 110 +++- apps/super-admin/src/utils/invoiceDraft.ts | 170 ++++++ apps/super-admin/src/vite-env.d.ts | 1 + docs/PROJECT_CONTEXT.md | 58 +- 20 files changed, 2501 insertions(+), 564 deletions(-) create mode 100644 apps/super-admin/src/components/InvoiceDraftFields.tsx create mode 100644 apps/super-admin/src/components/RichTextEditor.module.css create mode 100644 apps/super-admin/src/components/RichTextEditor.tsx create mode 100644 apps/super-admin/src/pages/InvoiceTemplateEditorPage.tsx create mode 100644 apps/super-admin/src/pages/IssueInvoicePage.tsx create mode 100644 apps/super-admin/src/pages/PublicInvoicePage.module.css create mode 100644 apps/super-admin/src/pages/PublicInvoicePage.tsx create mode 100644 apps/super-admin/src/utils/invoiceDraft.ts diff --git a/apps/super-admin/.env.example b/apps/super-admin/.env.example index 5f276d4..65c755f 100644 --- a/apps/super-admin/.env.example +++ b/apps/super-admin/.env.example @@ -3,4 +3,7 @@ VITE_API_BASE_URL=http://localhost:3000/api/v1 VITE_ADMIN_DOMAIN=meshkee.app # Public domain used in platform invoice links (https://{domain}/invoices/{id}). VITE_INVOICE_PUBLIC_DOMAIN=meshkee.com +# Optional full origin for local show-page design (overrides domain). Leave unset in DEV — +# links use the current Vite origin (e.g. https://meshkee.app:5174/invoices/{id}). +# VITE_INVOICE_PUBLIC_BASE_URL=https://meshkee.app:5174 # Local HTTPS certs (gitignored): mkcert -cert-file .certs/meshkee.app.pem -key-file .certs/meshkee.app-key.pem meshkee.app diff --git a/apps/super-admin/src/App.tsx b/apps/super-admin/src/App.tsx index 0fa4152..c295ee6 100644 --- a/apps/super-admin/src/App.tsx +++ b/apps/super-admin/src/App.tsx @@ -11,6 +11,9 @@ import { BusinessInvoicesPage } from './pages/BusinessInvoicesPage' import { UsersPage } from './pages/UsersPage' import { WebsitesPage } from './pages/WebsitesPage' import { SettingsPage } from './pages/SettingsPage' +import { InvoiceTemplateEditorPage } from './pages/InvoiceTemplateEditorPage' +import { IssueInvoicePage } from './pages/IssueInvoicePage' +import { PublicInvoicePage } from './pages/PublicInvoicePage' import { ProfilePage } from './pages/ProfilePage' import { LoginPage } from './pages/LoginPage' @@ -21,6 +24,8 @@ function App() { + } /> + }> } /> @@ -30,9 +35,15 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> + } /> + } + /> } /> diff --git a/apps/super-admin/src/components/InvoiceDraftFields.tsx b/apps/super-admin/src/components/InvoiceDraftFields.tsx new file mode 100644 index 0000000..3b66610 --- /dev/null +++ b/apps/super-admin/src/components/InvoiceDraftFields.tsx @@ -0,0 +1,314 @@ +import { Pencil, Plus, X } from 'lucide-react' +import type { InvoiceItemTemplate } from '../types/invoice' +import { formatIrtInput, formatIrtPrice } from '../utils/irtPrice' +import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft' +import { + draftItemFromItemTemplate, + emptyDraftAccount, + emptyDraftItem, + emptyDraftKeyPoint, +} from '../utils/invoiceDraft' +import tableStyles from '../pages/BusinessesPage.module.css' +import styles from '../pages/BusinessInvoicesPage.module.css' + +type Props = { + itemTemplates: InvoiceItemTemplate[] + items: DraftLineItem[] + keyPoints: DraftKeyPoint[] + accounts: DraftAccount[] + selectedItemTemplateId: string + onSelectedItemTemplateId: (id: string) => void + onItemsChange: (items: DraftLineItem[]) => void + onKeyPointsChange: (points: DraftKeyPoint[]) => void + onAccountsChange: (accounts: DraftAccount[]) => void + showItemTemplatePicker?: boolean +} + +export function InvoiceDraftFields({ + itemTemplates, + items, + keyPoints, + accounts, + selectedItemTemplateId, + onSelectedItemTemplateId, + onItemsChange, + onKeyPointsChange, + onAccountsChange, + showItemTemplatePicker = true, +}: Props) { + function updateItem(key: string, patch: Partial) { + onItemsChange(items.map((item) => (item.key === key ? { ...item, ...patch } : item))) + } + + function removeItem(key: string) { + if (items.length <= 1) return + onItemsChange(items.filter((item) => item.key !== key)) + } + + function addFromItemTemplate() { + const template = itemTemplates.find((t) => t.id === selectedItemTemplateId) + if (!template) return + const onlyEmpty = + items.length === 1 && + !items[0].title.trim() && + !items[0].price.trim() && + !items[0].description.trim() + onItemsChange(onlyEmpty ? [draftItemFromItemTemplate(template)] : [...items, draftItemFromItemTemplate(template)]) + onSelectedItemTemplateId('') + } + + return ( + <> +
+ {items.map((item, index) => ( +
+
+ Item {index + 1} + +
+
+
+ + updateItem(item.key, { title: e.target.value })} + placeholder="Service title" + /> +
+
+ + updateItem(item.key, { duration: e.target.value })} + placeholder="e.g. 3 months" + /> +
+
+ + updateItem(item.key, { worktime: e.target.value })} + placeholder="e.g. 40 hours" + /> +
+
+ + updateItem(item.key, { price: formatIrtInput(e.target.value) })} + placeholder="0" + /> +
+
+ + + updateItem(item.key, { discountedPrice: formatIrtInput(e.target.value) }) + } + placeholder="Optional" + /> +
+
+ +