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:
Alireza Hassani
2026-07-26 11:04:29 +03:30
co-authored by Cursor
parent 8f4af7a16c
commit cbffb23ec3
20 changed files with 2501 additions and 564 deletions
+3
View File
@@ -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
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
+19 -1
View File
@@ -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;
+236 -105
View File
@@ -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,
})
}
+109 -1
View File
@@ -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
+170
View File
@@ -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)
}
+1
View File
@@ -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
View File
@@ -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