mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add super-admin invoices: settings templates and per-business issue flow.
Platform invoice templates live under Settings; each business can list and issue invoices with optional name and a meshkee.com public link. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
db52a83f98
commit
8f4af7a16c
@@ -1,4 +1,6 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||||
# Hostname for this dashboard (meshkee.app in production).
|
# Hostname for this dashboard (meshkee.app in production).
|
||||||
VITE_ADMIN_DOMAIN=meshkee.app
|
VITE_ADMIN_DOMAIN=meshkee.app
|
||||||
|
# Public domain used in platform invoice links (https://{domain}/invoices/{id}).
|
||||||
|
VITE_INVOICE_PUBLIC_DOMAIN=meshkee.com
|
||||||
# Local HTTPS certs (gitignored): mkcert -cert-file .certs/meshkee.app.pem -key-file .certs/meshkee.app-key.pem meshkee.app
|
# Local HTTPS certs (gitignored): mkcert -cert-file .certs/meshkee.app.pem -key-file .certs/meshkee.app-key.pem meshkee.app
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { GuestRoute } from './components/GuestRoute'
|
|||||||
import { PageLayout } from './components/PageLayout'
|
import { PageLayout } from './components/PageLayout'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { BusinessesPage } from './pages/BusinessesPage'
|
import { BusinessesPage } from './pages/BusinessesPage'
|
||||||
|
import { BusinessInvoicesPage } from './pages/BusinessInvoicesPage'
|
||||||
import { UsersPage } from './pages/UsersPage'
|
import { UsersPage } from './pages/UsersPage'
|
||||||
import { WebsitesPage } from './pages/WebsitesPage'
|
import { WebsitesPage } from './pages/WebsitesPage'
|
||||||
|
import { SettingsPage } from './pages/SettingsPage'
|
||||||
import { ProfilePage } from './pages/ProfilePage'
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
|
|
||||||
@@ -27,8 +29,10 @@ function App() {
|
|||||||
<Route element={<PageLayout />}>
|
<Route element={<PageLayout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="businesses" element={<BusinessesPage />} />
|
<Route path="businesses" element={<BusinessesPage />} />
|
||||||
|
<Route path="businesses/:businessId/invoices" element={<BusinessInvoicesPage />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="websites" element={<WebsitesPage />} />
|
<Route path="websites" element={<WebsitesPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modalXl {
|
||||||
|
max-width: min(1180px, calc(100vw - 40px));
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 18px 18px 10px;
|
padding: 18px 18px 10px;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ interface ModalProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
wide?: boolean
|
wide?: boolean
|
||||||
|
/** Extra-wide dialog for dense forms (e.g. invoice editor). */
|
||||||
|
xl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ open, title, children, onClose, wide }: ModalProps) {
|
export function Modal({ open, title, children, onClose, wide, xl }: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -22,9 +24,11 @@ export function Modal({ open, title, children, onClose, wide }: ModalProps) {
|
|||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
|
const sizeClass = xl ? styles.modalXl : wide ? styles.modalWide : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.overlay} role="dialog" aria-modal="true" onClick={onClose}>
|
<div className={styles.overlay} role="dialog" aria-modal="true" onClick={onClose}>
|
||||||
<div className={`${styles.modal} ${wide ? styles.modalWide : ''}`} onClick={(e) => e.stopPropagation()}>
|
<div className={`${styles.modal} ${sizeClass}`} onClick={(e) => e.stopPropagation()}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h3 className={styles.title}>{title}</h3>
|
<h3 className={styles.title}>{title}</h3>
|
||||||
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
.gridHome {
|
.gridHome {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
import { Home, Building2, Users, Globe, HelpCircle, LogOut } from 'lucide-react'
|
import { Home, Building2, Users, Globe, Settings, HelpCircle, LogOut } from 'lucide-react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
@@ -9,6 +9,7 @@ const navItems = [
|
|||||||
{ icon: Building2, label: 'Businesses', to: '/businesses' },
|
{ icon: Building2, label: 'Businesses', to: '/businesses' },
|
||||||
{ icon: Users, label: 'Users', to: '/users' },
|
{ icon: Users, label: 'Users', to: '/users' },
|
||||||
{ icon: Globe, label: 'Websites', to: '/websites' },
|
{ icon: Globe, label: 'Websites', to: '/websites' },
|
||||||
|
{ icon: Settings, label: 'Settings', to: '/settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const footerItems = [
|
const footerItems = [
|
||||||
|
|||||||
@@ -8,3 +8,9 @@ export function getAdminDomain(): string {
|
|||||||
export function isAllowedAdminHost(hostname = window.location.hostname): boolean {
|
export function isAllowedAdminHost(hostname = window.location.hostname): boolean {
|
||||||
return hostname === getAdminDomain()
|
return hostname === getAdminDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Public invoice URL for platform (super-admin) invoices. */
|
||||||
|
export function getPlatformInvoicePublicUrl(invoiceId: string): string {
|
||||||
|
const domain = import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'
|
||||||
|
return `https://${domain}/invoices/${invoiceId}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
.backLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_draft {
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_issued {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.12);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_paid {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_cancelled {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templateBar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templateHint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.templateHint a {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemsStack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
max-height: min(48vh, 420px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemCard {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemCardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeItemBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: #b91c1c;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeItemBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeItemBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 1fr) minmax(240px, 1.4fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descField {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publicLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 280px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publicLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyLinkBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.06);
|
||||||
|
border: 1px solid rgba(var(--primary-rgb) / 0.14);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyLinkBtn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createTotal {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMeta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMeta select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right var(--select-arrow-offset) center;
|
||||||
|
background-size: var(--select-arrow-size);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailNotes {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailItems {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailItem {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailItemTop {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailItemMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 14px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailItemDesc {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strike {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.itemGrid,
|
||||||
|
.metaGrid,
|
||||||
|
.detailMeta {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descField {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.templateBar,
|
||||||
|
.itemGrid,
|
||||||
|
.metaGrid,
|
||||||
|
.detailMeta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span2,
|
||||||
|
.descField {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemActions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Copy,
|
||||||
|
Eye,
|
||||||
|
FilePlus2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { ApiError, isAbortError } from '../lib/api'
|
||||||
|
import { getPlatformInvoicePublicUrl } from '../lib/config'
|
||||||
|
import { getBusiness, type BusinessDetail } from '../services/businessService'
|
||||||
|
import {
|
||||||
|
createBusinessInvoice,
|
||||||
|
deleteBusinessInvoice,
|
||||||
|
listBusinessInvoices,
|
||||||
|
listInvoiceItemTemplates,
|
||||||
|
updateBusinessInvoiceStatus,
|
||||||
|
} from '../services/invoiceService'
|
||||||
|
import type {
|
||||||
|
Invoice,
|
||||||
|
InvoiceItemInput,
|
||||||
|
InvoiceItemTemplate,
|
||||||
|
InvoiceStatus,
|
||||||
|
} from '../types/invoice'
|
||||||
|
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||||
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
|
import tableStyles from './BusinessesPage.module.css'
|
||||||
|
import styles from './BusinessInvoicesPage.module.css'
|
||||||
|
|
||||||
|
type DraftItem = {
|
||||||
|
key: string
|
||||||
|
templateId?: string
|
||||||
|
title: string
|
||||||
|
duration: string
|
||||||
|
worktime: string
|
||||||
|
description: string
|
||||||
|
price: string
|
||||||
|
discountedPrice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'paid', 'cancelled']
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return value
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraftItem(): DraftItem {
|
||||||
|
return {
|
||||||
|
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
title: '',
|
||||||
|
duration: '',
|
||||||
|
worktime: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
discountedPrice: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromTemplate(template: InvoiceItemTemplate): DraftItem {
|
||||||
|
return {
|
||||||
|
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
templateId: template.id,
|
||||||
|
title: template.title,
|
||||||
|
duration: template.duration ?? '',
|
||||||
|
worktime: template.worktime ?? '',
|
||||||
|
description: template.description ?? '',
|
||||||
|
price: formatIrtInput(String(Math.round(template.price))),
|
||||||
|
discountedPrice:
|
||||||
|
template.discountedPrice === null || template.discountedPrice === undefined
|
||||||
|
? ''
|
||||||
|
: formatIrtInput(String(Math.round(template.discountedPrice))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: InvoiceStatus) {
|
||||||
|
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectivePrice(price: number, discountedPrice: number | null | undefined) {
|
||||||
|
if (discountedPrice !== null && discountedPrice !== undefined && discountedPrice < price) {
|
||||||
|
return discountedPrice
|
||||||
|
}
|
||||||
|
return price
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BusinessInvoicesPage() {
|
||||||
|
const { businessId = '' } = useParams()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
|
const [business, setBusiness] = useState<BusinessDetail | null>(null)
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||||
|
const [templates, setTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [draftItems, setDraftItems] = useState<DraftItem[]>([emptyDraftItem()])
|
||||||
|
const [invoiceName, setInvoiceName] = useState('')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [formError, setFormError] = useState('')
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
||||||
|
|
||||||
|
const [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null)
|
||||||
|
const [statusUpdating, setStatusUpdating] = useState(false)
|
||||||
|
const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null)
|
||||||
|
|
||||||
|
const createTotal = useMemo(() => {
|
||||||
|
return draftItems.reduce((sum, item) => {
|
||||||
|
const price = parseIrtInput(item.price) ?? 0
|
||||||
|
const discounted = item.discountedPrice.trim()
|
||||||
|
? parseIrtInput(item.discountedPrice)
|
||||||
|
: null
|
||||||
|
return sum + effectivePrice(price, discounted)
|
||||||
|
}, 0)
|
||||||
|
}, [draftItems])
|
||||||
|
|
||||||
|
async function reload(signal?: AbortSignal) {
|
||||||
|
if (!businessId) return
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [biz, list, tpl] = await Promise.all([
|
||||||
|
getBusiness(businessId, signal),
|
||||||
|
listBusinessInvoices(businessId, { page, pageSize: 20 }, signal),
|
||||||
|
listInvoiceItemTemplates(signal),
|
||||||
|
])
|
||||||
|
setBusiness(biz)
|
||||||
|
setInvoices(list.items)
|
||||||
|
setTotalPages(list.totalPages)
|
||||||
|
setTotal(list.total)
|
||||||
|
setTemplates(tpl.items.filter((t) => t.isActive))
|
||||||
|
} catch (err) {
|
||||||
|
if (isAbortError(err)) return
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Unable to load invoices.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
void reload(controller.signal)
|
||||||
|
return () => controller.abort()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [businessId, page])
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setDraftItems([emptyDraftItem()])
|
||||||
|
setInvoiceName('')
|
||||||
|
setNotes('')
|
||||||
|
setSelectedTemplateId('')
|
||||||
|
setFormError('')
|
||||||
|
setCreateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraftItem(key: string, patch: Partial<DraftItem>) {
|
||||||
|
setDraftItems((items) => items.map((item) => (item.key === key ? { ...item, ...patch } : item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDraftItem(key: string) {
|
||||||
|
setDraftItems((items) => (items.length <= 1 ? items : items.filter((item) => item.key !== key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomItem() {
|
||||||
|
setDraftItems((items) => [...items, emptyDraftItem()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFromTemplate() {
|
||||||
|
const template = templates.find((t) => t.id === selectedTemplateId)
|
||||||
|
if (!template) return
|
||||||
|
setDraftItems((items) => {
|
||||||
|
const onlyEmpty =
|
||||||
|
items.length === 1 &&
|
||||||
|
!items[0].title.trim() &&
|
||||||
|
!items[0].price.trim() &&
|
||||||
|
!items[0].description.trim()
|
||||||
|
return onlyEmpty ? [fromTemplate(template)] : [...items, fromTemplate(template)]
|
||||||
|
})
|
||||||
|
setSelectedTemplateId('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildItemsPayload(): InvoiceItemInput[] {
|
||||||
|
return draftItems.map((item) => {
|
||||||
|
const title = item.title.trim()
|
||||||
|
const price = parseIrtInput(item.price)
|
||||||
|
if (!title) throw new Error('Each item needs a title.')
|
||||||
|
if (price === null) throw new Error(`Price is required for “${title || 'item'}”.`)
|
||||||
|
const discountedPrice = item.discountedPrice.trim()
|
||||||
|
? parseIrtInput(item.discountedPrice)
|
||||||
|
: null
|
||||||
|
if (item.discountedPrice.trim() && discountedPrice === null) {
|
||||||
|
throw new Error(`Discounted price is invalid for “${title}”.`)
|
||||||
|
}
|
||||||
|
if (discountedPrice !== null && discountedPrice > price) {
|
||||||
|
throw new Error(`Discounted price cannot exceed price for “${title}”.`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
templateId: item.templateId,
|
||||||
|
title,
|
||||||
|
duration: item.duration.trim() || undefined,
|
||||||
|
worktime: item.worktime.trim() || undefined,
|
||||||
|
description: item.description.trim() || undefined,
|
||||||
|
price,
|
||||||
|
discountedPrice,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setFormError('')
|
||||||
|
let items: InvoiceItemInput[]
|
||||||
|
try {
|
||||||
|
items = buildItemsPayload()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof Error ? err.message : 'Invalid invoice items.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await createBusinessInvoice(businessId, {
|
||||||
|
items,
|
||||||
|
name: invoiceName.trim() || undefined,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
})
|
||||||
|
showToast('Invoice issued.', 'success')
|
||||||
|
setCreateOpen(false)
|
||||||
|
setPage(1)
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof ApiError ? err.message : 'Unable to create invoice.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(next: InvoiceStatus) {
|
||||||
|
if (!detailInvoice) return
|
||||||
|
setStatusUpdating(true)
|
||||||
|
try {
|
||||||
|
const updated = await updateBusinessInvoiceStatus(businessId, detailInvoice.id, {
|
||||||
|
status: next,
|
||||||
|
})
|
||||||
|
setDetailInvoice(updated)
|
||||||
|
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
|
||||||
|
showToast('Invoice status updated.', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof ApiError ? err.message : 'Unable to update status.', 'error')
|
||||||
|
} finally {
|
||||||
|
setStatusUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove() {
|
||||||
|
if (!removeTarget) return
|
||||||
|
try {
|
||||||
|
await deleteBusinessInvoice(businessId, removeTarget.id)
|
||||||
|
showToast('Invoice removed.', 'success')
|
||||||
|
setRemoveTarget(null)
|
||||||
|
if (detailInvoice?.id === removeTarget.id) setDetailInvoice(null)
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof ApiError ? err.message : 'Unable to remove invoice.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPublicLink(invoice: Invoice) {
|
||||||
|
const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
showToast('Invoice link copied.', 'success')
|
||||||
|
} catch {
|
||||||
|
showToast('Unable to copy link.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoicePublicUrl(invoice: Invoice) {
|
||||||
|
return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessName = business?.nameFa || business?.name || 'Business'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={pageStyles.content}>
|
||||||
|
<div className={pageStyles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<Link to="/businesses" className={styles.backLink}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back to businesses
|
||||||
|
</Link>
|
||||||
|
<h2 className={pageStyles.pageTitle}>Invoices · {businessName}</h2>
|
||||||
|
<p className={pageStyles.pageSubtitle}>
|
||||||
|
List invoices issued to this business, or create a new one from predefined or custom
|
||||||
|
items.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||||
|
onClick={openCreate}
|
||||||
|
>
|
||||||
|
<FilePlus2 size={16} />
|
||||||
|
Issue invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className={styles.alertError}>{error}</p> : null}
|
||||||
|
|
||||||
|
<div className={tableStyles.tableWrap}>
|
||||||
|
<div className={tableStyles.tableHeader}>
|
||||||
|
<div className={tableStyles.tableHeaderTitle}>Invoices</div>
|
||||||
|
<div className={tableStyles.meta}>
|
||||||
|
{loading ? 'Loading…' : `${total} invoice${total === 1 ? '' : 's'}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table className={tableStyles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={tableStyles.th}>Name</th>
|
||||||
|
<th className={tableStyles.th}>Issued</th>
|
||||||
|
<th className={tableStyles.th}>Status</th>
|
||||||
|
<th className={tableStyles.th}>Total</th>
|
||||||
|
<th className={tableStyles.th}>Link</th>
|
||||||
|
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{!loading && invoices.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className={tableStyles.td} colSpan={6}>
|
||||||
|
No invoices yet for this business.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<tr key={invoice.id}>
|
||||||
|
<td className={tableStyles.td}>
|
||||||
|
<div className={styles.itemTitle}>{invoice.name || `Invoice #${invoice.id}`}</div>
|
||||||
|
<div className={tableStyles.subText}>
|
||||||
|
{invoice.items?.length ?? 0} item{(invoice.items?.length ?? 0) === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td>
|
||||||
|
<td className={tableStyles.td}>
|
||||||
|
<span className={`${styles.statusChip} ${styles[`status_${invoice.status}`]}`}>
|
||||||
|
{statusLabel(invoice.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={tableStyles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
|
||||||
|
<td className={tableStyles.td}>
|
||||||
|
<div className={styles.linkRow}>
|
||||||
|
<a
|
||||||
|
className={styles.publicLink}
|
||||||
|
href={invoicePublicUrl(invoice)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={invoicePublicUrl(invoice)}
|
||||||
|
>
|
||||||
|
{invoicePublicUrl(invoice).replace(/^https?:\/\//, '')}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.copyLinkBtn}
|
||||||
|
onClick={() => void copyPublicLink(invoice)}
|
||||||
|
title="Copy link"
|
||||||
|
aria-label="Copy invoice link"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||||
|
<div className={tableStyles.rowActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tableStyles.controlBtn}
|
||||||
|
onClick={() => setDetailInvoice(invoice)}
|
||||||
|
title="View"
|
||||||
|
aria-label="View invoice"
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
||||||
|
onClick={() => setRemoveTarget(invoice)}
|
||||||
|
title="Remove"
|
||||||
|
aria-label="Remove invoice"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={createOpen}
|
||||||
|
title={`Issue invoice · ${businessName}`}
|
||||||
|
onClose={() => !submitting && setCreateOpen(false)}
|
||||||
|
xl
|
||||||
|
>
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="invoice-name">Name (optional)</label>
|
||||||
|
<input
|
||||||
|
id="invoice-name"
|
||||||
|
value={invoiceName}
|
||||||
|
onChange={(e) => setInvoiceName(e.target.value)}
|
||||||
|
placeholder="e.g. Website redesign package"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="invoice-notes">Notes</label>
|
||||||
|
<input
|
||||||
|
id="invoice-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Optional notes for this invoice"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.templateBar}>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="template-pick">Add from predefined</label>
|
||||||
|
<select
|
||||||
|
id="template-pick"
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select an item…</option>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.id} value={template.id}>
|
||||||
|
{template.title} · {formatIrtPrice(template.price)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||||
|
onClick={addFromTemplate}
|
||||||
|
disabled={!selectedTemplateId}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<p className={styles.templateHint}>
|
||||||
|
No predefined items yet. Manage them in{' '}
|
||||||
|
<Link to="/settings">Settings → Invoices</Link>, or add custom lines below.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.itemsStack}>
|
||||||
|
{draftItems.map((item, index) => (
|
||||||
|
<div key={item.key} className={styles.itemCard}>
|
||||||
|
<div className={styles.itemCardHeader}>
|
||||||
|
<span>Item {index + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeItemBtn}
|
||||||
|
onClick={() => removeDraftItem(item.key)}
|
||||||
|
disabled={draftItems.length <= 1}
|
||||||
|
aria-label="Remove item"
|
||||||
|
title="Remove item"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.itemGrid}>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label>Title</label>
|
||||||
|
<input
|
||||||
|
value={item.title}
|
||||||
|
onChange={(e) => updateDraftItem(item.key, { title: e.target.value })}
|
||||||
|
placeholder="Service title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label>Duration</label>
|
||||||
|
<input
|
||||||
|
value={item.duration}
|
||||||
|
onChange={(e) => updateDraftItem(item.key, { duration: e.target.value })}
|
||||||
|
placeholder="e.g. 3 months"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label>Worktime</label>
|
||||||
|
<input
|
||||||
|
value={item.worktime}
|
||||||
|
onChange={(e) => updateDraftItem(item.key, { worktime: e.target.value })}
|
||||||
|
placeholder="e.g. 40 hours"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label>Price (IRT)</label>
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={item.price}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDraftItem(item.key, { price: formatIrtInput(e.target.value) })
|
||||||
|
}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label>Discounted price (IRT)</label>
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={item.discountedPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDraftItem(item.key, {
|
||||||
|
discountedPrice: formatIrtInput(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${tableStyles.field} ${styles.descField}`}>
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => updateDraftItem(item.key, { description: e.target.value })}
|
||||||
|
placeholder="Optional details"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.itemActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||||
|
onClick={addCustomItem}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
Add custom item
|
||||||
|
</button>
|
||||||
|
<div className={styles.createTotal}>Total: {formatIrtPrice(createTotal)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError ? <p className={styles.alertError}>{formError}</p> : null}
|
||||||
|
|
||||||
|
<div className={tableStyles.actionsRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||||
|
onClick={() => setCreateOpen(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||||
|
onClick={() => void handleCreate()}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Issuing…' : 'Issue invoice'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!detailInvoice}
|
||||||
|
title={
|
||||||
|
detailInvoice
|
||||||
|
? detailInvoice.name || `Invoice · ${formatDate(detailInvoice.issuedAt)}`
|
||||||
|
: 'Invoice'
|
||||||
|
}
|
||||||
|
onClose={() => setDetailInvoice(null)}
|
||||||
|
xl
|
||||||
|
>
|
||||||
|
{detailInvoice ? (
|
||||||
|
<>
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{statusLabel(option)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.detailLabel}>Total</span>
|
||||||
|
<strong>{formatIrtPrice(detailInvoice.total ?? 0)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.detailLabel}>Public link</span>
|
||||||
|
<div className={styles.linkRow}>
|
||||||
|
<a
|
||||||
|
className={styles.publicLink}
|
||||||
|
href={invoicePublicUrl(detailInvoice)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{invoicePublicUrl(detailInvoice).replace(/^https?:\/\//, '')}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.copyLinkBtn}
|
||||||
|
onClick={() => void copyPublicLink(detailInvoice)}
|
||||||
|
title="Copy link"
|
||||||
|
aria-label="Copy invoice link"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailInvoice.notes ? (
|
||||||
|
<p className={styles.detailNotes}>{detailInvoice.notes}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.detailItems}>
|
||||||
|
{(detailInvoice.items ?? []).map((item) => (
|
||||||
|
<div key={item.id} className={styles.detailItem}>
|
||||||
|
<div className={styles.detailItemTop}>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span>
|
||||||
|
{item.discountedPrice !== null &&
|
||||||
|
item.discountedPrice < item.price ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.strike}>{formatIrtPrice(item.price)}</span>{' '}
|
||||||
|
{formatIrtPrice(item.discountedPrice)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
formatIrtPrice(item.price)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailItemMeta}>
|
||||||
|
{item.duration ? <span>Duration: {item.duration}</span> : null}
|
||||||
|
{item.worktime ? <span>Worktime: {item.worktime}</span> : null}
|
||||||
|
</div>
|
||||||
|
{item.description ? (
|
||||||
|
<p className={styles.detailItemDesc}>{item.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
open={!!removeTarget}
|
||||||
|
title="Remove invoice"
|
||||||
|
message="Remove this invoice permanently? This cannot be undone."
|
||||||
|
onCancel={() => setRemoveTarget(null)}
|
||||||
|
onConfirm={() => void handleRemove()}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
padding: var(--field-padding-y) var(--field-padding-x);
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
font-size: var(--field-font-size);
|
font-size: var(--field-font-size);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.65);
|
background-color: rgba(255, 255, 255, 0.65);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -76,8 +76,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field select {
|
.field select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
min-height: var(--field-height);
|
min-height: var(--field-height);
|
||||||
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
|
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right var(--select-arrow-offset) center;
|
||||||
|
background-size: var(--select-arrow-size);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input,
|
.field input,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -653,6 +654,15 @@ export function BusinessesPage() {
|
|||||||
onChange={(isActive) => void handleToggleActive(b, isActive)}
|
onChange={(isActive) => void handleToggleActive(b, isActive)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.controlBtn}
|
||||||
|
onClick={() => navigate(`/businesses/${b.id}/invoices`)}
|
||||||
|
title="Invoices"
|
||||||
|
aria-label="View invoices"
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.controlBtn}
|
className={styles.controlBtn}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CalendarDays, Building2, Users, Globe } from 'lucide-react'
|
import { CalendarDays, Building2, Users, Globe, Settings } from 'lucide-react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { SectionCard } from '../components/SectionCard'
|
import { SectionCard } from '../components/SectionCard'
|
||||||
import styles from '../components/PageContent.module.css'
|
import styles from '../components/PageContent.module.css'
|
||||||
@@ -25,6 +25,13 @@ const sections = [
|
|||||||
linkText: 'View websites',
|
linkText: 'View websites',
|
||||||
href: '/websites',
|
href: '/websites',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Settings,
|
||||||
|
title: 'Settings',
|
||||||
|
description: 'Configure platform defaults such as invoice line items.',
|
||||||
|
linkText: 'Open settings',
|
||||||
|
href: '/settings',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function getFormattedDate() {
|
function getFormattedDate() {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
.section {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--blur-glass));
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleRow svg {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSubtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint a {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.sectionHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span2 {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
|
||||||
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { ApiError, isAbortError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
createInvoiceItemTemplate,
|
||||||
|
deleteInvoiceItemTemplate,
|
||||||
|
listInvoiceItemTemplates,
|
||||||
|
updateInvoiceItemTemplate,
|
||||||
|
} from '../services/invoiceService'
|
||||||
|
import type { InvoiceItemTemplate } from '../types/invoice'
|
||||||
|
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||||
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
|
import tableStyles from './BusinessesPage.module.css'
|
||||||
|
import styles from './SettingsPage.module.css'
|
||||||
|
|
||||||
|
type TemplateDraft = {
|
||||||
|
title: string
|
||||||
|
duration: string
|
||||||
|
worktime: string
|
||||||
|
description: string
|
||||||
|
price: string
|
||||||
|
discountedPrice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: TemplateDraft = {
|
||||||
|
title: '',
|
||||||
|
duration: '',
|
||||||
|
worktime: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
discountedPrice: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromTemplate(t: InvoiceItemTemplate): TemplateDraft {
|
||||||
|
return {
|
||||||
|
title: t.title,
|
||||||
|
duration: t.duration ?? '',
|
||||||
|
worktime: t.worktime ?? '',
|
||||||
|
description: t.description ?? '',
|
||||||
|
price: formatIrtInput(String(Math.round(t.price))),
|
||||||
|
discountedPrice:
|
||||||
|
t.discountedPrice === null || t.discountedPrice === undefined
|
||||||
|
? ''
|
||||||
|
: formatIrtInput(String(Math.round(t.discountedPrice))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(draft: TemplateDraft) {
|
||||||
|
const price = parseIrtInput(draft.price)
|
||||||
|
if (!draft.title.trim()) {
|
||||||
|
throw new Error('Title is required.')
|
||||||
|
}
|
||||||
|
if (price === null) {
|
||||||
|
throw new Error('Price is required.')
|
||||||
|
}
|
||||||
|
const discountedPrice = draft.discountedPrice.trim()
|
||||||
|
? parseIrtInput(draft.discountedPrice)
|
||||||
|
: null
|
||||||
|
if (draft.discountedPrice.trim() && discountedPrice === null) {
|
||||||
|
throw new Error('Discounted price is invalid.')
|
||||||
|
}
|
||||||
|
if (discountedPrice !== null && discountedPrice > price) {
|
||||||
|
throw new Error('Discounted price cannot exceed price.')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: draft.title.trim(),
|
||||||
|
duration: draft.duration.trim() || undefined,
|
||||||
|
worktime: draft.worktime.trim() || undefined,
|
||||||
|
description: draft.description.trim() || undefined,
|
||||||
|
price,
|
||||||
|
discountedPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [templates, setTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<InvoiceItemTemplate | null>(null)
|
||||||
|
const [draft, setDraft] = useState<TemplateDraft>(EMPTY_DRAFT)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [formError, setFormError] = useState('')
|
||||||
|
const [removeTarget, setRemoveTarget] = useState<InvoiceItemTemplate | null>(null)
|
||||||
|
|
||||||
|
async function reload(signal?: AbortSignal) {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await listInvoiceItemTemplates(signal)
|
||||||
|
setTemplates(result.items)
|
||||||
|
} catch (err) {
|
||||||
|
if (isAbortError(err)) return
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Unable to load invoice templates.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
void reload(controller.signal)
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null)
|
||||||
|
setDraft(EMPTY_DRAFT)
|
||||||
|
setFormError('')
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(template: InvoiceItemTemplate) {
|
||||||
|
setEditing(template)
|
||||||
|
setDraft(draftFromTemplate(template))
|
||||||
|
setFormError('')
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setFormError('')
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = toPayload(draft)
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof Error ? err.message : 'Invalid form.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateInvoiceItemTemplate(editing.id, payload)
|
||||||
|
showToast('Invoice item updated.', 'success')
|
||||||
|
} else {
|
||||||
|
await createInvoiceItemTemplate(payload)
|
||||||
|
showToast('Invoice item created.', 'success')
|
||||||
|
}
|
||||||
|
setEditorOpen(false)
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof ApiError ? err.message : 'Unable to save invoice item.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove() {
|
||||||
|
if (!removeTarget) return
|
||||||
|
try {
|
||||||
|
await deleteInvoiceItemTemplate(removeTarget.id)
|
||||||
|
showToast('Invoice item removed.', 'success')
|
||||||
|
setRemoveTarget(null)
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof ApiError ? err.message : 'Unable to remove item.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={pageStyles.content}>
|
||||||
|
<div className={pageStyles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h2 className={pageStyles.pageTitle}>Settings</h2>
|
||||||
|
<p className={pageStyles.pageSubtitle}>
|
||||||
|
Platform configuration used across Meshkee super admin tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<div className={styles.sectionTitleRow}>
|
||||||
|
<FileText size={18} />
|
||||||
|
<div>
|
||||||
|
<h3 className={styles.sectionTitle}>Invoices</h3>
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
Predefined line items you can reuse when issuing invoices to businesses.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className={`${tableStyles.btn} ${tableStyles.btnPrimary}`} onClick={openCreate}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className={styles.alertError}>{error}</p> : null}
|
||||||
|
|
||||||
|
<div className={tableStyles.tableWrap}>
|
||||||
|
<div className={tableStyles.tableHeader}>
|
||||||
|
<div className={tableStyles.tableHeaderTitle}>Predefined invoice items</div>
|
||||||
|
<div className={tableStyles.meta}>
|
||||||
|
{loading ? 'Loading…' : `${templates.length} item${templates.length === 1 ? '' : 's'}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table className={tableStyles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={tableStyles.th}>Title</th>
|
||||||
|
<th className={tableStyles.th}>Duration</th>
|
||||||
|
<th className={tableStyles.th}>Worktime</th>
|
||||||
|
<th className={tableStyles.th}>Price</th>
|
||||||
|
<th className={tableStyles.th}>Discounted</th>
|
||||||
|
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{!loading && templates.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className={tableStyles.td} colSpan={6}>
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<SettingsIcon size={20} />
|
||||||
|
<span>No predefined items yet. Add one to speed up invoice creation.</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{templates.map((template) => (
|
||||||
|
<tr key={template.id}>
|
||||||
|
<td className={tableStyles.td}>
|
||||||
|
<div className={styles.itemTitle}>{template.title}</div>
|
||||||
|
{template.description ? (
|
||||||
|
<div className={tableStyles.subText}>{template.description}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className={tableStyles.td}>{template.duration || '—'}</td>
|
||||||
|
<td className={tableStyles.td}>{template.worktime || '—'}</td>
|
||||||
|
<td className={tableStyles.td}>{formatIrtPrice(template.price)}</td>
|
||||||
|
<td className={tableStyles.td}>
|
||||||
|
{template.discountedPrice === null
|
||||||
|
? '—'
|
||||||
|
: formatIrtPrice(template.discountedPrice)}
|
||||||
|
</td>
|
||||||
|
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||||
|
<div className={tableStyles.rowActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tableStyles.controlBtn}
|
||||||
|
onClick={() => openEdit(template)}
|
||||||
|
title="Edit"
|
||||||
|
aria-label="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
||||||
|
onClick={() => setRemoveTarget(template)}
|
||||||
|
title="Remove"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className={styles.hint}>
|
||||||
|
Tip: open a business from <Link to="/businesses">Businesses</Link> to list invoices or issue
|
||||||
|
a new one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={editorOpen}
|
||||||
|
title={editing ? 'Edit invoice item' : 'Add invoice item'}
|
||||||
|
onClose={() => !submitting && setEditorOpen(false)}
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
<div className={styles.formGrid}>
|
||||||
|
<div className={`${tableStyles.field} ${styles.span2}`}>
|
||||||
|
<label htmlFor="tpl-title">Title</label>
|
||||||
|
<input
|
||||||
|
id="tpl-title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
|
||||||
|
placeholder="e.g. Website setup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="tpl-duration">Duration</label>
|
||||||
|
<input
|
||||||
|
id="tpl-duration"
|
||||||
|
value={draft.duration}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, duration: e.target.value }))}
|
||||||
|
placeholder="e.g. 3 months"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="tpl-worktime">Worktime</label>
|
||||||
|
<input
|
||||||
|
id="tpl-worktime"
|
||||||
|
value={draft.worktime}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, worktime: e.target.value }))}
|
||||||
|
placeholder="e.g. 40 hours"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="tpl-price">Price (IRT)</label>
|
||||||
|
<input
|
||||||
|
id="tpl-price"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft.price}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, price: formatIrtInput(e.target.value) }))}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="tpl-discount">Discounted price (IRT)</label>
|
||||||
|
<input
|
||||||
|
id="tpl-discount"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft.discountedPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((d) => ({ ...d, discountedPrice: formatIrtInput(e.target.value) }))
|
||||||
|
}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${tableStyles.field} ${styles.span2}`}>
|
||||||
|
<label htmlFor="tpl-desc">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="tpl-desc"
|
||||||
|
rows={3}
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
|
||||||
|
placeholder="Optional details shown on the invoice line"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{formError ? <p className={styles.alertError}>{formError}</p> : null}
|
||||||
|
<div className={tableStyles.actionsRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||||
|
onClick={() => setEditorOpen(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create item'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
open={!!removeTarget}
|
||||||
|
title="Remove invoice item"
|
||||||
|
message={
|
||||||
|
removeTarget
|
||||||
|
? `Remove “${removeTarget.title}” from predefined invoice items?`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onCancel={() => setRemoveTarget(null)}
|
||||||
|
onConfirm={() => void handleRemove()}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -91,3 +91,18 @@ export async function removeBusiness(businessId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BusinessDetail {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameFa: string | null
|
||||||
|
slug: string
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBusiness(businessId: string, signal?: AbortSignal) {
|
||||||
|
return apiRequest<BusinessDetail>(`/businesses/${businessId}`, {
|
||||||
|
auth: true,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { apiRequest } from '../lib/api'
|
||||||
|
import type {
|
||||||
|
CreateInvoiceItemTemplatePayload,
|
||||||
|
CreateInvoicePayload,
|
||||||
|
Invoice,
|
||||||
|
InvoiceItemTemplate,
|
||||||
|
InvoiceStatus,
|
||||||
|
InvoiceTemplatesResponse,
|
||||||
|
InvoicesListResponse,
|
||||||
|
UpdateInvoiceItemTemplatePayload,
|
||||||
|
} from '../types/invoice'
|
||||||
|
|
||||||
|
export function listInvoiceItemTemplates(signal?: AbortSignal) {
|
||||||
|
return apiRequest<InvoiceTemplatesResponse>('/invoice-item-templates', {
|
||||||
|
auth: true,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInvoiceItemTemplate(payload: CreateInvoiceItemTemplatePayload) {
|
||||||
|
return apiRequest<InvoiceItemTemplate>('/invoice-item-templates', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInvoiceItemTemplate(
|
||||||
|
templateId: string,
|
||||||
|
payload: UpdateInvoiceItemTemplatePayload,
|
||||||
|
) {
|
||||||
|
return apiRequest<InvoiceItemTemplate>(`/invoice-item-templates/${templateId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
auth: true,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteInvoiceItemTemplate(templateId: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/invoice-item-templates/${templateId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListBusinessInvoicesParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: InvoiceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBusinessInvoices(
|
||||||
|
businessId: string,
|
||||||
|
params: ListBusinessInvoicesParams = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params.page) search.set('page', String(params.page))
|
||||||
|
if (params.pageSize) search.set('pageSize', String(params.pageSize))
|
||||||
|
if (params.status) search.set('status', params.status)
|
||||||
|
const qs = search.toString()
|
||||||
|
return apiRequest<InvoicesListResponse>(
|
||||||
|
`/businesses/${businessId}/invoices${qs ? `?${qs}` : ''}`,
|
||||||
|
{ auth: true, signal },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBusinessInvoice(businessId: string, invoiceId: string, signal?: AbortSignal) {
|
||||||
|
return apiRequest<Invoice>(`/businesses/${businessId}/invoices/${invoiceId}`, {
|
||||||
|
auth: true,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBusinessInvoice(businessId: string, payload: CreateInvoicePayload) {
|
||||||
|
return apiRequest<Invoice>(`/businesses/${businessId}/invoices`, {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBusinessInvoiceStatus(
|
||||||
|
businessId: string,
|
||||||
|
invoiceId: string,
|
||||||
|
payload: { status: InvoiceStatus; notes?: string },
|
||||||
|
) {
|
||||||
|
return apiRequest<Invoice>(`/businesses/${businessId}/invoices/${invoiceId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
auth: true,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBusinessInvoice(businessId: string, invoiceId: string) {
|
||||||
|
return apiRequest<{ ok: boolean }>(`/businesses/${businessId}/invoices/${invoiceId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'cancelled'
|
||||||
|
|
||||||
|
export interface InvoiceItemTemplate {
|
||||||
|
id: string
|
||||||
|
ownerScope: 'platform' | 'business'
|
||||||
|
businessId: string | null
|
||||||
|
title: string
|
||||||
|
duration: string | null
|
||||||
|
worktime: string | null
|
||||||
|
description: string | null
|
||||||
|
price: number
|
||||||
|
discountedPrice: number | null
|
||||||
|
sortOrder: number
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceItem {
|
||||||
|
id: string
|
||||||
|
invoiceId: string
|
||||||
|
templateId: string | null
|
||||||
|
title: string
|
||||||
|
duration: string | null
|
||||||
|
worktime: string | null
|
||||||
|
description: string | null
|
||||||
|
price: number
|
||||||
|
discountedPrice: number | null
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string
|
||||||
|
businessId: string
|
||||||
|
ownerScope: 'platform' | 'business'
|
||||||
|
issuerBusinessId: string | null
|
||||||
|
status: InvoiceStatus
|
||||||
|
name: string | null
|
||||||
|
notes: string | null
|
||||||
|
publicUrl: string | null
|
||||||
|
issuedBy: string | null
|
||||||
|
issuedAt: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
business?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameFa: string | null
|
||||||
|
}
|
||||||
|
issuer?: {
|
||||||
|
id: string
|
||||||
|
firstName: string | null
|
||||||
|
lastName: string | null
|
||||||
|
} | null
|
||||||
|
items?: InvoiceItem[]
|
||||||
|
subtotal?: number
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceItemInput {
|
||||||
|
templateId?: string
|
||||||
|
title: string
|
||||||
|
duration?: string
|
||||||
|
worktime?: string
|
||||||
|
description?: string
|
||||||
|
price: number
|
||||||
|
discountedPrice?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoicePayload {
|
||||||
|
items: InvoiceItemInput[]
|
||||||
|
name?: string
|
||||||
|
notes?: string
|
||||||
|
status?: InvoiceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceTemplatesResponse {
|
||||||
|
items: InvoiceItemTemplate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoicesListResponse {
|
||||||
|
items: Invoice[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceItemTemplatePayload {
|
||||||
|
title: string
|
||||||
|
duration?: string
|
||||||
|
worktime?: string
|
||||||
|
description?: string
|
||||||
|
price: number
|
||||||
|
discountedPrice?: number | null
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateInvoiceItemTemplatePayload = Partial<CreateInvoiceItemTemplatePayload> & {
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** Format a numeric price for display in Iranian Toman (IRT). */
|
||||||
|
export function formatIrtPrice(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return '—'
|
||||||
|
return `${Math.round(value).toLocaleString('en-US')} IRT`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format raw digits into comma-separated groups while typing. */
|
||||||
|
export function formatIrtInput(raw: string): string {
|
||||||
|
const digits = raw.replace(/\D/g, '')
|
||||||
|
if (!digits) return ''
|
||||||
|
return Number(digits).toLocaleString('en-US')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a formatted IRT input string back to a number. */
|
||||||
|
export function parseIrtInput(formatted: string): number | null {
|
||||||
|
const digits = formatted.replace(/\D/g, '')
|
||||||
|
if (!digits) return null
|
||||||
|
return Number(digits)
|
||||||
|
}
|
||||||
Vendored
+1
@@ -3,6 +3,7 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL?: string
|
readonly VITE_API_BASE_URL?: string
|
||||||
readonly VITE_ADMIN_DOMAIN?: string
|
readonly VITE_ADMIN_DOMAIN?: string
|
||||||
|
readonly VITE_INVOICE_PUBLIC_DOMAIN?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
+41
-1
@@ -3,7 +3,7 @@
|
|||||||
> **For AI agents:** Read this file at the start of a new chat before making changes.
|
> **For AI agents:** Read this file at the start of a new chat before making changes.
|
||||||
> Update this document when a major feature is completed or architecture changes.
|
> Update this document when a major feature is completed or architecture changes.
|
||||||
|
|
||||||
Last updated: July 2026
|
Last updated: July 24, 2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,8 +139,10 @@ Add to `/etc/hosts` (one line per tenant):
|
|||||||
| `/login` | Login |
|
| `/login` | Login |
|
||||||
| `/` | Home |
|
| `/` | Home |
|
||||||
| `/businesses` | Businesses list |
|
| `/businesses` | Businesses list |
|
||||||
|
| `/businesses/:businessId/invoices` | Business invoices (list + issue) |
|
||||||
| `/users` | Users |
|
| `/users` | Users |
|
||||||
| `/websites` | Websites / domains |
|
| `/websites` | Websites / domains |
|
||||||
|
| `/settings` | Platform settings (invoice item templates) |
|
||||||
| `/profile` | Profile |
|
| `/profile` | Profile |
|
||||||
|
|
||||||
### Customer (`apps/customer`)
|
### Customer (`apps/customer`)
|
||||||
@@ -308,9 +310,37 @@ Run in order from `MeshkeeApp Backend/database/migrations/`:
|
|||||||
| `009_categories_name_fa.sql` | `name_fa` on categories — **required** or categories 500 |
|
| `009_categories_name_fa.sql` | `name_fa` on categories — **required** or categories 500 |
|
||||||
| `010_category_variations.sql` | variation tables |
|
| `010_category_variations.sql` | variation tables |
|
||||||
| `016_product_variation_values.sql` | product variation values |
|
| `016_product_variation_values.sql` | product variation values |
|
||||||
|
| `036_invoices.sql` | Platform/business invoices + invoice item templates |
|
||||||
|
|
||||||
After schema changes: `npx prisma generate` and restart the backend.
|
After schema changes: `npx prisma generate` and restart the backend.
|
||||||
|
|
||||||
|
### Invoices (super-admin)
|
||||||
|
|
||||||
|
Platform billing invoices issued **to** a business. Schema supports a future `owner_scope=business` mode (business-issued invoices); UI is super-admin only for now.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `GET/POST /invoice-item-templates` | Platform predefined line items (Settings) |
|
||||||
|
| `PATCH/DELETE /invoice-item-templates/:id` | Update/remove template |
|
||||||
|
| `GET/POST /businesses/:businessId/invoices` | List / issue invoices for a business |
|
||||||
|
| `GET/PATCH/DELETE /businesses/:businessId/invoices/:invoiceId` | Detail, status update, delete |
|
||||||
|
|
||||||
|
**Invoice fields:** optional `name`, `notes`, `status` (`draft` \| `issued` \| `paid` \| `cancelled`), `publicUrl`.
|
||||||
|
|
||||||
|
**Line item fields:** `title`, `duration`, `worktime`, `description`, `price`, `discountedPrice` (IRT). Create flow can mix predefined templates + custom lines.
|
||||||
|
|
||||||
|
**Public link (platform invoices):** `https://meshkee.com/invoices/{id}`
|
||||||
|
- Backend: `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`) → response field `publicUrl`
|
||||||
|
- Super Admin UI: `VITE_INVOICE_PUBLIC_DOMAIN` fallback helper in `lib/config.ts`
|
||||||
|
- Public viewer page at that URL is **not** implemented yet — link is issued/copied only.
|
||||||
|
|
||||||
|
**Migrations:** `036_invoices.sql`, `037_invoice_name.sql`
|
||||||
|
|
||||||
|
**Super Admin UI:**
|
||||||
|
- `/settings` — Invoices section (CRUD templates)
|
||||||
|
- `/businesses/:businessId/invoices` — list, issue (extra-wide modal), view/copy link, update status, delete
|
||||||
|
- Businesses table row → FileText control opens invoices page
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key files by area
|
## Key files by area
|
||||||
@@ -337,9 +367,17 @@ After schema changes: `npx prisma generate` and restart the backend.
|
|||||||
|------|------|
|
|------|------|
|
||||||
| Products | `src/products/products.service.ts` |
|
| Products | `src/products/products.service.ts` |
|
||||||
| Categories | `src/categories/categories.service.ts` |
|
| Categories | `src/categories/categories.service.ts` |
|
||||||
|
| Invoices | `src/invoices/` |
|
||||||
| Media / S3 | `src/media/media.service.ts` |
|
| Media / S3 | `src/media/media.service.ts` |
|
||||||
| Prisma schema | `prisma/schema.prisma` |
|
| Prisma schema | `prisma/schema.prisma` |
|
||||||
|
|
||||||
|
### Super Admin
|
||||||
|
| Area | Path |
|
||||||
|
|------|------|
|
||||||
|
| Settings / invoice templates | `apps/super-admin/src/pages/SettingsPage.tsx` |
|
||||||
|
| Business invoices | `apps/super-admin/src/pages/BusinessInvoicesPage.tsx` |
|
||||||
|
| Invoice API client | `apps/super-admin/src/services/invoiceService.ts` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## UI conventions
|
## UI conventions
|
||||||
@@ -434,6 +472,8 @@ SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h (option A:
|
|||||||
|
|
||||||
## Suggested next work
|
## Suggested next work
|
||||||
|
|
||||||
|
- Public invoice page at `meshkee.com/invoices/{id}` (platform invoices)
|
||||||
|
- Business-dashboard invoice templates + issue flow (`owner_scope=business`)
|
||||||
- Migrate business and super-admin to `@meshkee/dashboard-core` / `@meshkee/dashboard-ui`
|
- Migrate business and super-admin to `@meshkee/dashboard-core` / `@meshkee/dashboard-ui`
|
||||||
- Connect product comments to backend
|
- Connect product comments to backend
|
||||||
- Wire order status transitions to store `orderProcessSteps`
|
- Wire order status transitions to store `orderProcessSteps`
|
||||||
|
|||||||
Reference in New Issue
Block a user