Files
dashboards/apps/super-admin/src/pages/SettingsPage.tsx
T
Alireza HassaniandCursor 8f4af7a16c Add super-admin invoices: settings templates and per-business issue flow.
Platform invoice templates live under Settings; each business can list and issue invoices with optional name and a meshkee.com public link.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 20:59:34 +03:30

377 lines
13 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
import {
createInvoiceItemTemplate,
deleteInvoiceItemTemplate,
listInvoiceItemTemplates,
updateInvoiceItemTemplate,
} from '../services/invoiceService'
import type { InvoiceItemTemplate } 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 = {
title: string
duration: string
worktime: string
description: string
price: string
discountedPrice: string
}
const EMPTY_DRAFT: TemplateDraft = {
title: '',
duration: '',
worktime: '',
description: '',
price: '',
discountedPrice: '',
}
function draftFromTemplate(t: InvoiceItemTemplate): TemplateDraft {
return {
title: t.title,
duration: t.duration ?? '',
worktime: t.worktime ?? '',
description: t.description ?? '',
price: formatIrtInput(String(Math.round(t.price))),
discountedPrice:
t.discountedPrice === null || t.discountedPrice === undefined
? ''
: formatIrtInput(String(Math.round(t.discountedPrice))),
}
}
function toPayload(draft: TemplateDraft) {
const price = parseIrtInput(draft.price)
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
if (draft.discountedPrice.trim() && discountedPrice === null) {
throw new Error('Discounted price is invalid.')
}
if (discountedPrice !== null && discountedPrice > price) {
throw new Error('Discounted price cannot exceed price.')
}
return {
title: draft.title.trim(),
duration: draft.duration.trim() || undefined,
worktime: draft.worktime.trim() || undefined,
description: draft.description.trim() || undefined,
price,
discountedPrice,
}
}
export function SettingsPage() {
const { showToast } = useToast()
const [templates, setTemplates] = useState<InvoiceItemTemplate[]>([])
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)
async function reload(signal?: AbortSignal) {
setLoading(true)
setError('')
try {
const result = await listInvoiceItemTemplates(signal)
setTemplates(result.items)
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load invoice templates.')
} finally {
setLoading(false)
}
}
useEffect(() => {
const controller = new AbortController()
void reload(controller.signal)
return () => controller.abort()
}, [])
function openCreate() {
setEditing(null)
setDraft(EMPTY_DRAFT)
setFormError('')
setEditorOpen(true)
}
function openEdit(template: InvoiceItemTemplate) {
setEditing(template)
setDraft(draftFromTemplate(template))
setFormError('')
setEditorOpen(true)
}
async function handleSave() {
setFormError('')
let payload
try {
payload = toPayload(draft)
} catch (err) {
setFormError(err instanceof Error ? err.message : 'Invalid form.')
return
}
setSubmitting(true)
try {
if (editing) {
await updateInvoiceItemTemplate(editing.id, payload)
showToast('Invoice item updated.', 'success')
} else {
await createInvoiceItemTemplate(payload)
showToast('Invoice item created.', 'success')
}
setEditorOpen(false)
await reload()
} catch (err) {
setFormError(err instanceof ApiError ? err.message : 'Unable to save invoice item.')
} finally {
setSubmitting(false)
}
}
async function handleRemove() {
if (!removeTarget) return
try {
await deleteInvoiceItemTemplate(removeTarget.id)
showToast('Invoice item removed.', 'success')
setRemoveTarget(null)
await reload()
} catch (err) {
showToast(err instanceof ApiError ? err.message : 'Unable to remove item.', 'error')
}
}
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Settings</h2>
<p className={pageStyles.pageSubtitle}>
Platform configuration used across Meshkee super admin tools.
</p>
</div>
</div>
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitleRow}>
<FileText size={18} />
<div>
<h3 className={styles.sectionTitle}>Invoices</h3>
<p className={styles.sectionSubtitle}>
Predefined line items you can reuse when issuing invoices to businesses.
</p>
</div>
</div>
<button type="button" className={`${tableStyles.btn} ${tableStyles.btnPrimary}`} onClick={openCreate}>
<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.meta}>
{loading ? 'Loading…' : `${templates.length} item${templates.length === 1 ? '' : 's'}`}
</div>
</div>
<table className={tableStyles.table}>
<thead>
<tr>
<th className={tableStyles.th}>Title</th>
<th className={tableStyles.th}>Duration</th>
<th className={tableStyles.th}>Worktime</th>
<th className={tableStyles.th}>Price</th>
<th className={tableStyles.th}>Discounted</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{!loading && templates.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>
</div>
</td>
</tr>
) : null}
{templates.map((template) => (
<tr key={template.id}>
<td className={tableStyles.td}>
<div className={styles.itemTitle}>{template.title}</div>
{template.description ? (
<div className={tableStyles.subText}>{template.description}</div>
) : null}
</td>
<td className={tableStyles.td}>{template.duration || '—'}</td>
<td className={tableStyles.td}>{template.worktime || '—'}</td>
<td className={tableStyles.td}>{formatIrtPrice(template.price)}</td>
<td className={tableStyles.td}>
{template.discountedPrice === null
? '—'
: formatIrtPrice(template.discountedPrice)}
</td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => openEdit(template)}
title="Edit"
aria-label="Edit"
>
<Pencil size={16} />
</button>
<button
type="button"
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
onClick={() => setRemoveTarget(template)}
title="Remove"
aria-label="Remove"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<p className={styles.hint}>
Tip: open a business from <Link to="/businesses">Businesses</Link> to list invoices or issue
a new one.
</p>
<Modal
open={editorOpen}
title={editing ? 'Edit invoice item' : 'Add invoice item'}
onClose={() => !submitting && setEditorOpen(false)}
wide
>
<div className={styles.formGrid}>
<div className={`${tableStyles.field} ${styles.span2}`}>
<label htmlFor="tpl-title">Title</label>
<input
id="tpl-title"
value={draft.title}
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
placeholder="e.g. Website setup"
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="tpl-duration">Duration</label>
<input
id="tpl-duration"
value={draft.duration}
onChange={(e) => setDraft((d) => ({ ...d, duration: e.target.value }))}
placeholder="e.g. 3 months"
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="tpl-worktime">Worktime</label>
<input
id="tpl-worktime"
value={draft.worktime}
onChange={(e) => setDraft((d) => ({ ...d, worktime: e.target.value }))}
placeholder="e.g. 40 hours"
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="tpl-price">Price (IRT)</label>
<input
id="tpl-price"
inputMode="numeric"
value={draft.price}
onChange={(e) => setDraft((d) => ({ ...d, price: formatIrtInput(e.target.value) }))}
placeholder="0"
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="tpl-discount">Discounted price (IRT)</label>
<input
id="tpl-discount"
inputMode="numeric"
value={draft.discountedPrice}
onChange={(e) =>
setDraft((d) => ({ ...d, discountedPrice: formatIrtInput(e.target.value) }))
}
placeholder="Optional"
/>
</div>
<div className={`${tableStyles.field} ${styles.span2}`}>
<label htmlFor="tpl-desc">Description</label>
<textarea
id="tpl-desc"
rows={3}
value={draft.description}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
placeholder="Optional details shown on the invoice line"
/>
</div>
</div>
{formError ? <p className={styles.alertError}>{formError}</p> : null}
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => setEditorOpen(false)}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void handleSave()}
disabled={submitting}
>
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create item'}
</button>
</div>
</Modal>
<ConfirmDeleteModal
open={!!removeTarget}
title="Remove invoice item"
message={
removeTarget
? `Remove “${removeTarget.title}” from predefined invoice items?`
: ''
}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void handleRemove()}
/>
</main>
)
}