mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8f4af7a16c
commit
cbffb23ec3
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="invoices/:invoiceId" element={<PublicInvoicePage />} />
|
||||
|
||||
<Route element={<GuestRoute />}>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
</Route>
|
||||
@@ -30,9 +35,15 @@ function App() {
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="businesses" element={<BusinessesPage />} />
|
||||
<Route path="businesses/:businessId/invoices" element={<BusinessInvoicesPage />} />
|
||||
<Route path="businesses/:businessId/invoices/new" element={<IssueInvoicePage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="websites" element={<WebsitesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="settings/invoice-templates/new" element={<InvoiceTemplateEditorPage />} />
|
||||
<Route
|
||||
path="settings/invoice-templates/:templateId"
|
||||
element={<InvoiceTemplateEditorPage />}
|
||||
/>
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -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<DraftLineItem>) {
|
||||
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 (
|
||||
<>
|
||||
<div className={styles.itemsStack}>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.key} className={styles.itemCard}>
|
||||
<div className={styles.itemCardHeader}>
|
||||
<span>Item {index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeItemBtn}
|
||||
onClick={() => removeItem(item.key)}
|
||||
disabled={items.length <= 1}
|
||||
aria-label="Remove item"
|
||||
title="Remove item"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.itemGrid}>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Title</label>
|
||||
<input
|
||||
value={item.title}
|
||||
onChange={(e) => updateItem(item.key, { title: e.target.value })}
|
||||
placeholder="Service title"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Duration</label>
|
||||
<input
|
||||
value={item.duration}
|
||||
onChange={(e) => updateItem(item.key, { duration: e.target.value })}
|
||||
placeholder="e.g. 3 months"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Worktime</label>
|
||||
<input
|
||||
value={item.worktime}
|
||||
onChange={(e) => updateItem(item.key, { worktime: e.target.value })}
|
||||
placeholder="e.g. 40 hours"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Price (IRT)</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.key, { price: formatIrtInput(e.target.value) })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Discounted price (IRT)</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={item.discountedPrice}
|
||||
onChange={(e) =>
|
||||
updateItem(item.key, { discountedPrice: formatIrtInput(e.target.value) })
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.descField}`}>
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(item.key, { description: e.target.value })}
|
||||
placeholder="Optional details"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.itemActions}>
|
||||
{showItemTemplatePicker ? (
|
||||
<div className={styles.itemActionsLeft}>
|
||||
<select
|
||||
id="item-template-pick"
|
||||
className={styles.compactSelect}
|
||||
value={selectedItemTemplateId}
|
||||
onChange={(e) => onSelectedItemTemplateId(e.target.value)}
|
||||
aria-label="Add from item template"
|
||||
>
|
||||
<option value="">Add from template…</option>
|
||||
{itemTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.title} · {formatIrtPrice(template.price)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost} ${styles.compactBtn}`}
|
||||
onClick={addFromItemTemplate}
|
||||
disabled={!selectedItemTemplateId}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost} ${styles.compactBtn}`}
|
||||
onClick={() => onItemsChange([...items, emptyDraftItem()])}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Add custom item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.blockSection}>
|
||||
<div className={styles.blockHeader}>
|
||||
<h4 className={styles.blockTitle}>Key points</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={() => onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add point
|
||||
</button>
|
||||
</div>
|
||||
{keyPoints.length === 0 ? (
|
||||
<p className={styles.templateHint}>No key points yet.</p>
|
||||
) : (
|
||||
<div className={styles.repeatStack}>
|
||||
{keyPoints.map((point, index) => (
|
||||
<div key={point.key} className={styles.repeatRow}>
|
||||
<div className={`${tableStyles.field} ${styles.flexGrow}`}>
|
||||
<label>Point {index + 1}</label>
|
||||
<input
|
||||
value={point.text}
|
||||
onChange={(e) =>
|
||||
onKeyPointsChange(
|
||||
keyPoints.map((p) =>
|
||||
p.key === point.key ? { ...p, text: e.target.value } : p,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="e.g. Payment due within 7 days"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeFieldBtn}
|
||||
onClick={() => onKeyPointsChange(keyPoints.filter((p) => p.key !== point.key))}
|
||||
aria-label="Remove key point"
|
||||
title="Remove"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.blockSection}>
|
||||
<div className={styles.blockHeader}>
|
||||
<h4 className={styles.blockTitle}>Account numbers</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add account
|
||||
</button>
|
||||
</div>
|
||||
{accounts.length === 0 ? (
|
||||
<p className={styles.templateHint}>No bank accounts yet.</p>
|
||||
) : (
|
||||
<div className={styles.repeatStack}>
|
||||
{accounts.map((acc) => (
|
||||
<div key={acc.key} className={styles.accountRow}>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
||||
<label>Bank name</label>
|
||||
<input
|
||||
value={acc.bankName}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Bank name"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
||||
<label>Account holder</label>
|
||||
<input
|
||||
value={acc.accountHolderName}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Account holder name"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
|
||||
<label>Card number</label>
|
||||
<input
|
||||
value={acc.cardNumber}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
|
||||
<label>IBAN</label>
|
||||
<input
|
||||
value={acc.iban}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, iban: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeFieldBtn}
|
||||
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
|
||||
aria-label="Remove account"
|
||||
title="Remove"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: calc(100vh - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
@@ -35,12 +38,14 @@
|
||||
position: relative;
|
||||
padding: 18px 18px 10px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
@@ -64,5 +69,8 @@
|
||||
|
||||
.body {
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
.wrapper {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.wrapper:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 88px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.45;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor ul,
|
||||
.editor ol {
|
||||
padding-left: 1.4em;
|
||||
margin: 0.35em 0;
|
||||
}
|
||||
|
||||
.editor p {
|
||||
margin: 0 0 0.35em;
|
||||
}
|
||||
|
||||
.editor p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Bold, Italic, List, ListOrdered, Underline } from 'lucide-react'
|
||||
import styles from './RichTextEditor.module.css'
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
/** Compact editor height for short fields like invoice top text. */
|
||||
editorMinHeight?: number
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '',
|
||||
editorMinHeight = 88,
|
||||
}: RichTextEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const syncChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
||||
editorRef.current.innerHTML = value
|
||||
}
|
||||
}, [value])
|
||||
|
||||
function exec(command: string) {
|
||||
editorRef.current?.focus()
|
||||
document.execCommand(command)
|
||||
syncChange()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
|
||||
<Bold size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('underline')}
|
||||
title="Underline"
|
||||
aria-label="Underline"
|
||||
>
|
||||
<Underline size={14} />
|
||||
</button>
|
||||
<span className={styles.divider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('insertUnorderedList')}
|
||||
title="Bullet list"
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec('insertOrderedList')}
|
||||
title="Numbered list"
|
||||
aria-label="Numbered list"
|
||||
>
|
||||
<ListOrdered size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className={styles.editor}
|
||||
style={{ minHeight: editorMinHeight }}
|
||||
contentEditable
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder={placeholder}
|
||||
onInput={syncChange}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** True when HTML is empty or only whitespace / empty tags. */
|
||||
export function isEmptyRichText(html: string | null | undefined): boolean {
|
||||
return richTextToPlain(html).length === 0
|
||||
}
|
||||
|
||||
/** Strip tags for single-line list previews. */
|
||||
export function richTextToPlain(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
@@ -9,8 +9,26 @@ export function isAllowedAdminHost(hostname = window.location.hostname): boolean
|
||||
return hostname === getAdminDomain()
|
||||
}
|
||||
|
||||
/** Public invoice URL for platform (super-admin) invoices. */
|
||||
/**
|
||||
* Public invoice URL for platform invoices.
|
||||
* Local/dev: current origin (`https://meshkee.app:5174/invoices/{id}`) so the show page is reachable.
|
||||
* Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default meshkee.com).
|
||||
* Override either with `VITE_INVOICE_PUBLIC_BASE_URL` (full origin, optional path prefix).
|
||||
*/
|
||||
export function getPlatformInvoicePublicUrl(invoiceId: string): string {
|
||||
const baseOverride = import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL?.trim()
|
||||
if (baseOverride) {
|
||||
return `${baseOverride.replace(/\/$/, '')}/invoices/${invoiceId}`
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return `${window.location.origin}/invoices/${invoiceId}`
|
||||
}
|
||||
const domain = import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'
|
||||
return `https://${domain}/invoices/${invoiceId}`
|
||||
}
|
||||
|
||||
/** Marketing / main business site for platform invoices (default https://meshkee.com). */
|
||||
export function getPlatformMarketingUrl(): string {
|
||||
const domain = import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'
|
||||
return `https://${domain.replace(/^https?:\/\//, '').replace(/\/$/, '')}`
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.removeItemBtn:hover:not(:disabled) {
|
||||
@@ -124,6 +125,37 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/** Matches compact field height; align with input (not label). */
|
||||
.removeFieldBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--field-height);
|
||||
height: var(--field-height);
|
||||
min-width: var(--field-height);
|
||||
border-radius: var(--radius-sm);
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.removeFieldBtn:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.repeatRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repeatRow .field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr);
|
||||
@@ -197,12 +229,50 @@
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.itemActionsLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.compactSelect {
|
||||
width: min(260px, 100%);
|
||||
min-height: 32px;
|
||||
padding: 6px var(--select-padding-end) 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compactBtn {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.createTotal {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.createTotalRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
.detailMeta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -284,6 +354,76 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.blockSection {
|
||||
margin: 16px 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.blockHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.blockTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.repeatStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.flexGrow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.accountCard {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.accountGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.accountRow {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 3fr 5fr var(--field-height);
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.accountCol2,
|
||||
.accountCol3,
|
||||
.accountCol5 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fullField {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.keyPointList {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.itemGrid,
|
||||
.metaGrid,
|
||||
@@ -309,8 +449,27 @@
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.accountRow {
|
||||
grid-template-columns: 1fr var(--field-height);
|
||||
}
|
||||
|
||||
.accountCol2,
|
||||
.accountCol3,
|
||||
.accountCol5 {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.itemActionsLeft {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.compactSelect {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Eye,
|
||||
FilePlus2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft, Copy, Eye, FilePlus2, Trash2 } from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { isEmptyRichText } from '../components/RichTextEditor'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { getPlatformInvoicePublicUrl } from '../lib/config'
|
||||
import { getBusiness, type BusinessDetail } from '../services/businessService'
|
||||
import {
|
||||
createBusinessInvoice,
|
||||
deleteBusinessInvoice,
|
||||
listBusinessInvoices,
|
||||
listInvoiceItemTemplates,
|
||||
updateBusinessInvoiceStatus,
|
||||
} from '../services/invoiceService'
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceItemInput,
|
||||
InvoiceItemTemplate,
|
||||
InvoiceStatus,
|
||||
} from '../types/invoice'
|
||||
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||
import type { Invoice, InvoiceStatus } from '../types/invoice'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import tableStyles from './BusinessesPage.module.css'
|
||||
import styles from './BusinessInvoicesPage.module.css'
|
||||
|
||||
type DraftItem = {
|
||||
key: string
|
||||
templateId?: string
|
||||
title: string
|
||||
duration: string
|
||||
worktime: string
|
||||
description: string
|
||||
price: string
|
||||
discountedPrice: string
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'paid', 'cancelled']
|
||||
|
||||
function formatDate(value: string) {
|
||||
@@ -57,95 +31,40 @@ function formatDate(value: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function emptyDraftItem(): DraftItem {
|
||||
return {
|
||||
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: '',
|
||||
duration: '',
|
||||
worktime: '',
|
||||
description: '',
|
||||
price: '',
|
||||
discountedPrice: '',
|
||||
}
|
||||
}
|
||||
|
||||
function fromTemplate(template: InvoiceItemTemplate): DraftItem {
|
||||
return {
|
||||
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
templateId: template.id,
|
||||
title: template.title,
|
||||
duration: template.duration ?? '',
|
||||
worktime: template.worktime ?? '',
|
||||
description: template.description ?? '',
|
||||
price: formatIrtInput(String(Math.round(template.price))),
|
||||
discountedPrice:
|
||||
template.discountedPrice === null || template.discountedPrice === undefined
|
||||
? ''
|
||||
: formatIrtInput(String(Math.round(template.discountedPrice))),
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: InvoiceStatus) {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
}
|
||||
|
||||
function effectivePrice(price: number, discountedPrice: number | null | undefined) {
|
||||
if (discountedPrice !== null && discountedPrice !== undefined && discountedPrice < price) {
|
||||
return discountedPrice
|
||||
}
|
||||
return price
|
||||
}
|
||||
|
||||
export function BusinessInvoicesPage() {
|
||||
const { businessId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [business, setBusiness] = useState<BusinessDetail | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [templates, setTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [draftItems, setDraftItems] = useState<DraftItem[]>([emptyDraftItem()])
|
||||
const [invoiceName, setInvoiceName] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
||||
|
||||
const [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null)
|
||||
const [statusUpdating, setStatusUpdating] = useState(false)
|
||||
const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null)
|
||||
|
||||
const createTotal = useMemo(() => {
|
||||
return draftItems.reduce((sum, item) => {
|
||||
const price = parseIrtInput(item.price) ?? 0
|
||||
const discounted = item.discountedPrice.trim()
|
||||
? parseIrtInput(item.discountedPrice)
|
||||
: null
|
||||
return sum + effectivePrice(price, discounted)
|
||||
}, 0)
|
||||
}, [draftItems])
|
||||
|
||||
async function reload(signal?: AbortSignal) {
|
||||
if (!businessId) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [biz, list, tpl] = await Promise.all([
|
||||
const [biz, list] = await Promise.all([
|
||||
getBusiness(businessId, signal),
|
||||
listBusinessInvoices(businessId, { page, pageSize: 20 }, signal),
|
||||
listInvoiceItemTemplates(signal),
|
||||
])
|
||||
setBusiness(biz)
|
||||
setInvoices(list.items)
|
||||
setTotalPages(list.totalPages)
|
||||
setTotal(list.total)
|
||||
setTemplates(tpl.items.filter((t) => t.isActive))
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load invoices.')
|
||||
@@ -161,96 +80,6 @@ export function BusinessInvoicesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [businessId, page])
|
||||
|
||||
function openCreate() {
|
||||
setDraftItems([emptyDraftItem()])
|
||||
setInvoiceName('')
|
||||
setNotes('')
|
||||
setSelectedTemplateId('')
|
||||
setFormError('')
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
function updateDraftItem(key: string, patch: Partial<DraftItem>) {
|
||||
setDraftItems((items) => items.map((item) => (item.key === key ? { ...item, ...patch } : item)))
|
||||
}
|
||||
|
||||
function removeDraftItem(key: string) {
|
||||
setDraftItems((items) => (items.length <= 1 ? items : items.filter((item) => item.key !== key)))
|
||||
}
|
||||
|
||||
function addCustomItem() {
|
||||
setDraftItems((items) => [...items, emptyDraftItem()])
|
||||
}
|
||||
|
||||
function addFromTemplate() {
|
||||
const template = templates.find((t) => t.id === selectedTemplateId)
|
||||
if (!template) return
|
||||
setDraftItems((items) => {
|
||||
const onlyEmpty =
|
||||
items.length === 1 &&
|
||||
!items[0].title.trim() &&
|
||||
!items[0].price.trim() &&
|
||||
!items[0].description.trim()
|
||||
return onlyEmpty ? [fromTemplate(template)] : [...items, fromTemplate(template)]
|
||||
})
|
||||
setSelectedTemplateId('')
|
||||
}
|
||||
|
||||
function buildItemsPayload(): InvoiceItemInput[] {
|
||||
return draftItems.map((item) => {
|
||||
const title = item.title.trim()
|
||||
const price = parseIrtInput(item.price)
|
||||
if (!title) throw new Error('Each item needs a title.')
|
||||
if (price === null) throw new Error(`Price is required for “${title || 'item'}”.`)
|
||||
const discountedPrice = item.discountedPrice.trim()
|
||||
? parseIrtInput(item.discountedPrice)
|
||||
: null
|
||||
if (item.discountedPrice.trim() && discountedPrice === null) {
|
||||
throw new Error(`Discounted price is invalid for “${title}”.`)
|
||||
}
|
||||
if (discountedPrice !== null && discountedPrice > price) {
|
||||
throw new Error(`Discounted price cannot exceed price for “${title}”.`)
|
||||
}
|
||||
return {
|
||||
templateId: item.templateId,
|
||||
title,
|
||||
duration: item.duration.trim() || undefined,
|
||||
worktime: item.worktime.trim() || undefined,
|
||||
description: item.description.trim() || undefined,
|
||||
price,
|
||||
discountedPrice,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
setFormError('')
|
||||
let items: InvoiceItemInput[]
|
||||
try {
|
||||
items = buildItemsPayload()
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : 'Invalid invoice items.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createBusinessInvoice(businessId, {
|
||||
items,
|
||||
name: invoiceName.trim() || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
showToast('Invoice issued.', 'success')
|
||||
setCreateOpen(false)
|
||||
setPage(1)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : 'Unable to create invoice.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(next: InvoiceStatus) {
|
||||
if (!detailInvoice) return
|
||||
setStatusUpdating(true)
|
||||
@@ -292,6 +121,10 @@ export function BusinessInvoicesPage() {
|
||||
}
|
||||
|
||||
function invoicePublicUrl(invoice: Invoice) {
|
||||
// Prefer local/dev origin so the public show page is reachable while designing.
|
||||
if (import.meta.env.DEV || import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL) {
|
||||
return getPlatformInvoicePublicUrl(invoice.id)
|
||||
}
|
||||
return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
||||
}
|
||||
|
||||
@@ -307,14 +140,13 @@ export function BusinessInvoicesPage() {
|
||||
</Link>
|
||||
<h2 className={pageStyles.pageTitle}>Invoices · {businessName}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
List invoices issued to this business, or create a new one from predefined or custom
|
||||
items.
|
||||
Select an invoice template and edit it for this business, or create a blank invoice.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={openCreate}
|
||||
onClick={() => navigate(`/businesses/${businessId}/invoices/new`)}
|
||||
>
|
||||
<FilePlus2 size={16} />
|
||||
Issue invoice
|
||||
@@ -331,86 +163,86 @@ export function BusinessInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<table className={tableStyles.table}>
|
||||
<thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={tableStyles.th}>Name</th>
|
||||
<th className={tableStyles.th}>Issued</th>
|
||||
<th className={tableStyles.th}>Status</th>
|
||||
<th className={tableStyles.th}>Total</th>
|
||||
<th className={tableStyles.th}>Link</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && invoices.length === 0 ? (
|
||||
<tr>
|
||||
<th className={tableStyles.th}>Name</th>
|
||||
<th className={tableStyles.th}>Issued</th>
|
||||
<th className={tableStyles.th}>Status</th>
|
||||
<th className={tableStyles.th}>Total</th>
|
||||
<th className={tableStyles.th}>Link</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
No invoices yet for this business.
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && invoices.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
No invoices yet for this business.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{invoices.map((invoice) => (
|
||||
<tr key={invoice.id}>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.itemTitle}>{invoice.name || `Invoice #${invoice.id}`}</div>
|
||||
<div className={tableStyles.subText}>
|
||||
{invoice.items?.length ?? 0} item{(invoice.items?.length ?? 0) === 1 ? '' : 's'}
|
||||
</div>
|
||||
</td>
|
||||
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td>
|
||||
<td className={tableStyles.td}>
|
||||
<span className={`${styles.statusChip} ${styles[`status_${invoice.status}`]}`}>
|
||||
{statusLabel(invoice.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={tableStyles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.linkRow}>
|
||||
<a
|
||||
className={styles.publicLink}
|
||||
href={invoicePublicUrl(invoice)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={invoicePublicUrl(invoice)}
|
||||
>
|
||||
{invoicePublicUrl(invoice).replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyLinkBtn}
|
||||
onClick={() => void copyPublicLink(invoice)}
|
||||
title="Copy link"
|
||||
aria-label="Copy invoice link"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||
<div className={tableStyles.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => setDetailInvoice(invoice)}
|
||||
title="View"
|
||||
aria-label="View invoice"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(invoice)}
|
||||
title="Remove"
|
||||
aria-label="Remove invoice"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
) : null}
|
||||
{invoices.map((invoice) => (
|
||||
<tr key={invoice.id}>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.itemTitle}>{invoice.name || `Invoice #${invoice.id}`}</div>
|
||||
<div className={tableStyles.subText}>
|
||||
{invoice.items?.length ?? 0} item{(invoice.items?.length ?? 0) === 1 ? '' : 's'}
|
||||
</div>
|
||||
</td>
|
||||
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td>
|
||||
<td className={tableStyles.td}>
|
||||
<span className={`${styles.statusChip} ${styles[`status_${invoice.status}`]}`}>
|
||||
{statusLabel(invoice.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={tableStyles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.linkRow}>
|
||||
<a
|
||||
className={styles.publicLink}
|
||||
href={invoicePublicUrl(invoice)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={invoicePublicUrl(invoice)}
|
||||
>
|
||||
{invoicePublicUrl(invoice).replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyLinkBtn}
|
||||
onClick={() => void copyPublicLink(invoice)}
|
||||
title="Copy link"
|
||||
aria-label="Copy invoice link"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||
<div className={tableStyles.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => setDetailInvoice(invoice)}
|
||||
title="View"
|
||||
aria-label="View invoice"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(invoice)}
|
||||
title="Remove"
|
||||
aria-label="Remove invoice"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -440,180 +272,6 @@ export function BusinessInvoicesPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title={`Issue invoice · ${businessName}`}
|
||||
onClose={() => !submitting && setCreateOpen(false)}
|
||||
xl
|
||||
>
|
||||
<div className={styles.metaGrid}>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="invoice-name">Name (optional)</label>
|
||||
<input
|
||||
id="invoice-name"
|
||||
value={invoiceName}
|
||||
onChange={(e) => setInvoiceName(e.target.value)}
|
||||
placeholder="e.g. Website redesign package"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="invoice-notes">Notes</label>
|
||||
<input
|
||||
id="invoice-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Optional notes for this invoice"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.templateBar}>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="template-pick">Add from predefined</label>
|
||||
<select
|
||||
id="template-pick"
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||
>
|
||||
<option value="">Select an item…</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.title} · {formatIrtPrice(template.price)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={addFromTemplate}
|
||||
disabled={!selectedTemplateId}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<p className={styles.templateHint}>
|
||||
No predefined items yet. Manage them in{' '}
|
||||
<Link to="/settings">Settings → Invoices</Link>, or add custom lines below.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.itemsStack}>
|
||||
{draftItems.map((item, index) => (
|
||||
<div key={item.key} className={styles.itemCard}>
|
||||
<div className={styles.itemCardHeader}>
|
||||
<span>Item {index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeItemBtn}
|
||||
onClick={() => removeDraftItem(item.key)}
|
||||
disabled={draftItems.length <= 1}
|
||||
aria-label="Remove item"
|
||||
title="Remove item"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.itemGrid}>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Title</label>
|
||||
<input
|
||||
value={item.title}
|
||||
onChange={(e) => updateDraftItem(item.key, { title: e.target.value })}
|
||||
placeholder="Service title"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Duration</label>
|
||||
<input
|
||||
value={item.duration}
|
||||
onChange={(e) => updateDraftItem(item.key, { duration: e.target.value })}
|
||||
placeholder="e.g. 3 months"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Worktime</label>
|
||||
<input
|
||||
value={item.worktime}
|
||||
onChange={(e) => updateDraftItem(item.key, { worktime: e.target.value })}
|
||||
placeholder="e.g. 40 hours"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Price (IRT)</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={item.price}
|
||||
onChange={(e) =>
|
||||
updateDraftItem(item.key, { price: formatIrtInput(e.target.value) })
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label>Discounted price (IRT)</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={item.discountedPrice}
|
||||
onChange={(e) =>
|
||||
updateDraftItem(item.key, {
|
||||
discountedPrice: formatIrtInput(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.descField}`}>
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={item.description}
|
||||
onChange={(e) => updateDraftItem(item.key, { description: e.target.value })}
|
||||
placeholder="Optional details"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.itemActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={addCustomItem}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Add custom item
|
||||
</button>
|
||||
<div className={styles.createTotal}>Total: {formatIrtPrice(createTotal)}</div>
|
||||
</div>
|
||||
|
||||
{formError ? <p className={styles.alertError}>{formError}</p> : null}
|
||||
|
||||
<div className={tableStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Issuing…' : 'Issue invoice'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={!!detailInvoice}
|
||||
title={
|
||||
@@ -669,8 +327,14 @@ export function BusinessInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailInvoice.topText && !isEmptyRichText(detailInvoice.topText) ? (
|
||||
<div
|
||||
className={styles.detailNotes}
|
||||
dangerouslySetInnerHTML={{ __html: detailInvoice.topText }}
|
||||
/>
|
||||
) : null}
|
||||
{detailInvoice.notes ? (
|
||||
<p className={styles.detailNotes}>{detailInvoice.notes}</p>
|
||||
<p className={styles.detailNotes}>Notes: {detailInvoice.notes}</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.detailItems}>
|
||||
@@ -679,8 +343,7 @@ export function BusinessInvoicesPage() {
|
||||
<div className={styles.detailItemTop}>
|
||||
<strong>{item.title}</strong>
|
||||
<span>
|
||||
{item.discountedPrice !== null &&
|
||||
item.discountedPrice < item.price ? (
|
||||
{item.discountedPrice !== null && item.discountedPrice < item.price ? (
|
||||
<>
|
||||
<span className={styles.strike}>{formatIrtPrice(item.price)}</span>{' '}
|
||||
{formatIrtPrice(item.discountedPrice)}
|
||||
@@ -700,6 +363,35 @@ export function BusinessInvoicesPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(detailInvoice.keyPoints?.length ?? 0) > 0 ? (
|
||||
<div className={styles.blockSection}>
|
||||
<h4 className={styles.blockTitle}>Key points</h4>
|
||||
<ul className={styles.keyPointList}>
|
||||
{detailInvoice.keyPoints!.map((kp) => (
|
||||
<li key={kp.id}>{kp.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
|
||||
<div className={styles.blockSection}>
|
||||
<h4 className={styles.blockTitle}>Accounts</h4>
|
||||
<div className={styles.detailItems}>
|
||||
{detailInvoice.accounts!.map((acc) => (
|
||||
<div key={acc.id} className={styles.detailItem}>
|
||||
<strong>{acc.bankName}</strong>
|
||||
<div className={styles.detailItemMeta}>
|
||||
{acc.accountHolderName ? <span>{acc.accountHolderName}</span> : null}
|
||||
{acc.cardNumber ? <span>Card: {acc.cardNumber}</span> : null}
|
||||
{acc.iban ? <span>IBAN: {acc.iban}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { InvoiceDraftFields } from '../components/InvoiceDraftFields'
|
||||
import { isEmptyRichText, RichTextEditor } from '../components/RichTextEditor'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createInvoiceTemplate,
|
||||
getInvoiceTemplate,
|
||||
listInvoiceItemTemplates,
|
||||
updateInvoiceTemplate,
|
||||
} from '../services/invoiceService'
|
||||
import type { InvoiceItemTemplate } from '../types/invoice'
|
||||
import {
|
||||
buildAccountsPayload,
|
||||
buildKeyPointsPayload,
|
||||
buildTemplateItemsPayload,
|
||||
draftsFromInvoiceTemplate,
|
||||
emptyDraftItem,
|
||||
type DraftAccount,
|
||||
type DraftKeyPoint,
|
||||
type DraftLineItem,
|
||||
} from '../utils/invoiceDraft'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import tableStyles from './BusinessesPage.module.css'
|
||||
import invoiceStyles from './BusinessInvoicesPage.module.css'
|
||||
import styles from './SettingsPage.module.css'
|
||||
|
||||
export function InvoiceTemplateEditorPage() {
|
||||
const { templateId } = useParams()
|
||||
const isEdit = Boolean(templateId)
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
const [error, setError] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [topText, setTopText] = useState('')
|
||||
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const itemsRes = await listInvoiceItemTemplates(controller.signal)
|
||||
setItemTemplates(itemsRes.items.filter((t) => t.isActive))
|
||||
|
||||
if (templateId) {
|
||||
const template = await getInvoiceTemplate(templateId, controller.signal)
|
||||
const drafts = draftsFromInvoiceTemplate(template)
|
||||
setName(drafts.name)
|
||||
setTopText(drafts.topText)
|
||||
setItems(drafts.items)
|
||||
setKeyPoints(drafts.keyPoints)
|
||||
setAccounts(drafts.accounts)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load template editor.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [templateId])
|
||||
|
||||
async function handleSave() {
|
||||
setFormError('')
|
||||
if (!name.trim()) {
|
||||
setFormError('Template name is required.')
|
||||
return
|
||||
}
|
||||
let lineItems
|
||||
try {
|
||||
lineItems = buildTemplateItemsPayload(items)
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : 'Invalid line items.')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
topText: isEmptyRichText(topText) ? undefined : topText,
|
||||
items: lineItems,
|
||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||
accounts: buildAccountsPayload(accounts),
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (templateId) {
|
||||
await updateInvoiceTemplate(templateId, payload)
|
||||
showToast('Invoice template updated.', 'success')
|
||||
} else {
|
||||
await createInvoiceTemplate(payload)
|
||||
showToast('Invoice template created.', 'success')
|
||||
}
|
||||
navigate('/settings')
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : 'Unable to save invoice template.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<Link to="/settings" className={invoiceStyles.backLink}>
|
||||
<ArrowLeft size={16} />
|
||||
Back to settings
|
||||
</Link>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? 'Edit invoice template' : 'Add invoice template'}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Define name, top text, line items, key points, and bank accounts for reuse when issuing
|
||||
invoices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className={styles.alertError}>{error}</p> : null}
|
||||
{loading ? <p className={tableStyles.meta}>Loading…</p> : null}
|
||||
|
||||
{!loading ? (
|
||||
<section className={styles.section}>
|
||||
<div className={invoiceStyles.metaGrid}>
|
||||
<div className={`${tableStyles.field} ${styles.span2}`}>
|
||||
<label htmlFor="tpl-name">Name</label>
|
||||
<input
|
||||
id="tpl-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Standard website package"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${tableStyles.field} ${invoiceStyles.fullField}`}>
|
||||
<label>Top text</label>
|
||||
<RichTextEditor
|
||||
value={topText}
|
||||
onChange={setTopText}
|
||||
placeholder="Optional intro shown at the top of the invoice"
|
||||
editorMinHeight={88}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InvoiceDraftFields
|
||||
itemTemplates={itemTemplates}
|
||||
items={items}
|
||||
keyPoints={keyPoints}
|
||||
accounts={accounts}
|
||||
selectedItemTemplateId={selectedItemTemplateId}
|
||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||
onItemsChange={setItems}
|
||||
onKeyPointsChange={setKeyPoints}
|
||||
onAccountsChange={setAccounts}
|
||||
/>
|
||||
|
||||
{formError ? <p className={styles.alertError}>{formError}</p> : null}
|
||||
|
||||
<div className={tableStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={() => navigate('/settings')}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving…' : isEdit ? 'Save changes' : 'Create template'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { InvoiceDraftFields } from '../components/InvoiceDraftFields'
|
||||
import { isEmptyRichText, RichTextEditor } from '../components/RichTextEditor'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { getBusiness, type BusinessDetail } from '../services/businessService'
|
||||
import {
|
||||
createBusinessInvoice,
|
||||
listInvoiceItemTemplates,
|
||||
listInvoiceTemplates,
|
||||
} from '../services/invoiceService'
|
||||
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
|
||||
import {
|
||||
buildAccountsPayload,
|
||||
buildKeyPointsPayload,
|
||||
buildLineItemsPayload,
|
||||
draftsFromInvoiceTemplate,
|
||||
emptyDraftItem,
|
||||
type DraftAccount,
|
||||
type DraftKeyPoint,
|
||||
type DraftLineItem,
|
||||
} from '../utils/invoiceDraft'
|
||||
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import tableStyles from './BusinessesPage.module.css'
|
||||
import styles from './BusinessInvoicesPage.module.css'
|
||||
import settingsStyles from './SettingsPage.module.css'
|
||||
|
||||
function effectivePrice(price: number, discountedPrice: number | null | undefined) {
|
||||
if (discountedPrice !== null && discountedPrice !== undefined && discountedPrice < price) {
|
||||
return discountedPrice
|
||||
}
|
||||
return price
|
||||
}
|
||||
|
||||
export function IssueInvoicePage() {
|
||||
const { businessId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [business, setBusiness] = useState<BusinessDetail | null>(null)
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [sourceTemplateId, setSourceTemplateId] = useState('')
|
||||
const [invoiceTemplateId, setInvoiceTemplateId] = useState<string | undefined>()
|
||||
const [invoiceName, setInvoiceName] = useState('')
|
||||
const [topText, setTopText] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const listPath = `/businesses/${businessId}/invoices`
|
||||
const businessName = business?.nameFa || business?.name || 'Business'
|
||||
|
||||
const createTotal = useMemo(() => {
|
||||
return draftItems.reduce((sum, item) => {
|
||||
const price = parseIrtInput(item.price) ?? 0
|
||||
const discounted = item.discountedPrice.trim()
|
||||
? parseIrtInput(item.discountedPrice)
|
||||
: null
|
||||
return sum + effectivePrice(price, discounted)
|
||||
}, 0)
|
||||
}, [draftItems])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [biz, items, templates] = await Promise.all([
|
||||
getBusiness(businessId, controller.signal),
|
||||
listInvoiceItemTemplates(controller.signal),
|
||||
listInvoiceTemplates(controller.signal),
|
||||
])
|
||||
setBusiness(biz)
|
||||
setItemTemplates(items.items.filter((t) => t.isActive))
|
||||
setInvoiceTemplates(templates.items.filter((t) => t.isActive))
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load invoice form.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [businessId])
|
||||
|
||||
function resetBlankDraft() {
|
||||
setSourceTemplateId('')
|
||||
setInvoiceTemplateId(undefined)
|
||||
setInvoiceName('')
|
||||
setTopText('')
|
||||
setNotes('')
|
||||
setDraftItems([emptyDraftItem()])
|
||||
setKeyPoints([])
|
||||
setAccounts([])
|
||||
setSelectedItemTemplateId('')
|
||||
setFormError('')
|
||||
}
|
||||
|
||||
function applyInvoiceTemplate(templateId: string) {
|
||||
setSourceTemplateId(templateId)
|
||||
if (!templateId) {
|
||||
resetBlankDraft()
|
||||
return
|
||||
}
|
||||
const template = invoiceTemplates.find((t) => t.id === templateId)
|
||||
if (!template) return
|
||||
const drafts = draftsFromInvoiceTemplate(template)
|
||||
setInvoiceTemplateId(template.id)
|
||||
setInvoiceName(drafts.name)
|
||||
setTopText(drafts.topText)
|
||||
setDraftItems(drafts.items)
|
||||
setKeyPoints(drafts.keyPoints)
|
||||
setAccounts(drafts.accounts)
|
||||
setSelectedItemTemplateId('')
|
||||
setFormError('')
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
setFormError('')
|
||||
let items
|
||||
try {
|
||||
items = buildLineItemsPayload(draftItems)
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : 'Invalid invoice items.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createBusinessInvoice(businessId, {
|
||||
items,
|
||||
name: invoiceName.trim() || undefined,
|
||||
topText: isEmptyRichText(topText) ? undefined : topText,
|
||||
notes: notes.trim() || undefined,
|
||||
invoiceTemplateId,
|
||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||
accounts: buildAccountsPayload(accounts),
|
||||
})
|
||||
showToast('Invoice issued.', 'success')
|
||||
navigate(listPath)
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : 'Unable to create invoice.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<Link to={listPath} className={styles.backLink}>
|
||||
<ArrowLeft size={16} />
|
||||
Back to invoices
|
||||
</Link>
|
||||
<h2 className={pageStyles.pageTitle}>Issue invoice · {businessName}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Select an invoice template and edit it for this business, or create a blank invoice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className={styles.alertError}>{error}</p> : null}
|
||||
{loading ? <p className={tableStyles.meta}>Loading…</p> : null}
|
||||
|
||||
{!loading ? (
|
||||
<section className={settingsStyles.section}>
|
||||
<div className={styles.metaGrid}>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="source-template">Start from template</label>
|
||||
<select
|
||||
id="source-template"
|
||||
value={sourceTemplateId}
|
||||
onChange={(e) => applyInvoiceTemplate(e.target.value)}
|
||||
>
|
||||
<option value="">Blank invoice</option>
|
||||
{invoiceTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name} · {template.items.length} items
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="invoice-name">Name (optional)</label>
|
||||
<input
|
||||
id="invoice-name"
|
||||
value={invoiceName}
|
||||
onChange={(e) => setInvoiceName(e.target.value)}
|
||||
placeholder="e.g. Website redesign package"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoiceTemplates.length === 0 ? (
|
||||
<p className={styles.templateHint}>
|
||||
No invoice templates yet. Manage them in{' '}
|
||||
<Link to="/settings">Settings → Invoice templates</Link>, or fill a blank invoice
|
||||
below.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={`${tableStyles.field} ${styles.fullField}`}>
|
||||
<label>Top text</label>
|
||||
<RichTextEditor
|
||||
value={topText}
|
||||
onChange={setTopText}
|
||||
placeholder="Optional intro text at the top of the invoice"
|
||||
editorMinHeight={88}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${tableStyles.field} ${styles.fullField}`}>
|
||||
<label htmlFor="invoice-notes">Notes</label>
|
||||
<input
|
||||
id="invoice-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Optional internal notes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InvoiceDraftFields
|
||||
itemTemplates={itemTemplates}
|
||||
items={draftItems}
|
||||
keyPoints={keyPoints}
|
||||
accounts={accounts}
|
||||
selectedItemTemplateId={selectedItemTemplateId}
|
||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||
onItemsChange={setDraftItems}
|
||||
onKeyPointsChange={setKeyPoints}
|
||||
onAccountsChange={setAccounts}
|
||||
/>
|
||||
|
||||
<div className={styles.createTotalRow}>
|
||||
<div className={styles.createTotal}>Total: {formatIrtPrice(createTotal)}</div>
|
||||
</div>
|
||||
|
||||
{formError ? <p className={styles.alertError}>{formError}</p> : null}
|
||||
|
||||
<div className={tableStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={() => navigate(listPath)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Issuing…' : 'Issue invoice'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 20px 16px 40px;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 55% at 8% 0%, rgba(56, 189, 248, 0.22), transparent 55%),
|
||||
radial-gradient(ellipse 55% 45% at 92% 8%, rgba(167, 139, 250, 0.18), transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at 50% 100%, rgba(52, 211, 153, 0.12), transparent 55%),
|
||||
linear-gradient(165deg, #e8f1ff 0%, #eef2ff 42%, #f3f7fb 100%);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
margin: 12px auto 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbarHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.printBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: var(--field-height, 38px);
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
border: none;
|
||||
background: var(--primary, #0a1628);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.printBtn:hover {
|
||||
background: var(--primary-dark, #050b14);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 22px 22px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(22px);
|
||||
-webkit-backdrop-filter: blur(22px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.topText {
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary, #475569);
|
||||
}
|
||||
|
||||
.topText p {
|
||||
margin: 0 0 0.4em;
|
||||
}
|
||||
|
||||
.topText p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 14px;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.itemMain {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.itemDesc {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.itemPrice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #0f172a);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.priceWas {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-end;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.totals > div {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-width: 200px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.totalRow {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.keyPoints {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary, #475569);
|
||||
}
|
||||
|
||||
.accounts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.account {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.accountBank {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.accountDetails {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary, #475569);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.22);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.footerLink {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.footerLink:hover .footerName {
|
||||
color: var(--primary, #0a1628);
|
||||
}
|
||||
|
||||
.footerLogo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footerLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footerName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #475569);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sheet {
|
||||
padding: 16px 14px 18px;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.itemPrice {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.accountDetails {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* —— Print / Save as PDF —— */
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 12mm 11mm;
|
||||
}
|
||||
|
||||
:global(html),
|
||||
:global(body) {
|
||||
background: #fff !important;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: #fff !important;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.item,
|
||||
.account {
|
||||
background: #fff !important;
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.itemPrice,
|
||||
.totals,
|
||||
.totalRow {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.footerLink {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.accountDetails {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { FileDown } from 'lucide-react'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import { isEmptyRichText } from '../components/RichTextEditor'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { getPlatformMarketingUrl } from '../lib/config'
|
||||
import { getPublicInvoice } from '../services/invoiceService'
|
||||
import type { PublicInvoice } from '../types/invoice'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import styles from './PublicInvoicePage.module.css'
|
||||
|
||||
function formatDate(value: string) {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function PublicInvoicePage() {
|
||||
const { invoiceId = '' } = useParams()
|
||||
const [invoice, setInvoice] = useState<PublicInvoice | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const marketingUrl = getPlatformMarketingUrl()
|
||||
|
||||
useEffect(() => {
|
||||
if (!invoiceId) return
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
void getPublicInvoice(invoiceId, controller.signal)
|
||||
.then((data) => setInvoice(data))
|
||||
.catch((err) => {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load invoice.')
|
||||
setInvoice(null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [invoiceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!invoice) return
|
||||
const previous = document.title
|
||||
document.title = invoice.name?.trim() || `Invoice #${invoice.id}`
|
||||
return () => {
|
||||
document.title = previous
|
||||
}
|
||||
}, [invoice])
|
||||
|
||||
function handlePrint() {
|
||||
window.print()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page} lang="en">
|
||||
<div className={styles.sheet}>
|
||||
{loading ? <p className={styles.muted}>Loading invoice…</p> : null}
|
||||
{error ? <p className={styles.error}>{error}</p> : null}
|
||||
|
||||
{invoice ? (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>{invoice.name || `Invoice #${invoice.id}`}</h1>
|
||||
<p className={styles.meta}>
|
||||
Issued {formatDate(invoice.issuedAt)}
|
||||
{invoice.business?.name ? ` · ${invoice.business.name}` : ''}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{invoice.topText && !isEmptyRichText(invoice.topText) ? (
|
||||
<div
|
||||
className={styles.topText}
|
||||
dangerouslySetInnerHTML={{ __html: invoice.topText }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Items</h2>
|
||||
<div className={styles.items}>
|
||||
{(invoice.items ?? []).map((item) => {
|
||||
const effective =
|
||||
item.discountedPrice !== null &&
|
||||
item.discountedPrice !== undefined &&
|
||||
item.discountedPrice < item.price
|
||||
? item.discountedPrice
|
||||
: item.price
|
||||
return (
|
||||
<article key={item.id} className={styles.item}>
|
||||
<div className={styles.itemMain}>
|
||||
<h3 className={styles.itemTitle}>{item.title}</h3>
|
||||
{item.description ? (
|
||||
<p className={styles.itemDesc}>{item.description}</p>
|
||||
) : null}
|
||||
<div className={styles.itemMeta}>
|
||||
{item.duration ? <span>{item.duration}</span> : null}
|
||||
{item.worktime ? <span>{item.worktime}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.itemPrice}>
|
||||
{item.discountedPrice !== null &&
|
||||
item.discountedPrice !== undefined &&
|
||||
item.discountedPrice < item.price ? (
|
||||
<span className={styles.priceWas}>{formatIrtPrice(item.price)}</span>
|
||||
) : null}
|
||||
<span>{formatIrtPrice(effective)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.totals}>
|
||||
<div>
|
||||
<span>Subtotal</span>
|
||||
<strong>{formatIrtPrice(invoice.subtotal ?? 0)}</strong>
|
||||
</div>
|
||||
<div className={styles.totalRow}>
|
||||
<span>Total</span>
|
||||
<strong>{formatIrtPrice(invoice.total ?? 0)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(invoice.keyPoints?.length ?? 0) > 0 ? (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Key points</h2>
|
||||
<ul className={styles.keyPoints}>
|
||||
{invoice.keyPoints!.map((kp) => (
|
||||
<li key={kp.id}>{kp.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(invoice.accounts?.length ?? 0) > 0 ? (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Account numbers</h2>
|
||||
<div className={styles.accounts}>
|
||||
{invoice.accounts!.map((acc) => {
|
||||
const details = [
|
||||
acc.accountHolderName,
|
||||
acc.cardNumber ? `Card: ${acc.cardNumber}` : null,
|
||||
acc.iban ? `IBAN: ${acc.iban}` : null,
|
||||
].filter(Boolean)
|
||||
return (
|
||||
<div key={acc.id} className={styles.account}>
|
||||
<strong className={styles.accountBank}>{acc.bankName}</strong>
|
||||
{details.length > 0 ? (
|
||||
<p className={styles.accountDetails}>{details.join(' · ')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
className={styles.footerLink}
|
||||
href={marketingUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img src={meshkeeLogo} alt="" className={styles.footerLogo} />
|
||||
<span className={styles.footerLabel}>Issued by</span>
|
||||
<span className={styles.footerName}>Meshkee E-Commerce Group</span>
|
||||
</a>
|
||||
</footer>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{invoice ? (
|
||||
<div className={styles.toolbar}>
|
||||
<p className={styles.toolbarHint}>
|
||||
Save as PDF from the print dialog (Destination → Save as PDF).
|
||||
</p>
|
||||
<button type="button" className={styles.printBtn} onClick={handlePrint}>
|
||||
<FileDown size={16} aria-hidden />
|
||||
Print to PDF
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,10 @@
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.sectionSpaced {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -45,6 +49,13 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topTextPreview {
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createInvoiceItemTemplate,
|
||||
deleteInvoiceItemTemplate,
|
||||
deleteInvoiceTemplate,
|
||||
listInvoiceItemTemplates,
|
||||
listInvoiceTemplates,
|
||||
updateInvoiceItemTemplate,
|
||||
} from '../services/invoiceService'
|
||||
import type { InvoiceItemTemplate } from '../types/invoice'
|
||||
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
|
||||
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import tableStyles from './BusinessesPage.module.css'
|
||||
import styles from './SettingsPage.module.css'
|
||||
|
||||
type TemplateDraft = {
|
||||
type ItemDraft = {
|
||||
title: string
|
||||
duration: string
|
||||
worktime: string
|
||||
@@ -26,7 +29,7 @@ type TemplateDraft = {
|
||||
discountedPrice: string
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: TemplateDraft = {
|
||||
const EMPTY_ITEM_DRAFT: ItemDraft = {
|
||||
title: '',
|
||||
duration: '',
|
||||
worktime: '',
|
||||
@@ -35,7 +38,7 @@ const EMPTY_DRAFT: TemplateDraft = {
|
||||
discountedPrice: '',
|
||||
}
|
||||
|
||||
function draftFromTemplate(t: InvoiceItemTemplate): TemplateDraft {
|
||||
function itemDraftFromTemplate(t: InvoiceItemTemplate): ItemDraft {
|
||||
return {
|
||||
title: t.title,
|
||||
duration: t.duration ?? '',
|
||||
@@ -49,14 +52,10 @@ function draftFromTemplate(t: InvoiceItemTemplate): TemplateDraft {
|
||||
}
|
||||
}
|
||||
|
||||
function toPayload(draft: TemplateDraft) {
|
||||
function itemDraftToPayload(draft: ItemDraft) {
|
||||
const price = parseIrtInput(draft.price)
|
||||
if (!draft.title.trim()) {
|
||||
throw new Error('Title is required.')
|
||||
}
|
||||
if (price === null) {
|
||||
throw new Error('Price is required.')
|
||||
}
|
||||
if (!draft.title.trim()) throw new Error('Title is required.')
|
||||
if (price === null) throw new Error('Price is required.')
|
||||
const discountedPrice = draft.discountedPrice.trim()
|
||||
? parseIrtInput(draft.discountedPrice)
|
||||
: null
|
||||
@@ -77,27 +76,34 @@ function toPayload(draft: TemplateDraft) {
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [templates, setTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<InvoiceItemTemplate | null>(null)
|
||||
const [draft, setDraft] = useState<TemplateDraft>(EMPTY_DRAFT)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [removeTarget, setRemoveTarget] = useState<InvoiceItemTemplate | null>(null)
|
||||
const [itemEditorOpen, setItemEditorOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<InvoiceItemTemplate | null>(null)
|
||||
const [itemDraft, setItemDraft] = useState<ItemDraft>(EMPTY_ITEM_DRAFT)
|
||||
const [itemSubmitting, setItemSubmitting] = useState(false)
|
||||
const [itemFormError, setItemFormError] = useState('')
|
||||
const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null)
|
||||
const [removeTplTarget, setRemoveTplTarget] = useState<InvoiceTemplate | null>(null)
|
||||
|
||||
async function reload(signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listInvoiceItemTemplates(signal)
|
||||
setTemplates(result.items)
|
||||
const [items, templates] = await Promise.all([
|
||||
listInvoiceItemTemplates(signal),
|
||||
listInvoiceTemplates(signal),
|
||||
])
|
||||
setItemTemplates(items.items)
|
||||
setInvoiceTemplates(templates.items)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load invoice templates.')
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load invoice settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -109,60 +115,71 @@ export function SettingsPage() {
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setDraft(EMPTY_DRAFT)
|
||||
setFormError('')
|
||||
setEditorOpen(true)
|
||||
function openCreateItem() {
|
||||
setEditingItem(null)
|
||||
setItemDraft(EMPTY_ITEM_DRAFT)
|
||||
setItemFormError('')
|
||||
setItemEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(template: InvoiceItemTemplate) {
|
||||
setEditing(template)
|
||||
setDraft(draftFromTemplate(template))
|
||||
setFormError('')
|
||||
setEditorOpen(true)
|
||||
function openEditItem(template: InvoiceItemTemplate) {
|
||||
setEditingItem(template)
|
||||
setItemDraft(itemDraftFromTemplate(template))
|
||||
setItemFormError('')
|
||||
setItemEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setFormError('')
|
||||
async function handleSaveItem() {
|
||||
setItemFormError('')
|
||||
let payload
|
||||
try {
|
||||
payload = toPayload(draft)
|
||||
payload = itemDraftToPayload(itemDraft)
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : 'Invalid form.')
|
||||
setItemFormError(err instanceof Error ? err.message : 'Invalid form.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setItemSubmitting(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateInvoiceItemTemplate(editing.id, payload)
|
||||
showToast('Invoice item updated.', 'success')
|
||||
if (editingItem) {
|
||||
await updateInvoiceItemTemplate(editingItem.id, payload)
|
||||
showToast('Item template updated.', 'success')
|
||||
} else {
|
||||
await createInvoiceItemTemplate(payload)
|
||||
showToast('Invoice item created.', 'success')
|
||||
showToast('Item template created.', 'success')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
setItemEditorOpen(false)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : 'Unable to save invoice item.')
|
||||
setItemFormError(err instanceof ApiError ? err.message : 'Unable to save item template.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setItemSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!removeTarget) return
|
||||
async function handleRemoveItem() {
|
||||
if (!removeItemTarget) return
|
||||
try {
|
||||
await deleteInvoiceItemTemplate(removeTarget.id)
|
||||
showToast('Invoice item removed.', 'success')
|
||||
setRemoveTarget(null)
|
||||
await deleteInvoiceItemTemplate(removeItemTarget.id)
|
||||
showToast('Item template removed.', 'success')
|
||||
setRemoveItemTarget(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'Unable to remove item.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveTpl() {
|
||||
if (!removeTplTarget) return
|
||||
try {
|
||||
await deleteInvoiceTemplate(removeTplTarget.id)
|
||||
showToast('Invoice template removed.', 'success')
|
||||
setRemoveTplTarget(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'Unable to remove template.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
@@ -174,30 +191,130 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className={styles.alertError}>{error}</p> : null}
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitleRow}>
|
||||
<FileStack size={18} />
|
||||
<div>
|
||||
<h3 className={styles.sectionTitle}>Invoice templates</h3>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Full blueprints (name, top text, items, key points, bank accounts) used when issuing
|
||||
invoices to businesses. Pick one and edit, or start blank.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={() => navigate('/settings/invoice-templates/new')}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.tableHeaderTitle}>Templates</div>
|
||||
<div className={tableStyles.meta}>
|
||||
{loading
|
||||
? 'Loading…'
|
||||
: `${invoiceTemplates.length} template${invoiceTemplates.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<table className={tableStyles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={tableStyles.th}>Name</th>
|
||||
<th className={tableStyles.th}>Items</th>
|
||||
<th className={tableStyles.th}>Key points</th>
|
||||
<th className={tableStyles.th}>Accounts</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && invoiceTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={5}>
|
||||
<div className={styles.emptyState}>
|
||||
<SettingsIcon size={20} />
|
||||
<span>No invoice templates yet. Add one to speed up issuing invoices.</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{invoiceTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.itemTitle}>{template.name}</div>
|
||||
{template.topText && !isEmptyRichText(template.topText) ? (
|
||||
<div className={`${tableStyles.subText} ${styles.topTextPreview}`} title={richTextToPlain(template.topText)}>
|
||||
{richTextToPlain(template.topText)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className={tableStyles.td}>{template.items.length}</td>
|
||||
<td className={tableStyles.td}>{template.keyPoints.length}</td>
|
||||
<td className={tableStyles.td}>{template.accounts.length}</td>
|
||||
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||
<div className={tableStyles.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => navigate(`/settings/invoice-templates/${template.id}`)}
|
||||
title="Edit"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
||||
onClick={() => setRemoveTplTarget(template)}
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${styles.section} ${styles.sectionSpaced}`}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitleRow}>
|
||||
<FileText size={18} />
|
||||
<div>
|
||||
<h3 className={styles.sectionTitle}>Invoices</h3>
|
||||
<h3 className={styles.sectionTitle}>Invoice item templates</h3>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Predefined line items you can reuse when issuing invoices to businesses.
|
||||
Reusable line items you can drop into invoice templates or when issuing an invoice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className={`${tableStyles.btn} ${tableStyles.btnPrimary}`} onClick={openCreate}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={openCreateItem}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <p className={styles.alertError}>{error}</p> : null}
|
||||
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.tableHeaderTitle}>Predefined invoice items</div>
|
||||
<div className={tableStyles.tableHeaderTitle}>Predefined line items</div>
|
||||
<div className={tableStyles.meta}>
|
||||
{loading ? 'Loading…' : `${templates.length} item${templates.length === 1 ? '' : 's'}`}
|
||||
{loading
|
||||
? 'Loading…'
|
||||
: `${itemTemplates.length} item${itemTemplates.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<table className={tableStyles.table}>
|
||||
@@ -212,17 +329,17 @@ export function SettingsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && templates.length === 0 ? (
|
||||
{!loading && itemTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
<div className={styles.emptyState}>
|
||||
<SettingsIcon size={20} />
|
||||
<span>No predefined items yet. Add one to speed up invoice creation.</span>
|
||||
<span>No predefined items yet.</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{templates.map((template) => (
|
||||
{itemTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
<td className={tableStyles.td}>
|
||||
<div className={styles.itemTitle}>{template.title}</div>
|
||||
@@ -243,7 +360,7 @@ export function SettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => openEdit(template)}
|
||||
onClick={() => openEditItem(template)}
|
||||
title="Edit"
|
||||
aria-label="Edit"
|
||||
>
|
||||
@@ -252,7 +369,7 @@ export function SettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(template)}
|
||||
onClick={() => setRemoveItemTarget(template)}
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
>
|
||||
@@ -268,108 +385,122 @@ export function SettingsPage() {
|
||||
</section>
|
||||
|
||||
<p className={styles.hint}>
|
||||
Tip: open a business from <Link to="/businesses">Businesses</Link> to list invoices or issue
|
||||
a new one.
|
||||
Tip: open a business from <Link to="/businesses">Businesses</Link> to issue an invoice from a
|
||||
template or from scratch.
|
||||
</p>
|
||||
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title={editing ? 'Edit invoice item' : 'Add invoice item'}
|
||||
onClose={() => !submitting && setEditorOpen(false)}
|
||||
open={itemEditorOpen}
|
||||
title={editingItem ? 'Edit item template' : 'Add item template'}
|
||||
onClose={() => !itemSubmitting && setItemEditorOpen(false)}
|
||||
wide
|
||||
>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${tableStyles.field} ${styles.span2}`}>
|
||||
<label htmlFor="tpl-title">Title</label>
|
||||
<label htmlFor="item-title">Title</label>
|
||||
<input
|
||||
id="tpl-title"
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
|
||||
id="item-title"
|
||||
value={itemDraft.title}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, title: e.target.value }))}
|
||||
placeholder="e.g. Website setup"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="tpl-duration">Duration</label>
|
||||
<label htmlFor="item-duration">Duration</label>
|
||||
<input
|
||||
id="tpl-duration"
|
||||
value={draft.duration}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, duration: e.target.value }))}
|
||||
id="item-duration"
|
||||
value={itemDraft.duration}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, duration: e.target.value }))}
|
||||
placeholder="e.g. 3 months"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="tpl-worktime">Worktime</label>
|
||||
<label htmlFor="item-worktime">Worktime</label>
|
||||
<input
|
||||
id="tpl-worktime"
|
||||
value={draft.worktime}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, worktime: e.target.value }))}
|
||||
id="item-worktime"
|
||||
value={itemDraft.worktime}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, worktime: e.target.value }))}
|
||||
placeholder="e.g. 40 hours"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="tpl-price">Price (IRT)</label>
|
||||
<label htmlFor="item-price">Price (IRT)</label>
|
||||
<input
|
||||
id="tpl-price"
|
||||
id="item-price"
|
||||
inputMode="numeric"
|
||||
value={draft.price}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, price: formatIrtInput(e.target.value) }))}
|
||||
value={itemDraft.price}
|
||||
onChange={(e) =>
|
||||
setItemDraft((d) => ({ ...d, price: formatIrtInput(e.target.value) }))
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className={tableStyles.field}>
|
||||
<label htmlFor="tpl-discount">Discounted price (IRT)</label>
|
||||
<label htmlFor="item-discount">Discounted price (IRT)</label>
|
||||
<input
|
||||
id="tpl-discount"
|
||||
id="item-discount"
|
||||
inputMode="numeric"
|
||||
value={draft.discountedPrice}
|
||||
value={itemDraft.discountedPrice}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, discountedPrice: formatIrtInput(e.target.value) }))
|
||||
setItemDraft((d) => ({ ...d, discountedPrice: formatIrtInput(e.target.value) }))
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.span2}`}>
|
||||
<label htmlFor="tpl-desc">Description</label>
|
||||
<label htmlFor="item-desc">Description</label>
|
||||
<textarea
|
||||
id="tpl-desc"
|
||||
id="item-desc"
|
||||
rows={3}
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
|
||||
placeholder="Optional details shown on the invoice line"
|
||||
value={itemDraft.description}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, description: e.target.value }))}
|
||||
placeholder="Optional details"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{formError ? <p className={styles.alertError}>{formError}</p> : null}
|
||||
{itemFormError ? <p className={styles.alertError}>{itemFormError}</p> : null}
|
||||
<div className={tableStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={submitting}
|
||||
onClick={() => setItemEditorOpen(false)}
|
||||
disabled={itemSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={submitting}
|
||||
onClick={() => void handleSaveItem()}
|
||||
disabled={itemSubmitting}
|
||||
>
|
||||
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create item'}
|
||||
{itemSubmitting ? 'Saving…' : editingItem ? 'Save changes' : 'Create item'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeTarget}
|
||||
title="Remove invoice item"
|
||||
open={!!removeTplTarget}
|
||||
title="Remove invoice template"
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove “${removeTarget.title}” from predefined invoice items?`
|
||||
removeTplTarget
|
||||
? `Remove “${removeTplTarget.name}” from invoice templates?`
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
onConfirm={() => void handleRemove()}
|
||||
onCancel={() => setRemoveTplTarget(null)}
|
||||
onConfirm={() => void handleRemoveTpl()}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeItemTarget}
|
||||
title="Remove item template"
|
||||
message={
|
||||
removeItemTarget
|
||||
? `Remove “${removeItemTarget.title}” from predefined line items?`
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveItemTarget(null)}
|
||||
onConfirm={() => void handleRemoveItem()}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -2,16 +2,21 @@ import { apiRequest } from '../lib/api'
|
||||
import type {
|
||||
CreateInvoiceItemTemplatePayload,
|
||||
CreateInvoicePayload,
|
||||
CreateInvoiceTemplatePayload,
|
||||
Invoice,
|
||||
InvoiceItemTemplate,
|
||||
InvoiceItemTemplatesResponse,
|
||||
InvoiceStatus,
|
||||
InvoiceTemplatesResponse,
|
||||
InvoiceTemplate,
|
||||
InvoiceTemplatesListResponse,
|
||||
InvoicesListResponse,
|
||||
PublicInvoice,
|
||||
UpdateInvoiceItemTemplatePayload,
|
||||
UpdateInvoiceTemplatePayload,
|
||||
} from '../types/invoice'
|
||||
|
||||
export function listInvoiceItemTemplates(signal?: AbortSignal) {
|
||||
return apiRequest<InvoiceTemplatesResponse>('/invoice-item-templates', {
|
||||
return apiRequest<InvoiceItemTemplatesResponse>('/invoice-item-templates', {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
@@ -43,6 +48,43 @@ export function deleteInvoiceItemTemplate(templateId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function listInvoiceTemplates(signal?: AbortSignal) {
|
||||
return apiRequest<InvoiceTemplatesListResponse>('/invoice-templates', {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function getInvoiceTemplate(templateId: string, signal?: AbortSignal) {
|
||||
return apiRequest<InvoiceTemplate>(`/invoice-templates/${templateId}`, {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function createInvoiceTemplate(payload: CreateInvoiceTemplatePayload) {
|
||||
return apiRequest<InvoiceTemplate>('/invoice-templates', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInvoiceTemplate(templateId: string, payload: UpdateInvoiceTemplatePayload) {
|
||||
return apiRequest<InvoiceTemplate>(`/invoice-templates/${templateId}`, {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteInvoiceTemplate(templateId: string) {
|
||||
return apiRequest<{ ok: boolean }>(`/invoice-templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export interface ListBusinessInvoicesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -98,3 +140,11 @@ export function deleteBusinessInvoice(businessId: string, invoiceId: string) {
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** Public show-page payload (no auth). */
|
||||
export function getPublicInvoice(invoiceId: string, signal?: AbortSignal) {
|
||||
return apiRequest<PublicInvoice>(`/public/invoices/${invoiceId}`, {
|
||||
auth: false,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,21 @@ export interface InvoiceItem {
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface InvoiceKeyPoint {
|
||||
id: string
|
||||
text: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface InvoiceAccount {
|
||||
id: string
|
||||
bankName: string
|
||||
accountHolderName: string | null
|
||||
cardNumber: string | null
|
||||
iban: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string
|
||||
businessId: string
|
||||
@@ -36,7 +51,9 @@ export interface Invoice {
|
||||
issuerBusinessId: string | null
|
||||
status: InvoiceStatus
|
||||
name: string | null
|
||||
topText: string | null
|
||||
notes: string | null
|
||||
invoiceTemplateId: string | null
|
||||
publicUrl: string | null
|
||||
issuedBy: string | null
|
||||
issuedAt: string
|
||||
@@ -53,10 +70,29 @@ export interface Invoice {
|
||||
lastName: string | null
|
||||
} | null
|
||||
items?: InvoiceItem[]
|
||||
keyPoints?: InvoiceKeyPoint[]
|
||||
accounts?: InvoiceAccount[]
|
||||
subtotal?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** Public viewer payload (no notes / issuer). */
|
||||
export type PublicInvoice = Pick<
|
||||
Invoice,
|
||||
| 'id'
|
||||
| 'status'
|
||||
| 'name'
|
||||
| 'topText'
|
||||
| 'issuedAt'
|
||||
| 'business'
|
||||
| 'items'
|
||||
| 'keyPoints'
|
||||
| 'accounts'
|
||||
| 'subtotal'
|
||||
| 'total'
|
||||
| 'publicUrl'
|
||||
>
|
||||
|
||||
export interface InvoiceItemInput {
|
||||
templateId?: string
|
||||
title: string
|
||||
@@ -67,17 +103,86 @@ export interface InvoiceItemInput {
|
||||
discountedPrice?: number | null
|
||||
}
|
||||
|
||||
export interface InvoiceKeyPointInput {
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface InvoiceAccountInput {
|
||||
bankName: string
|
||||
accountHolderName?: string
|
||||
cardNumber?: string
|
||||
iban?: string
|
||||
}
|
||||
|
||||
export interface CreateInvoicePayload {
|
||||
items: InvoiceItemInput[]
|
||||
name?: string
|
||||
topText?: string
|
||||
notes?: string
|
||||
invoiceTemplateId?: string
|
||||
keyPoints?: InvoiceKeyPointInput[]
|
||||
accounts?: InvoiceAccountInput[]
|
||||
status?: InvoiceStatus
|
||||
}
|
||||
|
||||
export interface InvoiceTemplatesResponse {
|
||||
export interface InvoiceTemplateItem {
|
||||
id: string
|
||||
itemTemplateId: string | null
|
||||
title: string
|
||||
duration: string | null
|
||||
worktime: string | null
|
||||
description: string | null
|
||||
price: number
|
||||
discountedPrice: number | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface InvoiceTemplate {
|
||||
id: string
|
||||
ownerScope: 'platform' | 'business'
|
||||
businessId: string | null
|
||||
name: string
|
||||
topText: string | null
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
items: InvoiceTemplateItem[]
|
||||
keyPoints: InvoiceKeyPoint[]
|
||||
accounts: InvoiceAccount[]
|
||||
}
|
||||
|
||||
export interface InvoiceTemplateItemInput {
|
||||
itemTemplateId?: string
|
||||
title: string
|
||||
duration?: string
|
||||
worktime?: string
|
||||
description?: string
|
||||
price: number
|
||||
discountedPrice?: number | null
|
||||
}
|
||||
|
||||
export interface CreateInvoiceTemplatePayload {
|
||||
name: string
|
||||
topText?: string
|
||||
items: InvoiceTemplateItemInput[]
|
||||
keyPoints?: InvoiceKeyPointInput[]
|
||||
accounts?: InvoiceAccountInput[]
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type UpdateInvoiceTemplatePayload = Partial<CreateInvoiceTemplatePayload> & {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface InvoiceItemTemplatesResponse {
|
||||
items: InvoiceItemTemplate[]
|
||||
}
|
||||
|
||||
export interface InvoiceTemplatesListResponse {
|
||||
items: InvoiceTemplate[]
|
||||
}
|
||||
|
||||
export interface InvoicesListResponse {
|
||||
items: Invoice[]
|
||||
page: number
|
||||
@@ -99,3 +204,6 @@ export interface CreateInvoiceItemTemplatePayload {
|
||||
export type UpdateInvoiceItemTemplatePayload = Partial<CreateInvoiceItemTemplatePayload> & {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated use InvoiceItemTemplatesResponse */
|
||||
export type InvoiceTemplatesResponse = InvoiceItemTemplatesResponse
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
||||
import type {
|
||||
InvoiceAccountInput,
|
||||
InvoiceItemInput,
|
||||
InvoiceItemTemplate,
|
||||
InvoiceKeyPointInput,
|
||||
InvoiceTemplate,
|
||||
InvoiceTemplateItemInput,
|
||||
} from '../types/invoice'
|
||||
|
||||
export type DraftLineItem = {
|
||||
key: string
|
||||
itemTemplateId?: string
|
||||
title: string
|
||||
duration: string
|
||||
worktime: string
|
||||
description: string
|
||||
price: string
|
||||
discountedPrice: string
|
||||
}
|
||||
|
||||
export type DraftKeyPoint = { key: string; text: string }
|
||||
|
||||
export type DraftAccount = {
|
||||
key: string
|
||||
bankName: string
|
||||
accountHolderName: string
|
||||
cardNumber: string
|
||||
iban: string
|
||||
}
|
||||
|
||||
export function newKey() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
export function emptyDraftItem(): DraftLineItem {
|
||||
return {
|
||||
key: newKey(),
|
||||
title: '',
|
||||
duration: '',
|
||||
worktime: '',
|
||||
description: '',
|
||||
price: '',
|
||||
discountedPrice: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyDraftKeyPoint(): DraftKeyPoint {
|
||||
return { key: newKey(), text: '' }
|
||||
}
|
||||
|
||||
export function emptyDraftAccount(): DraftAccount {
|
||||
return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
|
||||
}
|
||||
|
||||
export function draftItemFromItemTemplate(template: InvoiceItemTemplate): DraftLineItem {
|
||||
return {
|
||||
key: newKey(),
|
||||
itemTemplateId: template.id,
|
||||
title: template.title,
|
||||
duration: template.duration ?? '',
|
||||
worktime: template.worktime ?? '',
|
||||
description: template.description ?? '',
|
||||
price: formatIrtInput(String(Math.round(template.price))),
|
||||
discountedPrice:
|
||||
template.discountedPrice === null || template.discountedPrice === undefined
|
||||
? ''
|
||||
: formatIrtInput(String(Math.round(template.discountedPrice))),
|
||||
}
|
||||
}
|
||||
|
||||
export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
|
||||
name: string
|
||||
topText: string
|
||||
items: DraftLineItem[]
|
||||
keyPoints: DraftKeyPoint[]
|
||||
accounts: DraftAccount[]
|
||||
} {
|
||||
return {
|
||||
name: template.name,
|
||||
topText: template.topText ?? '',
|
||||
items:
|
||||
template.items.length > 0
|
||||
? template.items.map((item) => ({
|
||||
key: newKey(),
|
||||
itemTemplateId: item.itemTemplateId ?? undefined,
|
||||
title: item.title,
|
||||
duration: item.duration ?? '',
|
||||
worktime: item.worktime ?? '',
|
||||
description: item.description ?? '',
|
||||
price: formatIrtInput(String(Math.round(item.price))),
|
||||
discountedPrice:
|
||||
item.discountedPrice === null || item.discountedPrice === undefined
|
||||
? ''
|
||||
: formatIrtInput(String(Math.round(item.discountedPrice))),
|
||||
}))
|
||||
: [emptyDraftItem()],
|
||||
keyPoints:
|
||||
template.keyPoints.length > 0
|
||||
? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text }))
|
||||
: [],
|
||||
accounts:
|
||||
template.accounts.length > 0
|
||||
? template.accounts.map((acc) => ({
|
||||
key: newKey(),
|
||||
bankName: acc.bankName,
|
||||
accountHolderName: acc.accountHolderName ?? '',
|
||||
cardNumber: acc.cardNumber ?? '',
|
||||
iban: acc.iban ?? '',
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLineItemsPayload(items: DraftLineItem[]): InvoiceItemInput[] {
|
||||
return items.map((item) => {
|
||||
const title = item.title.trim()
|
||||
const price = parseIrtInput(item.price)
|
||||
if (!title) throw new Error('Each item needs a title.')
|
||||
if (price === null) throw new Error(`Price is required for “${title}”.`)
|
||||
const discountedPrice = item.discountedPrice.trim()
|
||||
? parseIrtInput(item.discountedPrice)
|
||||
: null
|
||||
if (item.discountedPrice.trim() && discountedPrice === null) {
|
||||
throw new Error(`Discounted price is invalid for “${title}”.`)
|
||||
}
|
||||
if (discountedPrice !== null && discountedPrice > price) {
|
||||
throw new Error(`Discounted price cannot exceed price for “${title}”.`)
|
||||
}
|
||||
return {
|
||||
templateId: item.itemTemplateId,
|
||||
title,
|
||||
duration: item.duration.trim() || undefined,
|
||||
worktime: item.worktime.trim() || undefined,
|
||||
description: item.description.trim() || undefined,
|
||||
price,
|
||||
discountedPrice,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildTemplateItemsPayload(items: DraftLineItem[]): InvoiceTemplateItemInput[] {
|
||||
return buildLineItemsPayload(items).map((item) => ({
|
||||
itemTemplateId: item.templateId,
|
||||
title: item.title,
|
||||
duration: item.duration,
|
||||
worktime: item.worktime,
|
||||
description: item.description,
|
||||
price: item.price,
|
||||
discountedPrice: item.discountedPrice,
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointInput[] {
|
||||
return points
|
||||
.map((p) => p.text.trim())
|
||||
.filter(Boolean)
|
||||
.map((text) => ({ text }))
|
||||
}
|
||||
|
||||
export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] {
|
||||
return accounts
|
||||
.map((acc) => ({
|
||||
bankName: acc.bankName.trim(),
|
||||
accountHolderName: acc.accountHolderName.trim() || undefined,
|
||||
cardNumber: acc.cardNumber.trim() || undefined,
|
||||
iban: acc.iban.trim() || undefined,
|
||||
}))
|
||||
.filter((acc) => acc.bankName)
|
||||
}
|
||||
Vendored
+1
@@ -4,6 +4,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_ADMIN_DOMAIN?: string
|
||||
readonly VITE_INVOICE_PUBLIC_DOMAIN?: string
|
||||
readonly VITE_INVOICE_PUBLIC_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
+40
-18
@@ -3,7 +3,7 @@
|
||||
> **For AI agents:** Read this file at the start of a new chat before making changes.
|
||||
> Update this document when a major feature is completed or architecture changes.
|
||||
|
||||
Last updated: July 24, 2026
|
||||
Last updated: July 26, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -139,10 +139,14 @@ Add to `/etc/hosts` (one line per tenant):
|
||||
| `/login` | Login |
|
||||
| `/` | Home |
|
||||
| `/businesses` | Businesses list |
|
||||
| `/businesses/:businessId/invoices` | Business invoices (list + issue) |
|
||||
| `/businesses/:businessId/invoices` | Business invoices list |
|
||||
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
|
||||
| `/users` | Users |
|
||||
| `/websites` | Websites / domains |
|
||||
| `/settings` | Platform settings (invoice item templates) |
|
||||
| `/settings` | Platform settings (invoice templates + item templates) |
|
||||
| `/settings/invoice-templates/new` | Create invoice template |
|
||||
| `/settings/invoice-templates/:templateId` | Edit invoice template |
|
||||
| `/invoices/:invoiceId` | Public invoice viewer (no auth; print to PDF) |
|
||||
| `/profile` | Profile |
|
||||
|
||||
### Customer (`apps/customer`)
|
||||
@@ -311,35 +315,49 @@ Run in order from `MeshkeeApp Backend/database/migrations/`:
|
||||
| `010_category_variations.sql` | variation tables |
|
||||
| `016_product_variation_values.sql` | product variation values |
|
||||
| `036_invoices.sql` | Platform/business invoices + invoice item templates |
|
||||
| `037_invoice_name.sql` | Optional invoice name |
|
||||
| `038_invoice_templates.sql` | Full invoice templates + key points / accounts |
|
||||
| `039_invoice_account_holder.sql` | Account holder name on bank accounts |
|
||||
|
||||
After schema changes: `npx prisma generate` and restart the backend.
|
||||
|
||||
### Invoices (super-admin)
|
||||
|
||||
Platform billing invoices issued **to** a business. Schema supports a future `owner_scope=business` mode (business-issued invoices); UI is super-admin only for now.
|
||||
Two template layers + issued invoices:
|
||||
|
||||
| Layer | Purpose |
|
||||
|-------|---------|
|
||||
| **Invoice item templates** | Reusable line items (title, duration, worktime, desc, price, discounted) |
|
||||
| **Invoice templates** | Full blueprints: name, top text, items (from item templates or custom), duplicatable key points, duplicatable bank accounts (bank name, account holder, card, IBAN) |
|
||||
| **Invoices** | Issued to a business — start from an invoice template (editable) or blank |
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET/POST /invoice-item-templates` | Platform predefined line items (Settings) |
|
||||
| `PATCH/DELETE /invoice-item-templates/:id` | Update/remove template |
|
||||
| `GET/POST /invoice-item-templates` | Platform line-item presets |
|
||||
| `PATCH/DELETE /invoice-item-templates/:id` | Update/remove line-item preset |
|
||||
| `GET/POST /invoice-templates` | Platform full invoice templates |
|
||||
| `GET/PATCH/DELETE /invoice-templates/:id` | Get / update / delete invoice template |
|
||||
| `GET/POST /businesses/:businessId/invoices` | List / issue invoices for a business |
|
||||
| `GET/PATCH/DELETE /businesses/:businessId/invoices/:invoiceId` | Detail, status update, delete |
|
||||
| `GET /public/invoices/:id` | Public show payload (issued/paid, no auth) |
|
||||
|
||||
**Invoice fields:** optional `name`, `notes`, `status` (`draft` \| `issued` \| `paid` \| `cancelled`), `publicUrl`.
|
||||
**Invoice fields:** optional `name`, `topText`, `notes`, `invoiceTemplateId`, `status`, `publicUrl`, nested `items`, `keyPoints`, `accounts` (bank name, account holder, card, IBAN).
|
||||
|
||||
**Line item fields:** `title`, `duration`, `worktime`, `description`, `price`, `discountedPrice` (IRT). Create flow can mix predefined templates + custom lines.
|
||||
**Public invoice viewer (platform):**
|
||||
- Route: super-admin SPA `/invoices/:id` (`PublicInvoicePage`) — glass layout, print-to-PDF, “Issued by” Meshkee footer
|
||||
- Local/dev link: current Vite origin (e.g. `https://meshkee.app:5174/invoices/{id}`)
|
||||
- Production link domain: `VITE_INVOICE_PUBLIC_DOMAIN` / `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`) — optional full origin override via `*_PUBLIC_BASE_URL`
|
||||
- Until `meshkee.com` proxies or hosts `/invoices/*`, production links may need that DNS/nginx wiring (viewer code ships with super-admin build)
|
||||
|
||||
**Public link (platform invoices):** `https://meshkee.com/invoices/{id}`
|
||||
- Backend: `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`) → response field `publicUrl`
|
||||
- Super Admin UI: `VITE_INVOICE_PUBLIC_DOMAIN` fallback helper in `lib/config.ts`
|
||||
- Public viewer page at that URL is **not** implemented yet — link is issued/copied only.
|
||||
|
||||
**Migrations:** `036_invoices.sql`, `037_invoice_name.sql`
|
||||
**Migrations:** `036_invoices.sql`, `037_invoice_name.sql`, `038_invoice_templates.sql`, `039_invoice_account_holder.sql`
|
||||
|
||||
**Super Admin UI:**
|
||||
- `/settings` — Invoices section (CRUD templates)
|
||||
- `/businesses/:businessId/invoices` — list, issue (extra-wide modal), view/copy link, update status, delete
|
||||
- Businesses table row → FileText control opens invoices page
|
||||
- `/settings` — Invoice templates list + item templates (top text preview = one-line ellipsis)
|
||||
- `/settings/invoice-templates/new` · `/settings/invoice-templates/:id` — full-page template editor (not modal)
|
||||
- `/businesses/:businessId/invoices` — list + view modal
|
||||
- `/businesses/:businessId/invoices/new` — full-page issue form
|
||||
- `/invoices/:id` — public viewer (no auth)
|
||||
- Schema supports future `owner_scope=business` (business-owned templates)
|
||||
|
||||
---
|
||||
|
||||
@@ -375,8 +393,12 @@ Platform billing invoices issued **to** a business. Schema supports a future `ow
|
||||
| Area | Path |
|
||||
|------|------|
|
||||
| Settings / invoice templates | `apps/super-admin/src/pages/SettingsPage.tsx` |
|
||||
| Template / issue editors | `InvoiceTemplateEditorPage.tsx`, `IssueInvoicePage.tsx` |
|
||||
| Shared draft fields | `apps/super-admin/src/components/InvoiceDraftFields.tsx` |
|
||||
| Public invoice viewer | `apps/super-admin/src/pages/PublicInvoicePage.tsx` |
|
||||
| Business invoices | `apps/super-admin/src/pages/BusinessInvoicesPage.tsx` |
|
||||
| Invoice API client | `apps/super-admin/src/services/invoiceService.ts` |
|
||||
| Invoice URL helpers | `apps/super-admin/src/lib/config.ts` |
|
||||
|
||||
---
|
||||
|
||||
@@ -472,7 +494,7 @@ SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h (option A:
|
||||
|
||||
## Suggested next work
|
||||
|
||||
- Public invoice page at `meshkee.com/invoices/{id}` (platform invoices)
|
||||
- Point `meshkee.com/invoices/*` at the public invoice viewer (proxy or dedicated host)
|
||||
- Business-dashboard invoice templates + issue flow (`owner_scope=business`)
|
||||
- Migrate business and super-admin to `@meshkee/dashboard-core` / `@meshkee/dashboard-ui`
|
||||
- Connect product comments to backend
|
||||
|
||||
Reference in New Issue
Block a user