mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Improve product cards/pagination, Farsi locale fonts, and super-admin migrate UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2e40d5eb4c
commit
f5b2193ba1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user