Improve product cards/pagination, Farsi locale fonts, and super-admin migrate UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-29 16:10:43 +03:30
co-authored by Cursor
parent 2e40d5eb4c
commit f5b2193ba1
108 changed files with 2421 additions and 812 deletions
Binary file not shown.
Binary file not shown.
+50 -25
View File
@@ -1,10 +1,11 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { ToastProvider } from './context/ToastContext'
import { AdminDomainGuard } from './components/AdminDomainGuard'
import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute'
import { PageLayout } from './components/PageLayout'
import { RouteErrorBoundary } from './components/RouteErrorBoundary'
import { HomePage } from './pages/HomePage'
import { BusinessesPage } from './pages/BusinessesPage'
import { BusinessInvoicesPage } from './pages/BusinessInvoicesPage'
@@ -17,41 +18,65 @@ import { PublicInvoicePage } from './pages/PublicInvoicePage'
import { ProfilePage } from './pages/ProfilePage'
import { LoginPage } from './pages/LoginPage'
function ProtectedPages() {
return (
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
)
}
function App() {
return (
<AdminDomainGuard>
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<Routes>
<Route path="invoices/:invoiceId" element={<PublicInvoicePage />} />
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<Routes>
{/* Host-agnostic: can be served from meshkee.com or other public domains */}
<Route path="invoices/:invoiceId" element={<PublicInvoicePage />} />
<Route element={<AdminDomainGuard />}>
<Route element={<GuestRoute />}>
<Route path="login" element={<LoginPage />} />
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<PageLayout />}>
<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 element={<ProtectedPages />}>
<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="businesses/:businessId/invoices/:invoiceId/edit"
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>
</Route>
</Routes>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</AdminDomainGuard>
</Route>
</Routes>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
)
}
@@ -1,10 +1,11 @@
import type { ReactNode } from 'react'
import { Outlet } from 'react-router-dom'
import { getAdminDomain, isAllowedAdminHost } from '../lib/config'
import styles from './AdminDomainGuard.module.css'
export function AdminDomainGuard({ children }: { children: ReactNode }) {
/** Protects manage-only routes. Public invoice routes stay outside this guard. */
export function AdminDomainGuard() {
if (isAllowedAdminHost()) {
return children
return <Outlet />
}
const expectedDomain = getAdminDomain()
@@ -1,13 +1,13 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(127, 29, 29, 0.2);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background: rgba(127, 29, 29, 0.28);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
z-index: 300;
padding: 20px;
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, X } from 'lucide-react'
import styles from './ConfirmDeleteModal.module.css'
@@ -41,13 +42,18 @@ export function ConfirmDeleteModal({
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onCancel])
if (!mounted) return null
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onCancel}
@@ -81,6 +87,7 @@ export function ConfirmDeleteModal({
<X size={18} />
</button>
</div>
</div>
</div>,
document.body,
)
}
@@ -1,9 +1,9 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.25);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
@@ -73,4 +73,3 @@
flex: 1;
min-height: 0;
}
+19 -6
View File
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import styles from './Modal.module.css'
@@ -18,17 +19,29 @@ export function Modal({ open, title, children, onClose, wide, xl }: ModalProps)
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [open, onClose])
if (!open) return null
const sizeClass = xl ? styles.modalXl : wide ? styles.modalWide : ''
return (
<div className={styles.overlay} role="dialog" aria-modal="true" onClick={onClose}>
<div className={`${styles.modal} ${sizeClass}`} onClick={(e) => e.stopPropagation()}>
return createPortal(
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className={`${styles.modal} ${sizeClass}`} onMouseDown={(e) => e.stopPropagation()}>
<div className={styles.header}>
<h3 className={styles.title}>{title}</h3>
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close">
@@ -37,7 +50,7 @@ export function Modal({ open, title, children, onClose, wide, xl }: ModalProps)
</div>
<div className={styles.body}>{children}</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,56 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
export class RouteErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Route render error:', error, info.componentStack)
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 24, maxWidth: 640 }}>
<h2 style={{ marginBottom: 8 }}>Something went wrong</h2>
<p style={{ color: '#b91c1c', marginBottom: 12 }}>{this.state.error.message}</p>
<pre
style={{
whiteSpace: 'pre-wrap',
fontSize: 12,
background: 'rgba(15,23,42,0.06)',
padding: 12,
borderRadius: 8,
overflow: 'auto',
}}
>
{this.state.error.stack}
</pre>
<button
type="button"
style={{ marginTop: 16 }}
onClick={() => {
this.setState({ error: null })
window.location.assign('/businesses')
}}
>
Reload businesses
</button>
</div>
)
}
return this.props.children
}
}
+2 -26
View File
@@ -1,9 +1,7 @@
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
@@ -11,30 +9,8 @@
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebMedium.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebMedium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebBold.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebBold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@@ -27,30 +27,76 @@
.statusChip {
display: inline-flex;
align-items: center;
padding: 3px 8px;
max-width: 100%;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid transparent;
transition: filter 0.2s;
}
.statusChipBtn {
cursor: pointer;
background: none;
}
.statusChipBtn:hover {
filter: brightness(0.95);
}
.statusChipBtn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.statusChipBtn:disabled {
cursor: wait;
opacity: 0.7;
}
.statusOptionList {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.statusOption {
min-height: 32px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
}
.status_draft {
background: rgba(148, 163, 184, 0.18);
color: #475569;
.statusOptionSelected {
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.status_draft,
.status_issued {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
color: #0f172a;
background: rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.18);
}
.status_approved {
color: #0e7490;
background: rgba(6, 182, 212, 0.14);
border-color: rgba(6, 182, 212, 0.32);
}
.status_paid {
background: rgba(34, 197, 94, 0.12);
color: #15803d;
background: rgba(34, 197, 94, 0.14);
border-color: rgba(34, 197, 94, 0.32);
}
.status_cancelled {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.28);
}
.itemTitle {
@@ -314,6 +360,10 @@
margin-bottom: 2px;
}
.detailMeta .statusChipBtn {
margin-top: 4px;
}
.detailNotes {
margin: 0 0 14px;
font-size: 13px;
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft, Copy, Eye, FilePlus2, Trash2 } from 'lucide-react'
import { ArrowLeft, Copy, Eye, FilePlus2, Pencil, Trash2 } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal'
import { isEmptyRichText } from '../components/RichTextEditor'
@@ -15,11 +15,12 @@ import {
} from '../services/invoiceService'
import type { Invoice, InvoiceStatus } from '../types/invoice'
import { formatIrtPrice } from '../utils/irtPrice'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './BusinessInvoicesPage.module.css'
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'paid', 'cancelled']
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'approved', 'paid', 'cancelled']
function formatDate(value: string) {
const d = new Date(value)
@@ -35,6 +36,21 @@ function statusLabel(status: InvoiceStatus) {
return status.charAt(0).toUpperCase() + status.slice(1)
}
function statusClass(status: InvoiceStatus) {
return styles[`status_${status}` as keyof typeof styles] ?? styles.status_draft
}
function canEditInvoice(status: InvoiceStatus) {
return status !== 'approved'
}
function allowedStatusOptions(current: InvoiceStatus): InvoiceStatus[] {
if (current !== 'approved') return STATUS_OPTIONS
return STATUS_OPTIONS.filter(
(option) => option === 'approved' || option === 'paid' || option === 'cancelled',
)
}
export function BusinessInvoicesPage() {
const { businessId = '' } = useParams()
const navigate = useNavigate()
@@ -49,7 +65,8 @@ export function BusinessInvoicesPage() {
const [total, setTotal] = useState(0)
const [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null)
const [statusUpdating, setStatusUpdating] = useState(false)
const [statusTarget, setStatusTarget] = useState<Invoice | null>(null)
const [statusUpdating, setStatusUpdating] = useState<InvoiceStatus | null>(null)
const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null)
async function reload(signal?: AbortSignal) {
@@ -80,20 +97,30 @@ export function BusinessInvoicesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [businessId, page])
async function handleStatusChange(next: InvoiceStatus) {
if (!detailInvoice) return
setStatusUpdating(true)
function openStatusModal(invoice: Invoice) {
setStatusTarget(invoice)
}
async function handleStatusSelect(next: InvoiceStatus) {
if (!statusTarget || statusUpdating) return
if (next === statusTarget.status) {
setStatusTarget(null)
return
}
setStatusUpdating(next)
try {
const updated = await updateBusinessInvoiceStatus(businessId, detailInvoice.id, {
const updated = await updateBusinessInvoiceStatus(businessId, statusTarget.id, {
status: next,
})
setDetailInvoice(updated)
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
setDetailInvoice((current) => (current?.id === updated.id ? updated : current))
showToast('Invoice status updated.', 'success')
setStatusTarget(null)
} catch (err) {
showToast(err instanceof ApiError ? err.message : 'Unable to update status.', 'error')
} finally {
setStatusUpdating(false)
setStatusUpdating(null)
}
}
@@ -191,9 +218,15 @@ export function BusinessInvoicesPage() {
</td>
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td>
<td className={tableStyles.td}>
<span className={`${styles.statusChip} ${styles[`status_${invoice.status}`]}`}>
<button
type="button"
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(invoice.status)}`}
onClick={() => openStatusModal(invoice)}
title="Change status"
aria-label={`Change status (${statusLabel(invoice.status)})`}
>
{statusLabel(invoice.status)}
</span>
</button>
</td>
<td className={tableStyles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
<td className={tableStyles.td}>
@@ -229,6 +262,30 @@ export function BusinessInvoicesPage() {
>
<Eye size={16} />
</button>
<span
title={
canEditInvoice(invoice.status)
? undefined
: 'Approved invoices cannot be edited'
}
>
<button
type="button"
className={tableStyles.controlBtn}
disabled={!canEditInvoice(invoice.status)}
onClick={() =>
navigate(`/businesses/${businessId}/invoices/${invoice.id}/edit`)
}
title={canEditInvoice(invoice.status) ? 'Edit' : undefined}
aria-label={
canEditInvoice(invoice.status)
? 'Edit invoice'
: 'Edit invoice (disabled — approved)'
}
>
<Pencil size={16} />
</button>
</span>
<button
type="button"
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
@@ -249,26 +306,15 @@ export function BusinessInvoicesPage() {
{totalPages > 1 ? (
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<button
type="button"
className={tableStyles.pageBtn}
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
Page {page} / {totalPages} · 20 per page · {total} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
) : null}
@@ -287,17 +333,15 @@ export function BusinessInvoicesPage() {
<div className={styles.detailMeta}>
<div>
<span className={styles.detailLabel}>Status</span>
<select
value={detailInvoice.status}
disabled={statusUpdating}
onChange={(e) => void handleStatusChange(e.target.value as InvoiceStatus)}
<button
type="button"
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(detailInvoice.status)}`}
onClick={() => openStatusModal(detailInvoice)}
title="Change status"
aria-label={`Change status (${statusLabel(detailInvoice.status)})`}
>
{STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{statusLabel(option)}
</option>
))}
</select>
{statusLabel(detailInvoice.status)}
</button>
</div>
<div>
<span className={styles.detailLabel}>Total</span>
@@ -396,6 +440,40 @@ export function BusinessInvoicesPage() {
) : null}
</Modal>
<Modal
open={!!statusTarget}
title="Change invoice status"
onClose={() => {
if (!statusUpdating) setStatusTarget(null)
}}
>
{statusTarget ? (
<div
className={styles.statusOptionList}
role="listbox"
aria-label="Invoice status"
>
{allowedStatusOptions(statusTarget.status).map((option) => {
const selected = statusTarget.status === option
const saving = statusUpdating === option
return (
<button
key={option}
type="button"
role="option"
aria-selected={selected}
className={`${styles.statusChip} ${styles.statusChipBtn} ${styles.statusOption} ${statusClass(option)} ${selected ? styles.statusOptionSelected : ''}`}
disabled={Boolean(statusUpdating)}
onClick={() => void handleStatusSelect(option)}
>
{saving ? 'Saving…' : statusLabel(option)}
</button>
)
})}
</div>
) : null}
</Modal>
<ConfirmDeleteModal
open={!!removeTarget}
title="Remove invoice"
@@ -60,6 +60,140 @@
color: var(--text-muted);
}
.entityGroupLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.entityChecks {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 8px;
}
.entityGroup {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 12px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
}
.entityGroup:last-child {
border-bottom: none;
padding-bottom: 4px;
}
.entityGroup:first-child {
padding-top: 4px;
}
.entityGroupTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
min-width: 88px;
flex-shrink: 0;
}
.entityNestedChecks {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px 18px;
}
.entityCheck {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
width: fit-content;
max-width: 100%;
}
.field .entityCheck input[type='checkbox'],
.entityCheck input[type='checkbox'],
.field .entityCheck input[type='radio'],
.entityCheck input[type='radio'] {
appearance: auto;
-webkit-appearance: auto;
width: 16px;
height: 16px;
min-width: 16px;
min-height: 16px;
margin: 0;
padding: 0;
flex-shrink: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
cursor: pointer;
}
.entityCheck span {
line-height: 1.3;
}
.alertError {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
color: #b91c1c;
}
.alertInfo {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.25);
color: #1d4ed8;
}
.alertSuccess {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.28);
color: #166534;
}
.migrateResultTitle {
margin: 0;
font-weight: 600;
}
.migrateResultList {
margin: 8px 0 0;
padding-inline-start: 18px;
display: flex;
flex-direction: column;
gap: 4px;
}
.migrateResultList strong {
font-weight: 600;
}
.field input,
.field select,
.field textarea {
@@ -182,6 +316,17 @@
transform: translateY(-1px);
}
.btnDanger {
color: white;
background: linear-gradient(135deg, #f87171 0%, #ef4444 55%, #dc2626 100%);
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.28);
border: none;
}
.btnDanger:hover:not(:disabled) {
transform: translateY(-1px);
}
.tablePanel {
margin-top: 12px;
padding: 0;
+525 -47
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
Database,
Eraser,
FileText,
Globe,
Lock,
@@ -16,7 +18,16 @@ import {
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
import type { BusinessesListResponse, BusinessListItem } from '../types/business'
import type {
BusinessesListResponse,
BusinessListItem,
MigrateEntityResult,
MigrateFromOldEntity,
MigrateFromOldResponse,
PurgeBusinessDataEntity,
PurgeBusinessDataResponse,
PurgeEntityResult,
} from '../types/business'
import type { BusinessCategory } from '../types/category'
import type { ListBusinessesParams } from '../services/businessService'
import { ApiError, isAbortError } from '../lib/api'
@@ -24,6 +35,8 @@ import {
addBusinessDomain,
createBusiness,
listBusinesses,
migrateBusinessFromOld,
purgeBusinessData,
removeBusiness,
setBusinessActive,
updateBusiness,
@@ -45,6 +58,7 @@ import {
updateBusinessPrimaryColor,
} from '../services/businessSettingsService'
import { flattenBusinessCategories } from '../utils/categories'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css'
@@ -56,12 +70,99 @@ function formatDate(value: string) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
product_categories: 'Product categories',
product: 'Products',
customer_categories: 'Customer categories',
customer: 'Customers',
blog_categories: 'Blog categories',
blog: 'Blogs',
portfolio_categories: 'Portfolio categories',
portfolio: 'Portfolios',
}
const DATA_ENTITY_GROUPS = [
{
title: 'Products',
items: [
['product_categories', 'Categories'],
['product', 'Products'],
],
},
{
title: 'Customers',
items: [
['customer_categories', 'Categories'],
['customer', 'Customers'],
],
},
{
title: 'Blogs',
items: [
['blog_categories', 'Categories'],
['blog', 'Blogs'],
],
},
{
title: 'Portfolios',
items: [
['portfolio_categories', 'Categories'],
['portfolio', 'Portfolios'],
],
},
] as const
function formatMigrateEntityResult(result: MigrateEntityResult): string {
if ('created' in result) {
const base = `${result.created} created, ${result.skipped} skipped (${result.total} total)`
const extras = [
result.imagesCopied != null ? `${result.imagesCopied} images copied` : null,
result.imagesResized != null && result.imagesResized > 0
? `${result.imagesResized} resized (≤1280px)`
: null,
result.titlesUpdated != null && result.titlesUpdated > 0
? `${result.titlesUpdated} titles updated`
: null,
result.imagesFailed != null && result.imagesFailed > 0
? `${result.imagesFailed} images failed`
: null,
result.skippedInvalidCell != null && result.skippedInvalidCell > 0
? `${result.skippedInvalidCell} invalid/missing cell`
: null,
result.skippedAlreadyLinked != null && result.skippedAlreadyLinked > 0
? `${result.skippedAlreadyLinked} already linked`
: null,
result.skippedCreateFailed != null && result.skippedCreateFailed > 0
? `${result.skippedCreateFailed} create failed`
: null,
].filter(Boolean)
return extras.length ? `${base}; ${extras.join(', ')}` : base
}
return 'Not implemented yet'
}
function formatPurgeEntityResult(result: PurgeEntityResult): string {
if ('deleted' in result) {
const extras = [
result.imagesDeleted != null ? `${result.imagesDeleted} images deleted` : null,
].filter(Boolean)
const base = `${result.deleted} deleted`
return extras.length ? `${base}; ${extras.join(', ')}` : base
}
return 'Not implemented yet'
}
function emptyMigrateEntities(): Record<MigrateFromOldEntity, boolean> {
return {
product_categories: false,
product: false,
customer_categories: false,
customer: false,
blog_categories: false,
blog: false,
portfolio_categories: false,
portfolio: false,
}
}
export function BusinessesPage() {
@@ -91,12 +192,14 @@ export function BusinessesPage() {
)
const [editLoadingSettings, setEditLoadingSettings] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [domainOpen, setDomainOpen] = useState(false)
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
const [domainId, setDomainId] = useState<number | null>(null)
const [domainHost, setDomainHost] = useState('')
const [domainSubmitting, setDomainSubmitting] = useState(false)
const [domainError, setDomainError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
@@ -108,11 +211,31 @@ export function BusinessesPage() {
const [createOwnerCell, setCreateOwnerCell] = useState('')
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null)
const [migrateOpen, setMigrateOpen] = useState(false)
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
const [migrateOldId, setMigrateOldId] = useState('')
const [migrateEntities, setMigrateEntities] = useState<Record<MigrateFromOldEntity, boolean>>(
() => emptyMigrateEntities(),
)
const [migrateSubmitting, setMigrateSubmitting] = useState(false)
const [migrateError, setMigrateError] = useState('')
const [migrateResult, setMigrateResult] = useState<MigrateFromOldResponse | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false)
const [purgeBusiness, setPurgeBusiness] = useState<BusinessListItem | null>(null)
const [purgeEntities, setPurgeEntities] = useState<Record<PurgeBusinessDataEntity, boolean>>(
() => emptyMigrateEntities(),
)
const [purgeSubmitting, setPurgeSubmitting] = useState(false)
const [purgeError, setPurgeError] = useState('')
const [purgeResult, setPurgeResult] = useState<PurgeBusinessDataResponse | null>(null)
async function fetchList() {
setLoading(true)
setError('')
@@ -171,8 +294,6 @@ export function BusinessesPage() {
return Math.max(1, Math.ceil(total / pageSize))
}, [data?.total, pageSize])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * pageSize + 1
@@ -206,6 +327,7 @@ export function BusinessesPage() {
setEditBusiness(b)
setEditName(b.name)
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
setEditError('')
setEditOpen(true)
setEditLoadingSettings(true)
@@ -216,7 +338,7 @@ export function BusinessesPage() {
})
.catch((err) => {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load business theme.')
setEditError(err instanceof ApiError ? err.message : 'Unable to load business theme.')
})
.finally(() => {
if (!controller.signal.aborted) setEditLoadingSettings(false)
@@ -286,7 +408,7 @@ export function BusinessesPage() {
async function submitEdit() {
if (!editBusiness) return
setEditSubmitting(true)
setError('')
setEditError('')
try {
await updateBusiness(editBusiness.id, { name: editName })
await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor)
@@ -306,7 +428,7 @@ export function BusinessesPage() {
showToast('Business updated.', 'success')
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update business.')
setEditError(err instanceof ApiError ? err.message : 'Unable to update business.')
} finally {
setEditSubmitting(false)
}
@@ -316,16 +438,142 @@ export function BusinessesPage() {
setDomainBusiness(b)
setDomainId(b.domainId)
setDomainHost(b.domain ?? '')
setDomainError('')
setDomainOpen(true)
}
function openMigrate(b: BusinessListItem) {
setMigrateBusiness(b)
setMigrateOldId(b.oldBusinessId != null ? String(b.oldBusinessId) : '')
setMigrateEntities(emptyMigrateEntities())
setMigrateError('')
setMigrateResult(null)
setMigrateOpen(true)
}
const migrateSubmittingRef = useRef(false)
migrateSubmittingRef.current = migrateSubmitting
const closeMigrate = useCallback(() => {
if (migrateSubmittingRef.current) return
setMigrateOpen(false)
setMigrateBusiness(null)
setMigrateError('')
setMigrateResult(null)
}, [])
function selectMigrateEntity(entity: MigrateFromOldEntity) {
setMigrateResult(null)
setMigrateError('')
setMigrateEntities({ ...emptyMigrateEntities(), [entity]: true })
}
async function submitMigrate() {
if (!migrateBusiness) return
const oldBusinessId = Number(String(migrateOldId).trim())
if (!Number.isInteger(oldBusinessId) || oldBusinessId <= 0) {
setMigrateResult(null)
setMigrateError('Enter a valid old business id (positive integer).')
return
}
const entities = (Object.keys(migrateEntities) as MigrateFromOldEntity[]).filter(
(key) => migrateEntities[key],
)
if (entities.length === 0) {
setMigrateResult(null)
setMigrateError('Select a data type to migrate.')
return
}
setMigrateSubmitting(true)
setMigrateError('')
setMigrateResult(null)
try {
const result = await migrateBusinessFromOld(migrateBusiness.id, {
oldBusinessId,
entities,
})
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === migrateBusiness.id
? { ...item, oldBusinessId: String(oldBusinessId) }
: item,
),
}
})
setMigrateResult(result)
} catch (err) {
setMigrateError(
err instanceof ApiError ? err.message : 'Unable to migrate data from old CMS.',
)
} finally {
setMigrateSubmitting(false)
}
}
function openPurge(b: BusinessListItem) {
setPurgeBusiness(b)
setPurgeEntities(emptyMigrateEntities())
setPurgeError('')
setPurgeResult(null)
setPurgeOpen(true)
}
const purgeSubmittingRef = useRef(false)
purgeSubmittingRef.current = purgeSubmitting
const closePurge = useCallback(() => {
if (purgeSubmittingRef.current) return
setPurgeOpen(false)
setPurgeBusiness(null)
setPurgeError('')
setPurgeResult(null)
}, [])
function selectPurgeEntity(entity: PurgeBusinessDataEntity) {
setPurgeResult(null)
setPurgeError('')
setPurgeEntities({ ...emptyMigrateEntities(), [entity]: true })
}
async function submitPurge() {
if (!purgeBusiness) return
const entities = (Object.keys(purgeEntities) as PurgeBusinessDataEntity[]).filter(
(key) => purgeEntities[key],
)
if (entities.length === 0) {
setPurgeResult(null)
setPurgeError('Select a data type to delete.')
return
}
setPurgeSubmitting(true)
setPurgeError('')
setPurgeResult(null)
try {
const result = await purgeBusinessData(purgeBusiness.id, { entities })
setPurgeResult(result)
showToast(result.message, 'success')
} catch (err) {
setPurgeError(
err instanceof ApiError ? err.message : 'Unable to delete selected business data.',
)
} finally {
setPurgeSubmitting(false)
}
}
async function submitDomain() {
if (!domainBusiness) return
const host = domainHost.trim()
if (!host) return
setDomainSubmitting(true)
setError('')
setDomainError('')
try {
if (domainId) {
await updateBusinessDomain(domainBusiness.id, domainId, { host })
@@ -366,7 +614,7 @@ export function BusinessesPage() {
setDomainBusiness(null)
setDomainId(null)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to save domain.')
setDomainError(err instanceof ApiError ? err.message : 'Unable to save domain.')
} finally {
setDomainSubmitting(false)
}
@@ -381,6 +629,7 @@ export function BusinessesPage() {
setCreateOwnerLastName('')
setCreateOwnerCell('')
setCreateOwnerPassword('')
setCreateError('')
}
function openCreate() {
@@ -393,7 +642,7 @@ export function BusinessesPage() {
if (!ownerCellNumber) return
setCreateSubmitting(true)
setError('')
setCreateError('')
try {
await createBusiness({
name: createName.trim(),
@@ -410,7 +659,7 @@ export function BusinessesPage() {
showToast(`"${createName.trim()}" has been created.`, 'success')
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to create business.')
setCreateError(err instanceof ApiError ? err.message : 'Unable to create business.')
} finally {
setCreateSubmitting(false)
}
@@ -468,6 +717,13 @@ export function BusinessesPage() {
toE164CellNumber(createOwnerCell.trim()).length > 0 &&
createOwnerPassword.length >= 8
const canSubmitMigrate =
Number.isInteger(Number(String(migrateOldId).trim())) &&
Number(String(migrateOldId).trim()) > 0 &&
Object.values(migrateEntities).some(Boolean)
const canSubmitPurge = Object.values(purgeEntities).some(Boolean)
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
@@ -681,6 +937,32 @@ export function BusinessesPage() {
>
<Users size={16} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openMigrate(b)
}}
title="Migrate from old CMS"
aria-label="Migrate from old CMS"
>
<Database size={16} />
</button>
<button
type="button"
className={`${styles.controlBtn} ${styles.controlBtnDanger}`}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openPurge(b)
}}
title="Delete data"
aria-label="Delete business data"
>
<Eraser size={16} />
</button>
<button
type="button"
className={styles.controlBtn}
@@ -718,37 +1000,15 @@ export function BusinessesPage() {
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {pageSize} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -769,8 +1029,14 @@ export function BusinessesPage() {
onClose={() => {
setEditOpen(false)
setEditBusiness(null)
setEditError('')
}}
>
{editError ? (
<p className={styles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="edit-name">Business name</label>
<input
@@ -820,8 +1086,14 @@ export function BusinessesPage() {
setDomainOpen(false)
setDomainBusiness(null)
setDomainId(null)
setDomainError('')
}}
>
{domainError ? (
<p className={styles.alertError} role="alert">
{domainError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="domain-host">Domain</label>
<input
@@ -853,6 +1125,207 @@ export function BusinessesPage() {
</div>
</Modal>
<Modal
open={migrateOpen}
title={
migrateBusiness
? `Migrate from old CMS — ${migrateBusiness.name}`
: 'Migrate from old CMS'
}
onClose={closeMigrate}
>
{migrateSubmitting ? (
<p className={styles.alertInfo} role="status" aria-live="polite">
Migrating selected data This can take a moment.
</p>
) : null}
{migrateError ? (
<p className={styles.alertError} role="alert">
{migrateError}
</p>
) : null}
{migrateResult ? (
<div className={styles.alertSuccess} role="status" aria-live="polite">
<p className={styles.migrateResultTitle}>{migrateResult.message}</p>
{migrateResult.results ? (
<ul className={styles.migrateResultList}>
{(Object.keys(migrateResult.results) as MigrateFromOldEntity[]).map((key) => {
const item = migrateResult.results?.[key]
if (!item) return null
return (
<li key={key}>
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
{formatMigrateEntityResult(item)}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
<p className={styles.helperText}>
Link this business to a legacy WillaEngine business id, then choose one
data type to migrate. Selecting blogs also migrates news under a top-level
News category. Selecting customers also migrates client categories when
needed.
</p>
<div className={styles.field}>
<label htmlFor="migrate-old-id">Old business id</label>
<input
id="migrate-old-id"
type="number"
min={1}
step={1}
value={String(migrateOldId)}
onChange={(e) => {
setMigrateOldId(e.target.value)
setMigrateResult(null)
setMigrateError('')
}}
placeholder="e.g. 2410"
disabled={migrateSubmitting}
/>
</div>
<div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to migrate (one)</span>
<div className={styles.entityChecks}>
{DATA_ENTITY_GROUPS.map((group) => (
<div key={group.title} className={styles.entityGroup}>
<span className={styles.entityGroupTitle}>{group.title}</span>
<div className={styles.entityNestedChecks}>
{group.items.map(([value, label]) => (
<label key={value} className={styles.entityCheck}>
<input
type="radio"
name="migrate-entity"
checked={migrateEntities[value]}
onChange={() => selectMigrateEntity(value)}
disabled={migrateSubmitting}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closeMigrate}
disabled={migrateSubmitting}
>
{migrateResult ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitMigrate()}
disabled={migrateSubmitting || !canSubmitMigrate}
>
{migrateSubmitting
? 'Migrating…'
: migrateResult
? 'Migrate again'
: 'Save & migrate'}
</button>
</div>
</Modal>
<Modal
open={purgeOpen}
title={
purgeBusiness ? `Delete data — ${purgeBusiness.name}` : 'Delete data'
}
onClose={closePurge}
>
{purgeSubmitting ? (
<p className={styles.alertInfo} role="status" aria-live="polite">
Deleting selected data This can take a moment.
</p>
) : null}
{purgeError ? (
<p className={styles.alertError} role="alert">
{purgeError}
</p>
) : null}
{purgeResult ? (
<div className={styles.alertSuccess} role="status" aria-live="polite">
<p className={styles.migrateResultTitle}>{purgeResult.message}</p>
{purgeResult.results ? (
<ul className={styles.migrateResultList}>
{(Object.keys(purgeResult.results) as PurgeBusinessDataEntity[]).map((key) => {
const item = purgeResult.results?.[key]
if (!item) return null
return (
<li key={key}>
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
{formatPurgeEntityResult(item)}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
<p className={styles.helperText}>
Permanently remove one selected data type from this business so you can
migrate again. Deleting blogs or portfolios also removes their images
from storage. Deleting customers removes business links (not global user
accounts).
</p>
<div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to delete (one)</span>
<div className={styles.entityChecks}>
{DATA_ENTITY_GROUPS.map((group) => (
<div key={group.title} className={styles.entityGroup}>
<span className={styles.entityGroupTitle}>{group.title}</span>
<div className={styles.entityNestedChecks}>
{group.items.map(([value, label]) => (
<label key={value} className={styles.entityCheck}>
<input
type="radio"
name="purge-entity"
checked={purgeEntities[value]}
onChange={() => selectPurgeEntity(value)}
disabled={purgeSubmitting}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closePurge}
disabled={purgeSubmitting}
>
{purgeResult ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnDanger}`}
onClick={() => void submitPurge()}
disabled={purgeSubmitting || !canSubmitPurge}
>
{purgeSubmitting
? 'Deleting…'
: purgeResult
? 'Delete again'
: 'Delete selected data'}
</button>
</div>
</Modal>
<Modal
open={createOpen}
title="Add business"
@@ -862,6 +1335,11 @@ export function BusinessesPage() {
resetCreateForm()
}}
>
{createError ? (
<p className={styles.alertError} role="alert">
{createError}
</p>
) : null}
<div className={styles.formGrid}>
<div className={styles.field}>
<label htmlFor="create-name-en">Name (EN)</label>
+115 -36
View File
@@ -8,14 +8,17 @@ import { ApiError, isAbortError } from '../lib/api'
import { getBusiness, type BusinessDetail } from '../services/businessService'
import {
createBusinessInvoice,
getBusinessInvoice,
listInvoiceItemTemplates,
listInvoiceTemplates,
updateBusinessInvoice,
} from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildLineItemsPayload,
draftsFromInvoice,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
@@ -36,7 +39,8 @@ function effectivePrice(price: number, discountedPrice: number | null | undefine
}
export function IssueInvoicePage() {
const { businessId = '' } = useParams()
const { businessId = '', invoiceId = '' } = useParams()
const isEdit = Boolean(invoiceId)
const navigate = useNavigate()
const { showToast } = useToast()
@@ -45,6 +49,7 @@ export function IssueInvoicePage() {
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [locked, setLocked] = useState(false)
const [sourceTemplateId, setSourceTemplateId] = useState('')
const [invoiceTemplateId, setInvoiceTemplateId] = useState<string | undefined>()
@@ -77,6 +82,7 @@ export function IssueInvoicePage() {
async function load() {
setLoading(true)
setError('')
setLocked(false)
try {
const [biz, items, templates] = await Promise.all([
getBusiness(businessId, controller.signal),
@@ -86,6 +92,24 @@ export function IssueInvoicePage() {
setBusiness(biz)
setItemTemplates(items.items.filter((t) => t.isActive))
setInvoiceTemplates(templates.items.filter((t) => t.isActive))
if (invoiceId) {
const invoice = await getBusinessInvoice(businessId, invoiceId, controller.signal)
if (invoice.status === 'approved') {
setLocked(true)
setError('This invoice is approved and can no longer be edited.')
}
const drafts = draftsFromInvoice(invoice)
setSourceTemplateId('')
setInvoiceTemplateId(invoice.invoiceTemplateId ?? undefined)
setInvoiceName(drafts.name)
setTopText(drafts.topText)
setNotes(drafts.notes)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
}
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load invoice form.')
@@ -96,7 +120,7 @@ export function IssueInvoicePage() {
void load()
return () => controller.abort()
}, [businessId])
}, [businessId, invoiceId])
function resetBlankDraft() {
setSourceTemplateId('')
@@ -112,6 +136,7 @@ export function IssueInvoicePage() {
}
function applyInvoiceTemplate(templateId: string) {
if (locked) return
setSourceTemplateId(templateId)
if (!templateId) {
resetBlankDraft()
@@ -130,7 +155,8 @@ export function IssueInvoicePage() {
setFormError('')
}
async function handleCreate() {
async function handleSubmit() {
if (locked) return
setFormError('')
let items
try {
@@ -142,19 +168,38 @@ export function IssueInvoicePage() {
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')
if (isEdit) {
await updateBusinessInvoice(businessId, invoiceId, {
items,
name: invoiceName.trim() || null,
topText: isEmptyRichText(topText) ? null : topText,
notes: notes.trim() || null,
invoiceTemplateId: invoiceTemplateId ?? null,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
})
showToast('Invoice updated.', 'success')
} else {
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.')
setFormError(
err instanceof ApiError
? err.message
: isEdit
? 'Unable to update invoice.'
: 'Unable to create invoice.',
)
} finally {
setSubmitting(false)
}
@@ -168,9 +213,13 @@ export function IssueInvoicePage() {
<ArrowLeft size={16} />
Back to invoices
</Link>
<h2 className={pageStyles.pageTitle}>Issue invoice · {businessName}</h2>
<h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit invoice' : 'Issue invoice'} · {businessName}
</h2>
<p className={pageStyles.pageSubtitle}>
Select an invoice template and edit it for this business, or create a blank invoice.
{isEdit
? 'Update invoice content for this business. Approved invoices cannot be changed.'
: 'Select an invoice template and edit it for this business, or create a blank invoice.'}
</p>
</div>
</div>
@@ -178,24 +227,36 @@ export function IssueInvoicePage() {
{error ? <p className={styles.alertError}>{error}</p> : null}
{loading ? <p className={tableStyles.meta}>Loading</p> : null}
{!loading ? (
{!loading && !locked ? (
<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>
{!isEdit ? (
<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>
) : (
<div className={tableStyles.field}>
<label htmlFor="invoice-name">Name (optional)</label>
<input
@@ -205,9 +266,9 @@ export function IssueInvoicePage() {
placeholder="e.g. Website redesign package"
/>
</div>
</div>
)}
{invoiceTemplates.length === 0 ? (
{!isEdit && 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
@@ -265,14 +326,32 @@ export function IssueInvoicePage() {
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void handleCreate()}
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? 'Issuing…' : 'Issue invoice'}
{submitting
? isEdit
? 'Saving…'
: 'Issuing…'
: isEdit
? 'Save changes'
: 'Issue invoice'}
</button>
</div>
</section>
) : null}
{!loading && locked ? (
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => navigate(listPath)}
>
Back to invoices
</button>
</div>
) : null}
</main>
)
}
@@ -27,6 +27,44 @@
min-width: 180px;
}
.toolbarActions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.toolbarError {
flex-basis: 100%;
margin: 0;
font-size: 12px;
color: #b91c1c;
}
.approveBtn {
display: inline-flex;
align-items: center;
gap: 8px;
height: var(--field-height, 38px);
padding: 0 14px;
border-radius: var(--radius-sm, 12px);
border: 1px solid rgba(4, 120, 87, 0.35);
background: rgba(16, 185, 129, 0.12);
color: #047857;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.approveBtn:hover:not(:disabled) {
background: rgba(16, 185, 129, 0.2);
}
.approveBtn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.printBtn {
display: inline-flex;
align-items: center;
@@ -66,6 +104,26 @@
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
}
.headerTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.approvedBadge {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 4px 10px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.14);
color: #047857;
font-size: 12px;
font-weight: 650;
}
.title {
margin: 0 0 4px;
font-size: 22px;
+59 -51
View File
@@ -23,6 +23,7 @@ import {
updateUserRole,
} from '../services/userService'
import { useToast } from '../context/ToastContext'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './UsersPage.module.css'
@@ -35,14 +36,6 @@ function formatDate(value: string) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function displayName(user: UserListItem) {
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return name || '—'
@@ -80,17 +73,20 @@ export function UsersPage() {
const [editEmail, setEditEmail] = useState('')
const [editCell, setEditCell] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [resetOpen, setResetOpen] = useState(false)
const [resetUser, setResetUser] = useState<UserListItem | null>(null)
const [resetPassword, setResetPassword] = useState('')
const [resetConfirm, setResetConfirm] = useState('')
const [resetSubmitting, setResetSubmitting] = useState(false)
const [resetError, setResetError] = useState('')
const [messageOpen, setMessageOpen] = useState(false)
const [messageUser, setMessageUser] = useState<UserListItem | null>(null)
const [messageText, setMessageText] = useState('')
const [messageSubmitting, setMessageSubmitting] = useState(false)
const [messageError, setMessageError] = useState('')
const [removeTarget, setRemoveTarget] = useState<UserListItem | null>(null)
@@ -99,6 +95,7 @@ export function UsersPage() {
const [selectedRoleSlug, setSelectedRoleSlug] = useState('')
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('')
const [roleSubmitting, setRoleSubmitting] = useState(false)
const [roleError, setRoleError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [createFirstName, setCreateFirstName] = useState('')
@@ -107,6 +104,7 @@ export function UsersPage() {
const [createPassword, setCreatePassword] = useState('')
const [createEmail, setCreateEmail] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [listVersion, setListVersion] = useState(0)
const businessFilter = useMemo(() => {
@@ -182,8 +180,6 @@ export function UsersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -222,6 +218,7 @@ export function UsersPage() {
setCreateCell('')
setCreatePassword('')
setCreateEmail('')
setCreateError('')
}
function openCreate() {
@@ -235,7 +232,7 @@ export function UsersPage() {
if (!cellNumber) return
setCreateSubmitting(true)
setError('')
setCreateError('')
try {
await createUser({
businessId: businessFilter.businessId,
@@ -251,7 +248,7 @@ export function UsersPage() {
setPage(1)
setListVersion((v) => v + 1)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to add user.')
setCreateError(err instanceof ApiError ? err.message : 'Unable to add user.')
} finally {
setCreateSubmitting(false)
}
@@ -266,6 +263,7 @@ export function UsersPage() {
setRoleUser(user)
setSelectedRoleSlug(user.roleSlug ?? '')
setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '')
setRoleError('')
setRoleOpen(true)
}
@@ -280,7 +278,7 @@ export function UsersPage() {
async function submitRoleChange() {
if (!roleUser || !selectedRoleSlug || !canSaveRole) return
setRoleSubmitting(true)
setError('')
setRoleError('')
try {
await updateUserRole(roleUser.id, selectedRoleSlug)
@@ -340,7 +338,7 @@ export function UsersPage() {
setRoleUser(null)
setSelectedTeamRoleSlug('')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update role.')
setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.')
} finally {
setRoleSubmitting(false)
}
@@ -352,13 +350,14 @@ export function UsersPage() {
setEditLastName(user.lastName ?? '')
setEditEmail('')
setEditCell(user.cellNumber)
setEditError('')
setEditOpen(true)
}
async function submitEdit() {
if (!editUser) return
setEditSubmitting(true)
setError('')
setEditError('')
try {
await updateUser(editUser.id, {
firstName: editFirstName.trim(),
@@ -386,7 +385,7 @@ export function UsersPage() {
}
})
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update user.')
setEditError(err instanceof ApiError ? err.message : 'Unable to update user.')
} finally {
setEditSubmitting(false)
}
@@ -396,24 +395,25 @@ export function UsersPage() {
setResetUser(user)
setResetPassword('')
setResetConfirm('')
setResetError('')
setResetOpen(true)
}
async function submitResetPassword() {
if (!resetUser) return
if (resetPassword !== resetConfirm) {
setError('Passwords do not match.')
setResetError('Passwords do not match.')
return
}
setResetSubmitting(true)
setError('')
setResetError('')
try {
await adminResetUserPassword(resetUser.id, resetPassword)
setResetOpen(false)
setResetUser(null)
showToast(`Password reset for "${displayName(resetUser)}".`, 'success')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to reset password.')
setResetError(err instanceof ApiError ? err.message : 'Unable to reset password.')
} finally {
setResetSubmitting(false)
}
@@ -422,20 +422,21 @@ export function UsersPage() {
function openSendMessage(user: UserListItem) {
setMessageUser(user)
setMessageText('')
setMessageError('')
setMessageOpen(true)
}
async function submitSendMessage() {
if (!messageUser) return
setMessageSubmitting(true)
setError('')
setMessageError('')
try {
const result = await sendUserMessage(messageUser.id, messageText.trim())
setMessageOpen(false)
setMessageUser(null)
showToast(result.message, result.enabled ? 'success' : 'info')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to send message.')
setMessageError(err instanceof ApiError ? err.message : 'Unable to send message.')
} finally {
setMessageSubmitting(false)
}
@@ -693,37 +694,15 @@ export function UsersPage() {
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -734,8 +713,14 @@ export function UsersPage() {
onClose={() => {
setEditOpen(false)
setEditUser(null)
setEditError('')
}}
>
{editError ? (
<p className={tableStyles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={tableStyles.formGrid}>
<div className={tableStyles.field}>
<label htmlFor="edit-first-name">First name</label>
@@ -808,8 +793,14 @@ export function UsersPage() {
onClose={() => {
setResetOpen(false)
setResetUser(null)
setResetError('')
}}
>
{resetError ? (
<p className={tableStyles.alertError} role="alert">
{resetError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Set a new password for {resetUser ? displayName(resetUser) : 'user'}.
</p>
@@ -863,8 +854,14 @@ export function UsersPage() {
onClose={() => {
setMessageOpen(false)
setMessageUser(null)
setMessageError('')
}}
>
{messageError ? (
<p className={tableStyles.alertError} role="alert">
{messageError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Send an SMS to {messageUser ? formatCellForDisplay(messageUser.cellNumber) : 'user'}.
</p>
@@ -906,8 +903,14 @@ export function UsersPage() {
setRoleOpen(false)
setRoleUser(null)
setSelectedTeamRoleSlug('')
setRoleError('')
}}
>
{roleError ? (
<p className={tableStyles.alertError} role="alert">
{roleError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Select a role for {roleUser ? displayName(roleUser) : 'user'}.
{businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''}
@@ -1021,6 +1024,11 @@ export function UsersPage() {
resetCreateForm()
}}
>
{createError ? (
<p className={tableStyles.alertError} role="alert">
{createError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Creates a new account or links an existing user as a customer of this business.
Password is required only for new accounts.
+19 -42
View File
@@ -26,20 +26,13 @@ import {
updateDomain,
} from '../services/domainService'
import { useToast } from '../context/ToastContext'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './WebsitesPage.module.css'
const PAGE_SIZE = 10
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function daysUntilExpiry(expiresAt: string | null) {
if (!expiresAt) return null
const diff = new Date(expiresAt).getTime() - Date.now()
@@ -103,6 +96,7 @@ export function WebsitesPage() {
const [editHost, setEditHost] = useState('')
const [editExpiresAt, setEditExpiresAt] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
@@ -142,8 +136,6 @@ export function WebsitesPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -169,13 +161,14 @@ export function WebsitesPage() {
setEditDomain(domain)
setEditHost(domain.host)
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
setEditError('')
setEditOpen(true)
}
async function submitEdit() {
if (!editDomain) return
setEditSubmitting(true)
setError('')
setEditError('')
try {
await updateDomain(editDomain.id, {
host: editHost.trim(),
@@ -200,7 +193,7 @@ export function WebsitesPage() {
}
})
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update domain.')
setEditError(err instanceof ApiError ? err.message : 'Unable to update domain.')
} finally {
setEditSubmitting(false)
}
@@ -575,37 +568,15 @@ export function WebsitesPage() {
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -616,8 +587,14 @@ export function WebsitesPage() {
onClose={() => {
setEditOpen(false)
setEditDomain(null)
setEditError('')
}}
>
{editError ? (
<p className={tableStyles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={tableStyles.field}>
<label htmlFor="edit-domain-host">Domain name</label>
<input
@@ -1,5 +1,13 @@
import { apiRequest } from '../lib/api'
import type { BusinessesListResponse, BusinessListItem, CreateBusinessPayload } from '../types/business'
import type {
BusinessesListResponse,
BusinessListItem,
CreateBusinessPayload,
MigrateFromOldPayload,
MigrateFromOldResponse,
PurgeBusinessDataPayload,
PurgeBusinessDataResponse,
} from '../types/business'
export interface ListBusinessesParams {
page?: number
@@ -12,6 +20,7 @@ export interface ListBusinessesParams {
export interface UpdateBusinessPayload {
name?: string
slug?: string
oldBusinessId?: number | null
}
export interface AddDomainPayload {
@@ -91,12 +100,35 @@ export async function removeBusiness(businessId: string) {
})
}
export async function migrateBusinessFromOld(
businessId: string,
payload: MigrateFromOldPayload,
) {
return apiRequest<MigrateFromOldResponse>(`/businesses/${businessId}/migrate-from-old`, {
method: 'POST',
auth: true,
body: payload,
})
}
export async function purgeBusinessData(
businessId: string,
payload: PurgeBusinessDataPayload,
) {
return apiRequest<PurgeBusinessDataResponse>(`/businesses/${businessId}/purge-data`, {
method: 'POST',
auth: true,
body: payload,
})
}
export interface BusinessDetail {
id: string
name: string
nameFa: string | null
slug: string
isActive: boolean
oldBusinessId: string | null
}
export async function getBusiness(businessId: string, signal?: AbortSignal) {
@@ -12,6 +12,7 @@ import type {
InvoicesListResponse,
PublicInvoice,
UpdateInvoiceItemTemplatePayload,
UpdateInvoicePayload,
UpdateInvoiceTemplatePayload,
} from '../types/invoice'
@@ -122,6 +123,18 @@ export function createBusinessInvoice(businessId: string, payload: CreateInvoice
})
}
export function updateBusinessInvoice(
businessId: string,
invoiceId: string,
payload: UpdateInvoicePayload,
) {
return apiRequest<Invoice>(`/businesses/${businessId}/invoices/${invoiceId}`, {
method: 'PUT',
auth: true,
body: payload,
})
}
export function updateBusinessInvoiceStatus(
businessId: string,
invoiceId: string,
@@ -148,3 +161,11 @@ export function getPublicInvoice(publicId: string, signal?: AbortSignal) {
signal,
})
}
/** Public approve (no auth). issued → approved. */
export function approvePublicInvoice(publicId: string) {
return apiRequest<PublicInvoice>(`/public/invoices/${publicId}/approve`, {
method: 'POST',
auth: false,
})
}
+61
View File
@@ -21,6 +21,67 @@ export interface BusinessListItem {
ownerCellNumber: string | null
isActive: boolean
primaryColor: BusinessPrimaryColorId
oldBusinessId: string | null
}
export type MigrateFromOldEntity =
| 'product_categories'
| 'product'
| 'customer_categories'
| 'customer'
| 'blog_categories'
| 'blog'
| 'portfolio_categories'
| 'portfolio'
export type PurgeBusinessDataEntity = MigrateFromOldEntity
export type MigrateEntityResult =
| {
created: number
skipped: number
total: number
imagesCopied?: number
imagesResized?: number
imagesFailed?: number
titlesUpdated?: number
skippedInvalidCell?: number
skippedAlreadyLinked?: number
skippedCreateFailed?: number
}
| { status: 'not_implemented' }
export type PurgeEntityResult =
| {
deleted: number
imagesDeleted?: number
}
| { status: 'not_implemented' }
export interface MigrateFromOldPayload {
oldBusinessId: number
entities: MigrateFromOldEntity[]
}
export interface MigrateFromOldResponse {
businessId: string
oldBusinessId: string
entities: MigrateFromOldEntity[]
status: 'ok' | 'partial' | 'linked'
message: string
results?: Partial<Record<MigrateFromOldEntity, MigrateEntityResult>>
}
export interface PurgeBusinessDataPayload {
entities: PurgeBusinessDataEntity[]
}
export interface PurgeBusinessDataResponse {
businessId: string
entities: PurgeBusinessDataEntity[]
status: 'ok' | 'partial' | 'noop'
message: string
results?: Partial<Record<PurgeBusinessDataEntity, PurgeEntityResult>>
}
export interface CreateBusinessPayload {
+11 -1
View File
@@ -1,4 +1,4 @@
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'cancelled'
export type InvoiceStatus = 'draft' | 'issued' | 'approved' | 'paid' | 'cancelled'
export interface InvoiceItemTemplate {
id: string
@@ -125,6 +125,16 @@ export interface CreateInvoicePayload {
status?: InvoiceStatus
}
export type UpdateInvoicePayload = {
items: InvoiceItemInput[]
name?: string | null
topText?: string | null
notes?: string | null
invoiceTemplateId?: string | null
keyPoints?: InvoiceKeyPointInput[]
accounts?: InvoiceAccountInput[]
}
export interface InvoiceTemplateItem {
id: string
itemTemplateId: string | null
@@ -112,6 +112,71 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
}
}
export function draftsFromInvoice(invoice: {
name: string | null
topText: string | null
notes?: string | null
items?: Array<{
templateId: string | null
title: string
duration: string | null
worktime: string | null
description: string | null
price: number
discountedPrice: number | null
}>
keyPoints?: Array<{ text: string }>
accounts?: Array<{
bankName: string
accountHolderName: string | null
cardNumber: string | null
iban: string | null
}>
}): {
name: string
topText: string
notes: string
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
accounts: DraftAccount[]
} {
return {
name: invoice.name ?? '',
topText: invoice.topText ?? '',
notes: invoice.notes ?? '',
items:
(invoice.items?.length ?? 0) > 0
? invoice.items!.map((item) => ({
key: newKey(),
itemTemplateId: item.templateId ?? 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:
(invoice.keyPoints?.length ?? 0) > 0
? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text }))
: [],
accounts:
(invoice.accounts?.length ?? 0) > 0
? invoice.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()