+ )
+ })
+ : primaryItems.map((item, index) => {
+ const secondary = secondaryItems[index]
+ const secondaryCount = secondary?.count ?? 0
+ const primaryHeight = (item.count / maxValue) * CHART_HEIGHT
+ const secondaryHeight = (secondaryCount / maxValue) * CHART_HEIGHT
+ const label = formatDayLabel(item.date, locale)
- return (
-
-
-
0 ? 4 : 0) }}
- title={t(primaryBarTitleKey, { day: label, count: item.count })}
- />
-
0 ? 4 : 0),
- }}
- title={t(secondaryBarTitleKey, {
- day: label,
- count: secondaryCount,
- })}
- />
-
-
- {label}
-
-
- )
- })}
+ return (
+
+
+
0 ? 4 : 0) }}
+ title={t(primaryBarTitleKey, { day: label, count: item.count })}
+ />
+
0 ? 4 : 0),
+ }}
+ title={t(secondaryBarTitleKey, {
+ day: label,
+ count: secondaryCount,
+ })}
+ />
+
+
+ {label}
+
+
+ )
+ })}
)}
diff --git a/apps/business/src/components/Header.module.css b/apps/business/src/components/Header.module.css
index f48668c..bee8285 100644
--- a/apps/business/src/components/Header.module.css
+++ b/apps/business/src/components/Header.module.css
@@ -6,9 +6,9 @@
position: sticky;
top: 0;
z-index: 50;
- background: var(--glass-bg);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
+ background: var(--header-bg);
+ backdrop-filter: blur(20px) saturate(150%);
+ -webkit-backdrop-filter: blur(20px) saturate(150%);
border-bottom: 1px solid var(--glass-border);
isolation: isolate;
transform: translateZ(0);
diff --git a/apps/business/src/components/Header.tsx b/apps/business/src/components/Header.tsx
index e1f28be..ced182d 100644
--- a/apps/business/src/components/Header.tsx
+++ b/apps/business/src/components/Header.tsx
@@ -46,11 +46,15 @@ export function Header() {
const membership =
user?.businesses.find((b) => String(b.id) === String(activeBusinessId)) ??
user?.businesses[0]
- const adminBadgeLabel = isSuperAdmin
+ const roleBadgeLabel = isSuperAdmin
? t('role.superAdmin')
- : membership?.teamRole === 'admin'
- ? t('role.admin')
- : null
+ : membership?.isOwner
+ ? t('role.owner')
+ : membership?.teamRole === 'admin'
+ ? t('role.admin')
+ : membership?.teamRole
+ ? t('role.staff')
+ : null
useEffect(() => {
if (!menuOpen) return
@@ -116,8 +120,8 @@ export function Header() {
>
{displayName}
- {adminBadgeLabel ? (
- {adminBadgeLabel}
+ {roleBadgeLabel ? (
+ {roleBadgeLabel}
) : null}
)
case 'products_added_1y':
diff --git a/apps/business/src/components/InvoiceDraftFields.module.css b/apps/business/src/components/InvoiceDraftFields.module.css
new file mode 100644
index 0000000..30d917e
--- /dev/null
+++ b/apps/business/src/components/InvoiceDraftFields.module.css
@@ -0,0 +1,250 @@
+.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);
+ flex-shrink: 0;
+}
+
+.removeItemBtn:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.14);
+}
+
+.removeItemBtn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/** Matches compact field height; align with input (not label). */
+.removeFieldBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--field-height);
+ height: var(--field-height);
+ min-width: var(--field-height);
+ border-radius: var(--radius-sm);
+ color: #b91c1c;
+ background: rgba(239, 68, 68, 0.08);
+ border: 1px solid rgba(239, 68, 68, 0.18);
+ flex-shrink: 0;
+}
+
+.removeFieldBtn:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.14);
+}
+
+.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;
+}
+
+.descField {
+ grid-column: 1 / -1;
+}
+
+.itemActions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.itemActionsLeft {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ flex: 1;
+}
+
+.compactSelect {
+ width: min(260px, 100%);
+ min-height: 32px;
+ padding: 6px var(--select-padding-end) 6px 10px;
+ font-size: 12px;
+ font-family: var(--font-ui);
+ color: var(--text-primary);
+ background-color: rgba(255, 255, 255, 0.7);
+ border: 1px solid rgba(148, 163, 184, 0.35);
+ border-radius: var(--radius-sm);
+ appearance: none;
+ -webkit-appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ background-size: 14px;
+ cursor: pointer;
+}
+
+:global([dir='rtl']) .compactSelect {
+ padding: 6px 10px 6px var(--select-padding-end);
+ background-position: left 8px center;
+}
+
+.compactBtn {
+ padding: 6px 10px;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.blockSection {
+ margin: 16px 0;
+ padding-top: 12px;
+ border-top: 1px solid rgba(148, 163, 184, 0.22);
+}
+
+.blockHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 10px;
+}
+
+.blockTitle {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.templateHint {
+ margin: 0 0 12px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.repeatStack {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.repeatRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.repeatRow > div {
+ flex: 1;
+ min-width: 0;
+}
+
+.fieldNoLabel {
+ margin: 0;
+}
+
+.fieldNoLabel label {
+ display: none;
+}
+
+.flexGrow {
+ flex: 1;
+ min-width: 0;
+}
+
+.accountRow {
+ display: grid;
+ grid-template-columns: 2fr 2fr 3fr 5fr var(--field-height);
+ align-items: end;
+ gap: 8px;
+}
+
+.accountRowPlain {
+ align-items: center;
+}
+
+.accountCol2,
+.accountCol3,
+.accountCol5 {
+ min-width: 0;
+}
+
+.keyPointList {
+ margin: 0;
+ padding-inline-start: 18px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+
+@media (max-width: 900px) {
+ .itemGrid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .descField {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 720px) {
+ .itemGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .descField {
+ grid-column: span 1;
+ }
+
+ .accountRow {
+ grid-template-columns: 1fr var(--field-height);
+ }
+
+ .accountCol2,
+ .accountCol3,
+ .accountCol5 {
+ grid-column: 1;
+ }
+
+ .itemActions {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .itemActionsLeft {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .compactSelect {
+ width: 100%;
+ }
+}
diff --git a/apps/business/src/components/InvoiceDraftFields.tsx b/apps/business/src/components/InvoiceDraftFields.tsx
new file mode 100644
index 0000000..7a0935c
--- /dev/null
+++ b/apps/business/src/components/InvoiceDraftFields.tsx
@@ -0,0 +1,351 @@
+import { Pencil, Plus, X } from 'lucide-react'
+import type { ReactNode } from 'react'
+import { useT } from '../i18n/useT'
+import type { InvoiceItemTemplate } from '../types/invoice'
+import { formatIrtInput } from '../utils/irtPrice'
+import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
+import {
+ draftItemFromItemTemplate,
+ emptyDraftAccount,
+ emptyDraftItem,
+ emptyDraftKeyPoint,
+} from '../utils/invoiceDraft'
+import formStyles from './InvoiceForm.module.css'
+import styles from './InvoiceDraftFields.module.css'
+
+function focusKeyPointInput(index: number) {
+ const el = document.querySelector
(`input[data-keypoint-index="${index}"]`)
+ el?.focus()
+ el?.select()
+}
+
+type Props = {
+ itemTemplates: InvoiceItemTemplate[]
+ items: DraftLineItem[]
+ keyPoints: DraftKeyPoint[]
+ accounts: DraftAccount[]
+ selectedItemTemplateId: string
+ onSelectedItemTemplateId: (id: string) => void
+ onItemsChange: (items: DraftLineItem[]) => void
+ onKeyPointsChange: (points: DraftKeyPoint[]) => void
+ onAccountsChange: (accounts: DraftAccount[]) => void
+ showItemTemplatePicker?: boolean
+ /** Rendered after line items (before key points / accounts), e.g. totals row. */
+ afterItems?: ReactNode
+}
+
+export function InvoiceDraftFields({
+ itemTemplates,
+ items,
+ keyPoints,
+ accounts,
+ selectedItemTemplateId,
+ onSelectedItemTemplateId,
+ onItemsChange,
+ onKeyPointsChange,
+ onAccountsChange,
+ showItemTemplatePicker = true,
+ afterItems,
+}: Props) {
+ const t = useT()
+
+ function updateItem(key: string, patch: Partial) {
+ onItemsChange(items.map((item) => (item.key === key ? { ...item, ...patch } : item)))
+ }
+
+ function removeItem(key: string) {
+ if (items.length <= 1) return
+ onItemsChange(items.filter((item) => item.key !== key))
+ }
+
+ function addFromItemTemplate() {
+ const template = itemTemplates.find((entry) => entry.id === selectedItemTemplateId)
+ if (!template) return
+ const onlyEmpty =
+ items.length === 1 &&
+ !items[0].title.trim() &&
+ !items[0].price.trim() &&
+ !items[0].description.trim()
+ onItemsChange(onlyEmpty ? [draftItemFromItemTemplate(template)] : [...items, draftItemFromItemTemplate(template)])
+ onSelectedItemTemplateId('')
+ }
+
+ return (
+ <>
+
+ {items.map((item, index) => (
+
+
+ {t('invoiceDraft.itemLabel', { index: index + 1 })}
+
+
+
+
+ ))}
+
+
+
+ {showItemTemplatePicker ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {afterItems}
+
+
+
+
{t('invoiceDraft.keyPoints')}
+
+
+ {keyPoints.length === 0 ? (
+
{t('invoiceDraft.noKeyPoints')}
+ ) : (
+
+ {keyPoints.map((point, index) => (
+
+
+
+ onKeyPointsChange(
+ keyPoints.map((p) =>
+ p.key === point.key ? { ...p, text: e.target.value } : p,
+ ),
+ )
+ }
+ onKeyDown={(e) => {
+ if (e.key !== 'Enter') return
+ e.preventDefault()
+ const next = index + 1
+ if (next < keyPoints.length) {
+ focusKeyPointInput(next)
+ return
+ }
+ onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])
+ window.setTimeout(() => focusKeyPointInput(next), 0)
+ }}
+ placeholder={t('invoiceDraft.keyPointPlaceholder')}
+ aria-label={t('invoiceDraft.keyPointAria', { index: index + 1 })}
+ />
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
{t('invoiceDraft.accounts')}
+
+
+ {accounts.length === 0 ? (
+
{t('invoiceDraft.noAccounts')}
+ ) : (
+
+ {accounts.map((acc, index) => {
+ const showLabels = index === 0
+ return (
+
+
+ {showLabels ? : null}
+
+ onAccountsChange(
+ accounts.map((a) =>
+ a.key === acc.key ? { ...a, bankName: e.target.value } : a,
+ ),
+ )
+ }
+ placeholder={t('invoiceDraft.bankName')}
+ aria-label={t('invoiceDraft.bankName')}
+ />
+
+
+ {showLabels ? : null}
+
+ onAccountsChange(
+ accounts.map((a) =>
+ a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
+ ),
+ )
+ }
+ placeholder={t('invoiceDraft.accountHolder')}
+ aria-label={t('invoiceDraft.accountHolder')}
+ />
+
+
+ {showLabels ? : null}
+
+ onAccountsChange(
+ accounts.map((a) =>
+ a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
+ ),
+ )
+ }
+ placeholder={t('invoiceDraft.optional')}
+ aria-label={t('invoiceDraft.cardNumber')}
+ />
+
+
+ {showLabels ? : null}
+
+ onAccountsChange(
+ accounts.map((a) =>
+ a.key === acc.key ? { ...a, iban: e.target.value } : a,
+ ),
+ )
+ }
+ placeholder={t('invoiceDraft.optional')}
+ aria-label={t('invoiceDraft.iban')}
+ />
+
+
+
+ )
+ })}
+
+ )}
+
+ >
+ )
+}
diff --git a/apps/business/src/components/InvoiceForm.module.css b/apps/business/src/components/InvoiceForm.module.css
new file mode 100644
index 0000000..d63513d
--- /dev/null
+++ b/apps/business/src/components/InvoiceForm.module.css
@@ -0,0 +1,227 @@
+/* Shared primitives (field/button/layout) reused across invoice pages & modals. */
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.field label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.field input,
+.field textarea,
+.field select {
+ width: 100%;
+ padding: var(--field-padding-y) var(--field-padding-x);
+ font-size: var(--field-font-size);
+ line-height: 1.4;
+ font-family: var(--font-ui);
+ 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);
+ outline: none;
+ transition: border-color 0.2s, box-shadow 0.2s;
+ box-sizing: border-box;
+}
+
+.field input,
+.field select {
+ min-height: var(--field-height);
+}
+
+.field select {
+ appearance: none;
+ -webkit-appearance: none;
+ padding-right: var(--select-padding-end);
+ 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;
+}
+
+:global([dir='rtl']) .field select {
+ padding-right: var(--field-padding-x);
+ padding-left: var(--select-padding-end);
+ background-position: left var(--select-arrow-offset) center;
+}
+
+.field textarea {
+ min-height: calc(var(--field-height) + 8px);
+ resize: vertical;
+}
+
+.field input:focus,
+.field textarea:focus,
+.field select:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
+}
+
+.span2 {
+ grid-column: span 2;
+}
+
+.fullField {
+ margin-bottom: 12px;
+}
+
+.metaGrid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.metaGridTwo {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.formGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.actionsRow {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.totalRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin: 14px 0 4px;
+ padding: 12px 14px;
+ border-radius: var(--radius-sm);
+ background: rgba(var(--primary-rgb) / 0.08);
+ border: 1px solid rgba(var(--primary-rgb) / 0.2);
+}
+
+.totalLabel {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.totalValue {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 9px 18px;
+ font-size: 13px;
+ font-weight: 600;
+ border-radius: var(--radius-sm);
+ transition: transform 0.15s, box-shadow 0.2s, background 0.2s, color 0.2s;
+ white-space: nowrap;
+}
+
+.btnPrimary {
+ color: #fff;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
+ box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.3);
+}
+
+.btnPrimary:hover:not(:disabled) {
+ transform: translateY(-1px);
+}
+
+.btnPrimary:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.btnGhost {
+ color: var(--text-secondary);
+ background: rgba(148, 163, 184, 0.12);
+ border: 1px solid rgba(148, 163, 184, 0.25);
+}
+
+.btnGhost:hover:not(:disabled) {
+ background: rgba(148, 163, 184, 0.2);
+ color: var(--text-primary);
+}
+
+.btnGhost:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.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;
+}
+
+.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;
+}
+
+.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);
+}
+
+.sectionSpaced {
+ margin-top: 16px;
+}
+
+@media (max-width: 900px) {
+ .metaGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .metaGridTwo {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 720px) {
+ .formGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .span2 {
+ grid-column: span 1;
+ }
+}
diff --git a/apps/business/src/components/ListFiltersPanel.module.css b/apps/business/src/components/ListFiltersPanel.module.css
index 8919f55..a6d7666 100644
--- a/apps/business/src/components/ListFiltersPanel.module.css
+++ b/apps/business/src/components/ListFiltersPanel.module.css
@@ -47,6 +47,13 @@
gap: 6px;
}
+/* Remaining columns after filters; buttons at inline-end (physical left in RTL) */
+.filterActionsEnd {
+ grid-column: span 5;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
.filterSpacerCol4 {
grid-column: span 4;
min-width: 0;
@@ -207,7 +214,8 @@
justify-content: flex-end;
}
- .filterActionsCol1 {
+ .filterActionsCol1,
+ .filterActionsEnd {
grid-column: span 12;
justify-content: flex-end;
}
diff --git a/apps/business/src/components/Modal.module.css b/apps/business/src/components/Modal.module.css
new file mode 100644
index 0000000..bb6b330
--- /dev/null
+++ b/apps/business/src/components/Modal.module.css
@@ -0,0 +1,76 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: var(--modal-overlay-bg);
+ backdrop-filter: blur(var(--modal-overlay-blur));
+ -webkit-backdrop-filter: blur(var(--modal-overlay-blur));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 300;
+ padding: 20px;
+}
+
+.modal {
+ width: 100%;
+ max-width: 520px;
+ max-height: calc(100vh - 40px);
+ display: flex;
+ flex-direction: column;
+ background: rgba(255, 255, 255, 0.9);
+ backdrop-filter: blur(28px);
+ -webkit-backdrop-filter: blur(28px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius);
+ box-shadow: 0 20px 60px rgba(var(--primary-rgb) / 0.16);
+ overflow: hidden;
+ font-family: var(--font-ui);
+}
+
+.modalWide {
+ max-width: 640px;
+}
+
+.modalXl {
+ max-width: min(1180px, calc(100vw - 40px));
+}
+
+.header {
+ position: relative;
+ padding: 18px 18px 10px;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.2);
+ flex-shrink: 0;
+}
+
+.title {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-primary);
+ padding-inline-end: 36px;
+}
+
+.closeBtn {
+ position: absolute;
+ top: 14px;
+ inset-inline-end: 14px;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ color: var(--text-muted);
+ transition: background 0.2s, color 0.2s;
+}
+
+.closeBtn:hover {
+ background: rgba(var(--primary-rgb) / 0.08);
+ color: var(--primary);
+}
+
+.body {
+ padding: 18px;
+ overflow: auto;
+ flex: 1;
+ min-height: 0;
+}
diff --git a/apps/business/src/components/Modal.tsx b/apps/business/src/components/Modal.tsx
new file mode 100644
index 0000000..d397958
--- /dev/null
+++ b/apps/business/src/components/Modal.tsx
@@ -0,0 +1,65 @@
+import { useEffect } from 'react'
+import { createPortal } from 'react-dom'
+import { X } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
+import styles from './Modal.module.css'
+
+interface ModalProps {
+ open: boolean
+ title: string
+ children: React.ReactNode
+ onClose: () => void
+ closeLabel?: string
+ wide?: boolean
+ /** Extra-wide dialog for dense forms (e.g. invoice editor). */
+ xl?: boolean
+}
+
+export function Modal({ open, title, children, onClose, closeLabel = 'Close', wide, xl }: ModalProps) {
+ const { locale, dir } = useLocale()
+
+ useEffect(() => {
+ if (!open) return
+ 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.body.style.overflow = prevOverflow
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [open, onClose])
+
+ if (!open) return null
+
+ const sizeClass = xl ? styles.modalXl : wide ? styles.modalWide : ''
+
+ return createPortal(
+ {
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+
e.stopPropagation()}
+ lang={locale}
+ dir={dir}
+ >
+
+
{title}
+
+
+
{children}
+
+
,
+ document.body,
+ )
+}
diff --git a/apps/business/src/components/ProductActivityChart.module.css b/apps/business/src/components/ProductActivityChart.module.css
index 983ec6b..13df7a5 100644
--- a/apps/business/src/components/ProductActivityChart.module.css
+++ b/apps/business/src/components/ProductActivityChart.module.css
@@ -124,6 +124,8 @@
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
+ font-family: var(--font-ui);
+ text-align: center;
}
@media (max-width: 768px) {
diff --git a/apps/business/src/components/RichTextEditor.tsx b/apps/business/src/components/RichTextEditor.tsx
index 8d728a0..fdd63f7 100644
--- a/apps/business/src/components/RichTextEditor.tsx
+++ b/apps/business/src/components/RichTextEditor.tsx
@@ -352,3 +352,18 @@ export function RichTextEditor({
)
}
+
+/** Strip tags for single-line list previews. */
+export function richTextToPlain(html: string | null | undefined): string {
+ if (!html) return ''
+ return html
+ .replace(/
/gi, ' ')
+ .replace(/ /gi, ' ')
+ .replace(/<[^>]+>/g, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+export function isEmptyRichText(html: string | null | undefined): boolean {
+ return richTextToPlain(html).length === 0
+}
diff --git a/apps/business/src/components/Sidebar.tsx b/apps/business/src/components/Sidebar.tsx
index ffb9bf5..d09e1bb 100644
--- a/apps/business/src/components/Sidebar.tsx
+++ b/apps/business/src/components/Sidebar.tsx
@@ -15,6 +15,7 @@ import {
Building2,
Pencil,
Package,
+ Wallet,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
@@ -61,8 +62,13 @@ interface NavLinkItem {
type NavItem = NavLinkItem | NavGroup
-function isGroupActive(basePath: string, pathname: string) {
- return pathname === basePath || pathname.startsWith(`${basePath}/`)
+function isGroupActive(group: NavGroup, pathname: string) {
+ if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
+ return true
+ }
+ return group.children.some(
+ (child) => pathname === child.to || pathname.startsWith(`${child.to}/`),
+ )
}
export function Sidebar() {
@@ -130,6 +136,18 @@ export function Sidebar() {
],
},
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
+ {
+ type: 'group',
+ id: 'finance',
+ icon: Wallet,
+ labelKey: 'nav.finance',
+ basePath: '/finance',
+ children: [
+ { labelKey: 'nav.finance.overview', to: '/finance' },
+ { labelKey: 'nav.finance.invoices', to: '/invoices' },
+ { labelKey: 'nav.finance.transactions', to: '/transactions' },
+ ],
+ },
{
type: 'link',
id: 'customer-products',
@@ -227,7 +245,7 @@ export function Sidebar() {
useEffect(() => {
const activeGroup = navItems.find(
- (item) => item.type === 'group' && isGroupActive(item.basePath, pathname),
+ (item) => item.type === 'group' && isGroupActive(item, pathname),
)
if (!activeGroup || activeGroup.type !== 'group') return
setOpenGroups({ [activeGroup.id]: true })
@@ -285,7 +303,7 @@ export function Sidebar() {
}
const isOpen = openGroups[item.id] ?? false
- const groupActive = isGroupActive(item.basePath, pathname)
+ const groupActive = isGroupActive(item, pathname)
return (
diff --git a/apps/business/src/context/BusinessThemeContext.tsx b/apps/business/src/context/BusinessThemeContext.tsx
index 2a83766..eceed5c 100644
--- a/apps/business/src/context/BusinessThemeContext.tsx
+++ b/apps/business/src/context/BusinessThemeContext.tsx
@@ -2,9 +2,13 @@ import { useEffect, type ReactNode } from 'react'
import { isAbortError } from '../lib/api'
import { getBusinessDomain } from '../lib/config'
import { resolveTenantByDomain } from '../services/tenantService'
-import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
+import { applyBusinessPrimaryColor } from '../utils/applyBusinessTheme'
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
+/**
+ * Applies the tenant primary color. Does not reset to blue on unmount —
+ * Strict Mode / HMR remounts were wiping the brand color back to the default.
+ */
export function BusinessThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController()
@@ -27,7 +31,6 @@ export function BusinessThemeProvider({ children }: { children: ReactNode }) {
return () => {
controller.abort()
- resetBusinessPrimaryColor()
}
}, [])
diff --git a/apps/business/src/context/TenantBrandingContext.tsx b/apps/business/src/context/TenantBrandingContext.tsx
index 938d7e4..6b342a5 100644
--- a/apps/business/src/context/TenantBrandingContext.tsx
+++ b/apps/business/src/context/TenantBrandingContext.tsx
@@ -15,6 +15,8 @@ import { getBusinessDomain } from '../lib/config'
import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext'
import { getBusinessProfile } from '../services/businessProfileService'
import { resolveTenantByDomain } from '../services/tenantService'
+import { applyBusinessPrimaryColor } from '../utils/applyBusinessTheme'
+import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import {
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
@@ -114,6 +116,9 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setEnabledModules(normalizeEnabledBusinessModules(tenant.enabledModules))
setHomeCharts(normalizeHomeCharts(tenant.homeCharts))
applyDocumentFavicon(nextFavicon)
+ applyBusinessPrimaryColor(
+ normalizeBusinessPrimaryColorId(tenant.primaryColor),
+ )
if (!defaultLocaleAppliedRef.current) {
defaultLocaleAppliedRef.current = true
if (tenant.defaultLocale === 'en' || tenant.defaultLocale === 'fa') {
@@ -133,6 +138,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setEnabledModules([...DEFAULT_ENABLED_BUSINESS_MODULES])
setHomeCharts([...DEFAULT_HOME_CHARTS])
applyDocumentFavicon(null)
+ applyBusinessPrimaryColor(undefined)
}
}
diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts
index f0fb8bd..19caca9 100644
--- a/apps/business/src/i18n/messages.ts
+++ b/apps/business/src/i18n/messages.ts
@@ -30,6 +30,13 @@ const en = {
'nav.store.settings': 'Settings',
'nav.customers': 'Users',
'nav.customerProducts': 'Customer products',
+ 'nav.invoices': 'Invoices',
+ 'nav.invoices.list': 'Invoices',
+ 'nav.invoices.templates': 'Templates',
+ 'nav.finance': 'Finance Management',
+ 'nav.finance.overview': 'Overview',
+ 'nav.finance.invoices': 'Invoices',
+ 'nav.finance.transactions': 'Transactions',
'nav.settings': 'Settings',
'nav.blog': 'Blog',
'nav.blog.overview': 'Overview',
@@ -177,6 +184,9 @@ const en = {
'home.card.website.title': 'Website',
'home.card.website.desc': 'Manage contact forms, FAQ, badges, subscriptions, and e-payment.',
'home.card.website.link': 'View website',
+ 'home.card.finance.title': 'Finance Management',
+ 'home.card.finance.desc': 'Manage invoices and financial transactions.',
+ 'home.card.finance.link': 'View finance',
'home.chart.orders.title': 'Orders',
'home.chart.orders.subtitle': 'Orders and cart adds in the last 30 days',
@@ -200,8 +210,8 @@ const en = {
'home.chart.customersJoined.activeLegend': 'Active ({count})',
'home.chart.customersJoined.loading': 'Loading chart...',
'home.chart.customersJoined.error': 'Unable to load customer activity.',
- 'home.chart.customersJoined.bar': '{day}: {count} registered',
- 'home.chart.customersJoined.activeBar': '{day}: {count} active',
+ 'home.chart.customersJoined.bar': '{month}: {count} registered',
+ 'home.chart.customersJoined.activeBar': '{month}: {count} active',
'home.chart.blogViews.title': 'Article views',
'home.chart.blogViews.subtitle': 'Blog article views in the last 30 days',
'home.chart.placeholder.title': 'Chart',
@@ -802,6 +812,7 @@ const en = {
'customers.edit': 'Edit customer',
'customers.sms': 'Send SMS',
'customers.tickets': 'Tickets',
+ 'customers.invoices': 'View invoices',
'customers.remove': 'Remove customer',
'customers.add': 'Add customer',
'customers.error.load': 'Unable to load customers.',
@@ -1290,6 +1301,222 @@ const en = {
'title.faq': 'FAQ',
'title.badges': 'Badges',
'title.ePayment': 'E-Payment',
+ 'title.invoices': 'Invoices',
+ 'title.finance': 'Finance Management',
+ 'title.issueInvoice': 'Issue Invoice',
+ 'title.editInvoice': 'Edit Invoice',
+ 'title.invoiceTemplates': 'Invoice Templates',
+ 'title.addInvoiceTemplate': 'Add Invoice Template',
+ 'title.editInvoiceTemplate': 'Edit Invoice Template',
+ 'title.transactions': 'Transactions',
+
+ 'finance.page.subtitle': 'Manage invoices and financial transactions.',
+ 'finance.card.invoices.desc': 'Issue and track invoices for your customers.',
+ 'finance.card.transactions.desc': 'View payment and financial transactions.',
+
+ 'transactions.subtitle': 'View payment and financial transactions.',
+ 'transactions.lead': 'Transactions will appear here.',
+ 'transactions.note': 'This section is coming soon. You can still review order payment details from Orders.',
+
+ 'invoices.subtitle': 'Manage invoices issued to your customers.',
+ 'invoices.templatesLink': 'Template management',
+ 'invoices.issue': 'Issue invoice',
+ 'invoices.tableTitle': 'Invoices',
+ 'invoices.countOne': '{count} invoice',
+ 'invoices.countMany': '{count} invoices',
+ 'invoices.loading': 'Loading…',
+ 'invoices.empty': 'No invoices yet.',
+ 'invoices.col.name': 'Name',
+ 'invoices.col.customer': 'Customer',
+ 'invoices.col.issued': 'Issued',
+ 'invoices.col.status': 'Status',
+ 'invoices.col.total': 'Total',
+ 'invoices.col.link': 'Link',
+ 'invoices.col.actions': 'Actions',
+ 'invoices.itemCountOne': '{count} item',
+ 'invoices.itemCountMany': '{count} items',
+ 'invoices.changeStatus': 'Change status',
+ 'invoices.changeStatusAria': 'Change status ({status})',
+ 'invoices.view': 'View',
+ 'invoices.viewAria': 'View invoice',
+ 'invoices.edit': 'Edit',
+ 'invoices.editAria': 'Edit invoice',
+ 'invoices.editDisabledTitle': 'Approved invoices cannot be edited',
+ 'invoices.editDisabledAria': 'Edit invoice (disabled — approved)',
+ 'invoices.remove': 'Remove',
+ 'invoices.removeAria': 'Remove invoice',
+ 'invoices.copyLink': 'Copy link',
+ 'invoices.copyLinkAria': 'Copy invoice link',
+ 'invoices.linkCopied': 'Invoice link copied.',
+ 'invoices.linkCopyError': 'Unable to copy link.',
+ 'invoices.deleteTitle': 'Remove invoice?',
+ 'invoices.deleteMessage': 'Remove this invoice permanently? This cannot be undone.',
+ 'invoices.removed': 'Invoice removed.',
+ 'invoices.removeError': 'Unable to remove invoice.',
+ 'invoices.statusUpdated': 'Invoice status updated.',
+ 'invoices.statusUpdateError': 'Unable to update status.',
+ 'invoices.loadError': 'Unable to load invoices.',
+ 'invoices.filterBannerNamed': "{name}'s invoices",
+ 'invoices.filterBannerGeneric': "This customer's invoices",
+ 'invoices.filterCancel': 'Cancel',
+ 'invoices.filterClear': 'Clear filter',
+ 'invoices.issueForCustomer': 'Issue invoice for this customer',
+ 'invoices.detailFallbackTitle': 'Invoice',
+ 'invoices.detail.status': 'Status',
+ 'invoices.detail.total': 'Total',
+ 'invoices.detail.link': 'Public link',
+ 'invoices.detail.billedTo': 'Billed to',
+ 'invoices.detail.notes': 'Notes: {notes}',
+ 'invoices.status.draft': 'Draft',
+ 'invoices.status.issued': 'Issued',
+ 'invoices.status.approved': 'Approved',
+ 'invoices.status.paid': 'Paid',
+ 'invoices.status.cancelled': 'Cancelled',
+
+ 'issueInvoice.subtitleNew': 'Select an invoice template and edit it, or create a blank invoice.',
+ 'issueInvoice.subtitleEdit': 'Update invoice content. Approved invoices cannot be changed.',
+ 'issueInvoice.startFromTemplate': 'Start from template',
+ 'issueInvoice.blankInvoice': 'Without template',
+ 'issueInvoice.name': 'Name (optional)',
+ 'issueInvoice.namePlaceholder': 'e.g. Website redesign package',
+ 'issueInvoice.noTemplatesHint': 'No invoice templates yet. Manage them in {link}, or fill a blank invoice below.',
+ 'issueInvoice.noTemplatesLink': 'Invoice Templates',
+ 'issueInvoice.topText': 'Top text',
+ 'issueInvoice.topTextPlaceholder': 'Optional intro text at the top of the invoice',
+ 'issueInvoice.notes': 'Notes',
+ 'issueInvoice.notesPlaceholder': 'Optional internal notes',
+ 'issueInvoice.total': 'Total: {total}',
+ 'issueInvoice.totalLabel': 'Total',
+ 'issueInvoice.cancel': 'Cancel',
+ 'issueInvoice.save': 'Save changes',
+ 'issueInvoice.saving': 'Saving…',
+ 'issueInvoice.issue': 'Issue invoice',
+ 'issueInvoice.issuing': 'Issuing…',
+ 'issueInvoice.backToInvoices': 'Back to invoices',
+ 'issueInvoice.approvedLocked': 'This invoice is approved and can no longer be edited.',
+ 'issueInvoice.loadError': 'Unable to load invoice form.',
+ 'issueInvoice.updateError': 'Unable to update invoice.',
+ 'issueInvoice.createError': 'Unable to create invoice.',
+ 'issueInvoice.updated': 'Invoice updated.',
+ 'issueInvoice.issued': 'Invoice issued.',
+ 'issueInvoice.recipient': 'Issue invoice for',
+ 'issueInvoice.recipientFor': 'Issue invoice for {name}',
+ 'issueInvoice.recipientRequired': 'Select a customer to bill this invoice to.',
+ 'issueInvoice.pickCustomer': 'Select customer',
+ 'issueInvoice.pickCustomerPlaceholder': 'Search by name or cell number…',
+ 'issueInvoice.pickCustomerNoResults': 'No customers found.',
+ 'issueInvoice.pickCustomerHint': 'Type at least 2 characters to search.',
+ 'issueInvoice.changeCustomer': 'Change',
+ 'issueInvoice.unknownCustomer': 'Customer',
+ 'issueInvoice.loading': 'Loading…',
+
+ 'invoiceDraft.itemLabel': 'Item {index}',
+ 'invoiceDraft.removeItem': 'Remove item',
+ 'invoiceDraft.fieldTitle': 'Title',
+ 'invoiceDraft.fieldTitlePlaceholder': 'Service title',
+ 'invoiceDraft.fieldDuration': 'Duration',
+ 'invoiceDraft.fieldDurationPlaceholder': 'e.g. 3 months',
+ 'invoiceDraft.fieldWorktime': 'Worktime',
+ 'invoiceDraft.fieldWorktimePlaceholder': 'e.g. 40 hours',
+ 'invoiceDraft.fieldPrice': 'Price (IRT)',
+ 'invoiceDraft.fieldDiscountedPrice': 'Discounted price (IRT)',
+ 'invoiceDraft.fieldDiscountedPricePlaceholder': 'Optional',
+ 'invoiceDraft.optional': 'Optional',
+ 'invoiceDraft.fieldDescription': 'Description',
+ 'invoiceDraft.fieldDescriptionPlaceholder': 'Optional details',
+ 'invoiceDraft.addFromTemplate': 'Add from template…',
+ 'invoiceDraft.addFromTemplateAria': 'Add from item template',
+ 'invoiceDraft.add': 'Add',
+ 'invoiceDraft.addCustomItem': 'Add custom item',
+ 'invoiceDraft.keyPoints': 'Key points',
+ 'invoiceDraft.addPoint': 'Add point',
+ 'invoiceDraft.noKeyPoints': 'No key points yet.',
+ 'invoiceDraft.keyPointPlaceholder': 'e.g. Payment due within 7 days',
+ 'invoiceDraft.keyPointAria': 'Key point {index}',
+ 'invoiceDraft.removeKeyPoint': 'Remove key point',
+ 'invoiceDraft.accounts': 'Account numbers',
+ 'invoiceDraft.addAccount': 'Add account',
+ 'invoiceDraft.noAccounts': 'No bank accounts yet.',
+ 'invoiceDraft.bankName': 'Bank name',
+ 'invoiceDraft.accountHolder': 'Account holder',
+ 'invoiceDraft.cardNumber': 'Card number',
+ 'invoiceDraft.iban': 'IBAN',
+ 'invoiceDraft.removeAccount': 'Remove account',
+ 'invoiceDraft.error.itemTitle': 'Each item needs a title.',
+ 'invoiceDraft.error.itemPrice': 'Price is required for "{title}".',
+ 'invoiceDraft.error.itemDiscountInvalid': 'Discounted price is invalid for "{title}".',
+ 'invoiceDraft.error.itemDiscountExceeds': 'Discounted price cannot exceed price for "{title}".',
+
+ 'invoiceTemplates.subtitle': 'Manage reusable invoice templates and predefined line items.',
+ 'invoiceTemplates.section.templates.title': 'Invoice templates',
+ 'invoiceTemplates.section.templates.desc':
+ 'Full blueprints for faster issuing — pick one and edit, or start blank.',
+ 'invoiceTemplates.addTemplate': 'Add template',
+ 'invoiceTemplates.templatesTableTitle': 'Templates',
+ 'invoiceTemplates.countTemplatesOne': '{count} template',
+ 'invoiceTemplates.countTemplatesMany': '{count} templates',
+ 'invoiceTemplates.col.name': 'Name',
+ 'invoiceTemplates.col.items': 'Items',
+ 'invoiceTemplates.col.keyPoints': 'Key points',
+ 'invoiceTemplates.col.accounts': 'Accounts',
+ 'invoiceTemplates.col.actions': 'Actions',
+ 'invoiceTemplates.emptyTemplates': 'No invoice templates yet. Add one to speed up issuing invoices.',
+ 'invoiceTemplates.section.items.title': 'Invoice item templates',
+ 'invoiceTemplates.section.items.desc':
+ 'Reusable line items you can drop into invoice templates or when issuing an invoice.',
+ 'invoiceTemplates.addItem': 'Add item',
+ 'invoiceTemplates.itemsTableTitle': 'Predefined line items',
+ 'invoiceTemplates.countItemsOne': '{count} item',
+ 'invoiceTemplates.countItemsMany': '{count} items',
+ 'invoiceTemplates.col.title': 'Title',
+ 'invoiceTemplates.col.duration': 'Duration',
+ 'invoiceTemplates.col.worktime': 'Worktime',
+ 'invoiceTemplates.col.price': 'Price',
+ 'invoiceTemplates.col.discounted': 'Discounted',
+ 'invoiceTemplates.emptyItems': 'No predefined items yet.',
+ 'invoiceTemplates.editTemplate': 'Edit',
+ 'invoiceTemplates.removeTemplate': 'Remove',
+ 'invoiceTemplates.editItem': 'Edit',
+ 'invoiceTemplates.removeItem': 'Remove',
+ 'invoiceTemplates.deleteTemplateTitle': 'Remove invoice template',
+ 'invoiceTemplates.deleteTemplateMessage': 'Remove "{name}" from invoice templates?',
+ 'invoiceTemplates.deleteItemTitle': 'Remove item template',
+ 'invoiceTemplates.deleteItemMessage': 'Remove "{title}" from predefined line items?',
+ 'invoiceTemplates.itemModalAddTitle': 'Add item template',
+ 'invoiceTemplates.itemModalEditTitle': 'Edit item template',
+ 'invoiceTemplates.itemUpdated': 'Item template updated.',
+ 'invoiceTemplates.itemCreated': 'Item template created.',
+ 'invoiceTemplates.itemRemoved': 'Item template removed.',
+ 'invoiceTemplates.templateRemoved': 'Invoice template removed.',
+ 'invoiceTemplates.loadError': 'Unable to load invoice settings.',
+ 'invoiceTemplates.itemSaveError': 'Unable to save item template.',
+ 'invoiceTemplates.itemRemoveError': 'Unable to remove item.',
+ 'invoiceTemplates.templateRemoveError': 'Unable to remove template.',
+ 'invoiceTemplates.saveItem': 'Save changes',
+ 'invoiceTemplates.savingItem': 'Saving…',
+ 'invoiceTemplates.createItem': 'Create item',
+ 'invoiceTemplates.cancel': 'Cancel',
+ 'invoiceTemplates.hint': 'Tip: issue an invoice from the Invoices page using a template or from scratch.',
+
+ 'invoiceTemplateEditor.addTitle': 'Add invoice template',
+ 'invoiceTemplateEditor.editTitle': 'Edit invoice template',
+ 'invoiceTemplateEditor.subtitle':
+ 'Define name, top text, line items, key points, and bank accounts for reuse when issuing invoices.',
+ 'invoiceTemplateEditor.name': 'Name',
+ 'invoiceTemplateEditor.namePlaceholder': 'e.g. Standard website package',
+ 'invoiceTemplateEditor.topText': 'Top text',
+ 'invoiceTemplateEditor.topTextPlaceholder': 'Optional intro shown at the top of the invoice',
+ 'invoiceTemplateEditor.nameRequired': 'Template name is required.',
+ 'invoiceTemplateEditor.loadError': 'Unable to load template editor.',
+ 'invoiceTemplateEditor.saveError': 'Unable to save invoice template.',
+ 'invoiceTemplateEditor.updated': 'Invoice template updated.',
+ 'invoiceTemplateEditor.created': 'Invoice template created.',
+ 'invoiceTemplateEditor.backToTemplates': 'Back to templates',
+ 'invoiceTemplateEditor.create': 'Create template',
+ 'invoiceTemplateEditor.save': 'Save changes',
+ 'invoiceTemplateEditor.saving': 'Saving…',
+ 'invoiceTemplateEditor.cancel': 'Cancel',
+ 'invoiceTemplateEditor.loading': 'Loading…',
'login.welcome': 'Welcome back',
'login.subtitle': 'Sign in with your mobile number',
@@ -1406,6 +1633,13 @@ const fa: Record = {
'nav.store.settings': 'تنظیمات',
'nav.customers': 'کاربران',
'nav.customerProducts': 'محصولات مشتریان',
+ 'nav.invoices': 'فاکتورها',
+ 'nav.invoices.list': 'فاکتورها',
+ 'nav.invoices.templates': 'قالبها',
+ 'nav.finance': 'مدیریت مالی',
+ 'nav.finance.overview': 'نمای کلی',
+ 'nav.finance.invoices': 'فاکتورها',
+ 'nav.finance.transactions': 'تراکنشها',
'nav.settings': 'تنظیمات',
'nav.blog': 'بلاگ',
'nav.blog.overview': 'نمای کلی',
@@ -1553,6 +1787,9 @@ const fa: Record = {
'home.card.website.title': 'وبسایت',
'home.card.website.desc': 'فرم تماس، سوالات متداول، نشانها، عضویتها و پرداخت الکترونیک.',
'home.card.website.link': 'مشاهده وبسایت',
+ 'home.card.finance.title': 'مدیریت مالی',
+ 'home.card.finance.desc': 'فاکتورها و تراکنشهای مالی را مدیریت کنید.',
+ 'home.card.finance.link': 'مشاهده مالی',
'home.chart.orders.title': 'سفارشها',
'home.chart.orders.subtitle': 'سفارشها در ۳۰ روز گذشته',
@@ -1576,8 +1813,8 @@ const fa: Record = {
'home.chart.customersJoined.activeLegend': 'فعال ({count})',
'home.chart.customersJoined.loading': 'در حال بارگذاری نمودار...',
'home.chart.customersJoined.error': 'بارگذاری فعالیت مشتریان ممکن نشد.',
- 'home.chart.customersJoined.bar': '{day}: {count} ثبتنام',
- 'home.chart.customersJoined.activeBar': '{day}: {count} فعال',
+ 'home.chart.customersJoined.bar': '{month}: {count} ثبتنام',
+ 'home.chart.customersJoined.activeBar': '{month}: {count} فعال',
'home.chart.blogViews.title': 'بازدید مقالات',
'home.chart.blogViews.subtitle': 'بازدید مقالات در ۳۰ روز گذشته',
'home.chart.placeholder.title': 'نمودار',
@@ -2176,6 +2413,7 @@ const fa: Record = {
'customers.edit': 'ویرایش مشتری',
'customers.sms': 'ارسال پیامک',
'customers.tickets': 'تیکتها',
+ 'customers.invoices': 'مشاهده فاکتورها',
'customers.remove': 'حذف مشتری',
'customers.add': 'افزودن مشتری',
'customers.error.load': 'بارگذاری مشتریان ممکن نشد.',
@@ -2663,6 +2901,222 @@ const fa: Record = {
'title.faq': 'سوالات متداول',
'title.badges': 'نشانها',
'title.ePayment': 'پرداخت الکترونیک',
+ 'title.invoices': 'فاکتورها',
+ 'title.finance': 'مدیریت مالی',
+ 'title.issueInvoice': 'صدور فاکتور',
+ 'title.editInvoice': 'ویرایش فاکتور',
+ 'title.invoiceTemplates': 'قالبهای فاکتور',
+ 'title.addInvoiceTemplate': 'افزودن قالب فاکتور',
+ 'title.editInvoiceTemplate': 'ویرایش قالب فاکتور',
+ 'title.transactions': 'تراکنشها',
+
+ 'finance.page.subtitle': 'فاکتورها و تراکنشهای مالی را مدیریت کنید.',
+ 'finance.card.invoices.desc': 'صدور و پیگیری فاکتور برای مشتریان.',
+ 'finance.card.transactions.desc': 'مشاهده تراکنشهای پرداختی و مالی.',
+
+ 'transactions.subtitle': 'تراکنشهای پرداختی و مالی را مشاهده کنید.',
+ 'transactions.lead': 'تراکنشها اینجا نمایش داده میشوند.',
+ 'transactions.note': 'این بخش بهزودی آماده میشود. فعلاً میتوانید جزئیات پرداخت سفارش را از صفحه سفارشها ببینید.',
+
+ 'invoices.subtitle': 'فاکتورهای صادر شده برای مشتریان خود را مدیریت کنید.',
+ 'invoices.templatesLink': 'مدیریت قالب ها',
+ 'invoices.issue': 'صدور فاکتور',
+ 'invoices.tableTitle': 'فاکتورها',
+ 'invoices.countOne': '{count} فاکتور',
+ 'invoices.countMany': '{count} فاکتور',
+ 'invoices.loading': 'در حال بارگذاری...',
+ 'invoices.empty': 'هنوز فاکتوری ثبت نشده است.',
+ 'invoices.col.name': 'نام',
+ 'invoices.col.customer': 'مشتری',
+ 'invoices.col.issued': 'تاریخ صدور',
+ 'invoices.col.status': 'وضعیت',
+ 'invoices.col.total': 'مبلغ کل',
+ 'invoices.col.link': 'لینک',
+ 'invoices.col.actions': 'عملیات',
+ 'invoices.itemCountOne': '{count} آیتم',
+ 'invoices.itemCountMany': '{count} آیتم',
+ 'invoices.changeStatus': 'تغییر وضعیت',
+ 'invoices.changeStatusAria': 'تغییر وضعیت ({status})',
+ 'invoices.view': 'مشاهده',
+ 'invoices.viewAria': 'مشاهده فاکتور',
+ 'invoices.edit': 'ویرایش',
+ 'invoices.editAria': 'ویرایش فاکتور',
+ 'invoices.editDisabledTitle': 'فاکتورهای تاییدشده قابل ویرایش نیستند',
+ 'invoices.editDisabledAria': 'ویرایش فاکتور (غیرفعال — تاییدشده)',
+ 'invoices.remove': 'حذف',
+ 'invoices.removeAria': 'حذف فاکتور',
+ 'invoices.copyLink': 'کپی لینک',
+ 'invoices.copyLinkAria': 'کپی لینک فاکتور',
+ 'invoices.linkCopied': 'لینک فاکتور کپی شد.',
+ 'invoices.linkCopyError': 'کپی لینک ممکن نشد.',
+ 'invoices.deleteTitle': 'حذف فاکتور؟',
+ 'invoices.deleteMessage': 'این فاکتور برای همیشه حذف شود؟ این عملیات قابل بازگشت نیست.',
+ 'invoices.removed': 'فاکتور حذف شد.',
+ 'invoices.removeError': 'حذف فاکتور ممکن نشد.',
+ 'invoices.statusUpdated': 'وضعیت فاکتور بهروزرسانی شد.',
+ 'invoices.statusUpdateError': 'بهروزرسانی وضعیت ممکن نشد.',
+ 'invoices.loadError': 'بارگذاری فاکتورها ممکن نشد.',
+ 'invoices.filterBannerNamed': 'فاکتور های {name}',
+ 'invoices.filterBannerGeneric': 'فاکتور های این مشتری',
+ 'invoices.filterCancel': 'انصراف',
+ 'invoices.filterClear': 'پاک کردن فیلتر',
+ 'invoices.issueForCustomer': 'صدور فاکتور برای این مشتری',
+ 'invoices.detailFallbackTitle': 'فاکتور',
+ 'invoices.detail.status': 'وضعیت',
+ 'invoices.detail.total': 'مبلغ کل',
+ 'invoices.detail.link': 'لینک عمومی',
+ 'invoices.detail.billedTo': 'صادر شده برای',
+ 'invoices.detail.notes': 'یادداشت: {notes}',
+ 'invoices.status.draft': 'پیشنویس',
+ 'invoices.status.issued': 'صادرشده',
+ 'invoices.status.approved': 'تاییدشده',
+ 'invoices.status.paid': 'پرداختشده',
+ 'invoices.status.cancelled': 'لغوشده',
+
+ 'issueInvoice.subtitleNew': 'یک قالب فاکتور انتخاب و ویرایش کنید یا یک فاکتور خالی بسازید.',
+ 'issueInvoice.subtitleEdit': 'محتوای فاکتور را بهروزرسانی کنید. فاکتورهای تاییدشده قابل تغییر نیستند.',
+ 'issueInvoice.startFromTemplate': 'شروع از قالب',
+ 'issueInvoice.blankInvoice': 'بدون قالب',
+ 'issueInvoice.name': 'نام (اختیاری)',
+ 'issueInvoice.namePlaceholder': 'مثلاً بسته طراحی سایت',
+ 'issueInvoice.noTemplatesHint': 'هنوز قالب فاکتوری ندارید. آنها را در {link} مدیریت کنید یا فاکتور خالی زیر را تکمیل کنید.',
+ 'issueInvoice.noTemplatesLink': 'قالبهای فاکتور',
+ 'issueInvoice.topText': 'متن بالای فاکتور',
+ 'issueInvoice.topTextPlaceholder': 'متن مقدماتی اختیاری در بالای فاکتور',
+ 'issueInvoice.notes': 'یادداشتها',
+ 'issueInvoice.notesPlaceholder': 'یادداشت داخلی اختیاری',
+ 'issueInvoice.total': 'مبلغ کل: {total}',
+ 'issueInvoice.totalLabel': 'مبلغ کل',
+ 'issueInvoice.cancel': 'انصراف',
+ 'issueInvoice.save': 'ذخیره تغییرات',
+ 'issueInvoice.saving': 'در حال ذخیره...',
+ 'issueInvoice.issue': 'صدور فاکتور',
+ 'issueInvoice.issuing': 'در حال صدور...',
+ 'issueInvoice.backToInvoices': 'بازگشت به فاکتورها',
+ 'issueInvoice.approvedLocked': 'این فاکتور تاییدشده و دیگر قابل ویرایش نیست.',
+ 'issueInvoice.loadError': 'بارگذاری فرم فاکتور ممکن نشد.',
+ 'issueInvoice.updateError': 'بهروزرسانی فاکتور ممکن نشد.',
+ 'issueInvoice.createError': 'ایجاد فاکتور ممکن نشد.',
+ 'issueInvoice.updated': 'فاکتور بهروزرسانی شد.',
+ 'issueInvoice.issued': 'فاکتور صادر شد.',
+ 'issueInvoice.recipient': 'صدور فاکتور برای',
+ 'issueInvoice.recipientFor': 'صدور فاکتور برای {name}',
+ 'issueInvoice.recipientRequired': 'یک مشتری برای صدور این فاکتور انتخاب کنید.',
+ 'issueInvoice.pickCustomer': 'انتخاب مشتری',
+ 'issueInvoice.pickCustomerPlaceholder': 'جستجو بر اساس نام یا شماره تماس...',
+ 'issueInvoice.pickCustomerNoResults': 'مشتریای یافت نشد.',
+ 'issueInvoice.pickCustomerHint': 'برای جستجو حداقل ۲ کاراکتر وارد کنید.',
+ 'issueInvoice.changeCustomer': 'تغییر',
+ 'issueInvoice.unknownCustomer': 'مشتری',
+ 'issueInvoice.loading': 'در حال بارگذاری...',
+
+ 'invoiceDraft.itemLabel': 'آیتم {index}',
+ 'invoiceDraft.removeItem': 'حذف آیتم',
+ 'invoiceDraft.fieldTitle': 'عنوان',
+ 'invoiceDraft.fieldTitlePlaceholder': 'عنوان خدمت',
+ 'invoiceDraft.fieldDuration': 'مدت',
+ 'invoiceDraft.fieldDurationPlaceholder': 'مثلاً ۳ ماه',
+ 'invoiceDraft.fieldWorktime': 'ساعت کاری',
+ 'invoiceDraft.fieldWorktimePlaceholder': 'مثلاً ۴۰ ساعت',
+ 'invoiceDraft.fieldPrice': 'قیمت (تومان)',
+ 'invoiceDraft.fieldDiscountedPrice': 'قیمت با تخفیف (تومان)',
+ 'invoiceDraft.fieldDiscountedPricePlaceholder': 'اختیاری',
+ 'invoiceDraft.optional': 'اختیاری',
+ 'invoiceDraft.fieldDescription': 'توضیحات',
+ 'invoiceDraft.fieldDescriptionPlaceholder': 'جزئیات اختیاری',
+ 'invoiceDraft.addFromTemplate': 'افزودن از قالب...',
+ 'invoiceDraft.addFromTemplateAria': 'افزودن از قالب آیتم',
+ 'invoiceDraft.add': 'افزودن',
+ 'invoiceDraft.addCustomItem': 'افزودن آیتم سفارشی',
+ 'invoiceDraft.keyPoints': 'نکات کلیدی',
+ 'invoiceDraft.addPoint': 'افزودن نکته',
+ 'invoiceDraft.noKeyPoints': 'هنوز نکته کلیدیای ثبت نشده است.',
+ 'invoiceDraft.keyPointPlaceholder': 'مثلاً پرداخت ظرف ۷ روز',
+ 'invoiceDraft.keyPointAria': 'نکته کلیدی {index}',
+ 'invoiceDraft.removeKeyPoint': 'حذف نکته کلیدی',
+ 'invoiceDraft.accounts': 'شماره حسابها',
+ 'invoiceDraft.addAccount': 'افزودن حساب',
+ 'invoiceDraft.noAccounts': 'هنوز حساب بانکیای ثبت نشده است.',
+ 'invoiceDraft.bankName': 'نام بانک',
+ 'invoiceDraft.accountHolder': 'صاحب حساب',
+ 'invoiceDraft.cardNumber': 'شماره کارت',
+ 'invoiceDraft.iban': 'شبا',
+ 'invoiceDraft.removeAccount': 'حذف حساب',
+ 'invoiceDraft.error.itemTitle': 'هر آیتم به عنوان نیاز دارد.',
+ 'invoiceDraft.error.itemPrice': 'قیمت برای «{title}» الزامی است.',
+ 'invoiceDraft.error.itemDiscountInvalid': 'قیمت با تخفیف برای «{title}» نامعتبر است.',
+ 'invoiceDraft.error.itemDiscountExceeds': 'قیمت با تخفیف نمیتواند از قیمت اصلی «{title}» بیشتر باشد.',
+
+ 'invoiceTemplates.subtitle': 'قالبهای فاکتور و آیتمهای از پیش تعریفشده را مدیریت کنید.',
+ 'invoiceTemplates.section.templates.title': 'قالبهای فاکتور',
+ 'invoiceTemplates.section.templates.desc':
+ 'الگوهای کامل برای صدور سریعتر فاکتور — یکی را انتخاب کنید یا از خالی شروع کنید.',
+ 'invoiceTemplates.addTemplate': 'افزودن قالب',
+ 'invoiceTemplates.templatesTableTitle': 'قالبها',
+ 'invoiceTemplates.countTemplatesOne': '{count} قالب',
+ 'invoiceTemplates.countTemplatesMany': '{count} قالب',
+ 'invoiceTemplates.col.name': 'نام',
+ 'invoiceTemplates.col.items': 'آیتمها',
+ 'invoiceTemplates.col.keyPoints': 'نکات کلیدی',
+ 'invoiceTemplates.col.accounts': 'حسابها',
+ 'invoiceTemplates.col.actions': 'عملیات',
+ 'invoiceTemplates.emptyTemplates': 'هنوز قالب فاکتوری ندارید. یکی اضافه کنید تا صدور فاکتور سریعتر شود.',
+ 'invoiceTemplates.section.items.title': 'قالبهای آیتم فاکتور',
+ 'invoiceTemplates.section.items.desc':
+ 'آیتمهای قابل استفاده مجدد که میتوانید در قالبهای فاکتور یا هنگام صدور فاکتور استفاده کنید.',
+ 'invoiceTemplates.addItem': 'افزودن آیتم',
+ 'invoiceTemplates.itemsTableTitle': 'آیتمهای از پیش تعریفشده',
+ 'invoiceTemplates.countItemsOne': '{count} آیتم',
+ 'invoiceTemplates.countItemsMany': '{count} آیتم',
+ 'invoiceTemplates.col.title': 'عنوان',
+ 'invoiceTemplates.col.duration': 'مدت',
+ 'invoiceTemplates.col.worktime': 'ساعت کاری',
+ 'invoiceTemplates.col.price': 'قیمت',
+ 'invoiceTemplates.col.discounted': 'با تخفیف',
+ 'invoiceTemplates.emptyItems': 'هنوز آیتم از پیش تعریفشدهای ندارید.',
+ 'invoiceTemplates.editTemplate': 'ویرایش',
+ 'invoiceTemplates.removeTemplate': 'حذف',
+ 'invoiceTemplates.editItem': 'ویرایش',
+ 'invoiceTemplates.removeItem': 'حذف',
+ 'invoiceTemplates.deleteTemplateTitle': 'حذف قالب فاکتور',
+ 'invoiceTemplates.deleteTemplateMessage': '«{name}» از قالبهای فاکتور حذف شود؟',
+ 'invoiceTemplates.deleteItemTitle': 'حذف قالب آیتم',
+ 'invoiceTemplates.deleteItemMessage': '«{title}» از آیتمهای از پیش تعریفشده حذف شود؟',
+ 'invoiceTemplates.itemModalAddTitle': 'افزودن قالب آیتم',
+ 'invoiceTemplates.itemModalEditTitle': 'ویرایش قالب آیتم',
+ 'invoiceTemplates.itemUpdated': 'قالب آیتم بهروزرسانی شد.',
+ 'invoiceTemplates.itemCreated': 'قالب آیتم ایجاد شد.',
+ 'invoiceTemplates.itemRemoved': 'قالب آیتم حذف شد.',
+ 'invoiceTemplates.templateRemoved': 'قالب فاکتور حذف شد.',
+ 'invoiceTemplates.loadError': 'بارگذاری تنظیمات فاکتور ممکن نشد.',
+ 'invoiceTemplates.itemSaveError': 'ذخیره قالب آیتم ممکن نشد.',
+ 'invoiceTemplates.itemRemoveError': 'حذف آیتم ممکن نشد.',
+ 'invoiceTemplates.templateRemoveError': 'حذف قالب ممکن نشد.',
+ 'invoiceTemplates.saveItem': 'ذخیره تغییرات',
+ 'invoiceTemplates.savingItem': 'در حال ذخیره...',
+ 'invoiceTemplates.createItem': 'ایجاد آیتم',
+ 'invoiceTemplates.cancel': 'انصراف',
+ 'invoiceTemplates.hint': 'نکته: از صفحه فاکتورها با استفاده از یک قالب یا از ابتدا فاکتور صادر کنید.',
+
+ 'invoiceTemplateEditor.addTitle': 'افزودن قالب فاکتور',
+ 'invoiceTemplateEditor.editTitle': 'ویرایش قالب فاکتور',
+ 'invoiceTemplateEditor.subtitle':
+ 'نام، متن بالا، آیتمها، نکات کلیدی و حسابهای بانکی را برای استفاده مجدد هنگام صدور فاکتور تعریف کنید.',
+ 'invoiceTemplateEditor.name': 'نام',
+ 'invoiceTemplateEditor.namePlaceholder': 'مثلاً بسته استاندارد سایت',
+ 'invoiceTemplateEditor.topText': 'متن بالای فاکتور',
+ 'invoiceTemplateEditor.topTextPlaceholder': 'متن مقدماتی اختیاری در بالای فاکتور',
+ 'invoiceTemplateEditor.nameRequired': 'نام قالب الزامی است.',
+ 'invoiceTemplateEditor.loadError': 'بارگذاری ویرایشگر قالب ممکن نشد.',
+ 'invoiceTemplateEditor.saveError': 'ذخیره قالب فاکتور ممکن نشد.',
+ 'invoiceTemplateEditor.updated': 'قالب فاکتور بهروزرسانی شد.',
+ 'invoiceTemplateEditor.created': 'قالب فاکتور ایجاد شد.',
+ 'invoiceTemplateEditor.backToTemplates': 'بازگشت به قالبها',
+ 'invoiceTemplateEditor.create': 'ایجاد قالب',
+ 'invoiceTemplateEditor.save': 'ذخیره تغییرات',
+ 'invoiceTemplateEditor.saving': 'در حال ذخیره...',
+ 'invoiceTemplateEditor.cancel': 'انصراف',
+ 'invoiceTemplateEditor.loading': 'در حال بارگذاری...',
'login.welcome': 'خوش آمدید',
'login.subtitle': 'با شماره موبایل وارد شوید',
@@ -2844,10 +3298,27 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/store/settings', labels: [t('title.store'), t('title.settings')] },
{ match: '/store', labels: [t('title.store')] },
{ match: '/customers', labels: [t('title.customers')] },
+ { match: '/finance', labels: [t('title.finance')] },
{ match: '/customer-products/new', labels: [t('title.customerProducts'), t('customerProducts.add')] },
{ match: /^\/customer-products\/[^/]+\/edit$/, labels: [t('title.customerProducts'), t('customerProducts.edit')] },
{ match: /^\/customer-products\/[^/]+$/, labels: [t('title.customerProducts'), t('customerProducts.detailsTitle')] },
{ match: '/customer-products', labels: [t('title.customerProducts')] },
+ {
+ match: /^\/invoices\/templates\/new$/,
+ labels: [t('title.finance'), t('title.invoiceTemplates'), t('title.addInvoiceTemplate')],
+ },
+ {
+ match: /^\/invoices\/templates\/[^/]+$/,
+ labels: [t('title.finance'), t('title.invoiceTemplates'), t('title.editInvoiceTemplate')],
+ },
+ { match: '/invoices/templates', labels: [t('title.finance'), t('title.invoiceTemplates')] },
+ { match: '/invoices/new', labels: [t('title.finance'), t('title.invoices'), t('title.issueInvoice')] },
+ {
+ match: /^\/invoices\/[^/]+\/edit$/,
+ labels: [t('title.finance'), t('title.invoices'), t('title.editInvoice')],
+ },
+ { match: '/invoices', labels: [t('title.finance'), t('title.invoices')] },
+ { match: '/transactions', labels: [t('title.finance'), t('title.transactions')] },
{ match: '/blog/list', labels: [t('title.blog'), t('title.myBlogs')] },
{ match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.blog'), t('title.blogDetails')] },
{ match: '/blog/new', labels: [t('title.blog'), t('title.addBlog')] },
diff --git a/apps/business/src/index.css b/apps/business/src/index.css
index efac186..4077574 100644
--- a/apps/business/src/index.css
+++ b/apps/business/src/index.css
@@ -21,6 +21,12 @@
--bg-gradient-mid: color-mix(in srgb, var(--primary-light) 42%, #ffffff);
--bg-gradient-end: color-mix(in srgb, var(--primary-light) 18%, #ffffff);
--glass-bg: rgba(255, 255, 255, 0.55);
+ /* Sticky header: opaque enough over scrolling content; tinted by theme (works for near-white primary-light e.g. brown) */
+ --header-bg: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--primary-light) 42%, #ffffff) 90%,
+ transparent
+ );
--glass-border: rgba(255, 255, 255, 0.7);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08);
--blur-glass: 28px;
diff --git a/apps/business/src/lib/config.ts b/apps/business/src/lib/config.ts
index c48eb87..bbfc46e 100644
--- a/apps/business/src/lib/config.ts
+++ b/apps/business/src/lib/config.ts
@@ -34,3 +34,23 @@ export function isAllowedBusinessHost(hostname = window.location.hostname): bool
export function getBusinessDomain(): string {
return getBaseBusinessDomain()
}
+
+/**
+ * Fallback public invoice URL if the API response is missing `publicUrl`.
+ * Prefer `invoice.publicUrl` returned by the API — this only covers edge cases.
+ * Business invoices use the tenant domain (e.g. sanihome.ir), not meshkee.com.
+ */
+export function getInvoicePublicUrlFallback(
+ publicId: string,
+ businessDomain = getBusinessDomain(),
+): string {
+ const baseOverride = import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL?.trim()
+ if (baseOverride) {
+ return `${baseOverride.replace(/\/$/, '')}/invoices/${publicId}`
+ }
+ const domain =
+ import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() ||
+ businessDomain.trim() ||
+ 'meshkee.com'
+ return `https://${domain}/invoices/${publicId}`
+}
diff --git a/apps/business/src/pages/CustomersPage.tsx b/apps/business/src/pages/CustomersPage.tsx
index d5870c7..901ec11 100644
--- a/apps/business/src/pages/CustomersPage.tsx
+++ b/apps/business/src/pages/CustomersPage.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
-import { MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+import { MessageSquare, Pencil, Plus, Receipt, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
@@ -70,6 +71,7 @@ function teamRoleMessageKey(teamRole: string): BusinessMessageKey {
export function CustomersPage() {
const t = useT()
+ const navigate = useNavigate()
const { locale } = useLocale()
const { user: authUser } = useAuth()
const { showToast } = useToast()
@@ -243,6 +245,10 @@ export function CustomersPage() {
}
}
+ function handleViewInvoices(customer: BusinessCustomerListItem) {
+ navigate(`/invoices?userId=${customer.id}`, { state: { presetCustomer: customer } })
+ }
+
function handleSendSms(customer: BusinessCustomerListItem) {
showToast(
t('customers.toast.smsSoon', { phone: formatCellForDisplay(customer.cellNumber) }),
@@ -352,8 +358,7 @@ export function CustomersPage() {
-
-
+
+
+
+
+ }
+ />
+
+ {formError ?
{formError}
: null}
+
+
+ navigate(listPath)}
+ disabled={submitting}
+ >
+ {t('issueInvoice.cancel')}
+
+ void handleSubmit()}
+ disabled={submitting}
+ >
+ {submitting
+ ? isEdit
+ ? t('issueInvoice.saving')
+ : t('issueInvoice.issuing')
+ : isEdit
+ ? t('issueInvoice.save')
+ : t('issueInvoice.issue')}
+
+
+
+ ) : null}
+
+ {!loading && locked ? (
+
+ navigate(listPath)}
+ >
+ {t('issueInvoice.backToInvoices')}
+
+
+ ) : null}
+
+ )
+}
diff --git a/apps/business/src/pages/TransactionsPage.tsx b/apps/business/src/pages/TransactionsPage.tsx
new file mode 100644
index 0000000..19405bd
--- /dev/null
+++ b/apps/business/src/pages/TransactionsPage.tsx
@@ -0,0 +1,34 @@
+import { Breadcrumbs } from '../components/Breadcrumbs'
+import { PageTitle } from '../components/PageTitle'
+import formStyles from '../components/InvoiceForm.module.css'
+import pageStyles from '../components/PageContent.module.css'
+import { useT } from '../i18n/useT'
+import styles from './WebsitePage.module.css'
+
+export function TransactionsPage() {
+ const t = useT()
+
+ return (
+
+
+
+
+
+
{t('title.transactions')}
+
{t('transactions.subtitle')}
+
+
+
+
+ {t('transactions.lead')}
+ {t('transactions.note')}
+
+
+ )
+}
diff --git a/apps/business/src/services/invoiceService.ts b/apps/business/src/services/invoiceService.ts
new file mode 100644
index 0000000..0441f37
--- /dev/null
+++ b/apps/business/src/services/invoiceService.ts
@@ -0,0 +1,159 @@
+import { apiRequest } from '../lib/api'
+import { getActiveBusinessId } from '../lib/businessContext'
+import type {
+ CreateInvoiceItemTemplatePayload,
+ CreateInvoicePayload,
+ CreateInvoiceTemplatePayload,
+ Invoice,
+ InvoiceItemTemplate,
+ InvoiceItemTemplatesResponse,
+ InvoiceStatus,
+ InvoiceTemplate,
+ InvoiceTemplatesListResponse,
+ InvoicesListResponse,
+ UpdateInvoiceItemTemplatePayload,
+ UpdateInvoicePayload,
+ UpdateInvoiceTemplatePayload,
+} from '../types/invoice'
+
+function businessPath(suffix = '') {
+ const businessId = getActiveBusinessId()
+ if (!businessId) {
+ throw new Error('No active business selected. Please sign in again.')
+ }
+ return `/businesses/${businessId}${suffix}`
+}
+
+export function listInvoiceItemTemplates(signal?: AbortSignal) {
+ return apiRequest
(businessPath('/invoice-item-templates'), {
+ auth: true,
+ signal,
+ })
+}
+
+export function createInvoiceItemTemplate(payload: CreateInvoiceItemTemplatePayload) {
+ return apiRequest(businessPath('/invoice-item-templates'), {
+ method: 'POST',
+ auth: true,
+ body: payload,
+ })
+}
+
+export function updateInvoiceItemTemplate(
+ templateId: string,
+ payload: UpdateInvoiceItemTemplatePayload,
+) {
+ return apiRequest(
+ businessPath(`/invoice-item-templates/${templateId}`),
+ {
+ method: 'PATCH',
+ auth: true,
+ body: payload,
+ },
+ )
+}
+
+export function deleteInvoiceItemTemplate(templateId: string) {
+ return apiRequest<{ ok: boolean }>(businessPath(`/invoice-item-templates/${templateId}`), {
+ method: 'DELETE',
+ auth: true,
+ })
+}
+
+export function listInvoiceTemplates(signal?: AbortSignal) {
+ return apiRequest(businessPath('/invoice-templates'), {
+ auth: true,
+ signal,
+ })
+}
+
+export function getInvoiceTemplate(templateId: string, signal?: AbortSignal) {
+ return apiRequest(businessPath(`/invoice-templates/${templateId}`), {
+ auth: true,
+ signal,
+ })
+}
+
+export function createInvoiceTemplate(payload: CreateInvoiceTemplatePayload) {
+ return apiRequest(businessPath('/invoice-templates'), {
+ method: 'POST',
+ auth: true,
+ body: payload,
+ })
+}
+
+export function updateInvoiceTemplate(templateId: string, payload: UpdateInvoiceTemplatePayload) {
+ return apiRequest(businessPath(`/invoice-templates/${templateId}`), {
+ method: 'PATCH',
+ auth: true,
+ body: payload,
+ })
+}
+
+export function deleteInvoiceTemplate(templateId: string) {
+ return apiRequest<{ ok: boolean }>(businessPath(`/invoice-templates/${templateId}`), {
+ method: 'DELETE',
+ auth: true,
+ })
+}
+
+export interface ListInvoicesParams {
+ page?: number
+ pageSize?: number
+ status?: InvoiceStatus
+ userId?: string
+}
+
+export function listInvoices(params: ListInvoicesParams = {}, 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)
+ if (params.userId) search.set('userId', params.userId)
+ const qs = search.toString()
+ return apiRequest(businessPath(`/invoices${qs ? `?${qs}` : ''}`), {
+ auth: true,
+ signal,
+ })
+}
+
+export function getInvoice(invoiceId: string, signal?: AbortSignal) {
+ return apiRequest(businessPath(`/invoices/${invoiceId}`), {
+ auth: true,
+ signal,
+ })
+}
+
+export function createInvoice(payload: CreateInvoicePayload) {
+ return apiRequest(businessPath('/invoices'), {
+ method: 'POST',
+ auth: true,
+ body: payload,
+ })
+}
+
+export function updateInvoice(invoiceId: string, payload: UpdateInvoicePayload) {
+ return apiRequest(businessPath(`/invoices/${invoiceId}`), {
+ method: 'PUT',
+ auth: true,
+ body: payload,
+ })
+}
+
+export function updateInvoiceStatus(
+ invoiceId: string,
+ payload: { status: InvoiceStatus; notes?: string },
+) {
+ return apiRequest(businessPath(`/invoices/${invoiceId}`), {
+ method: 'PATCH',
+ auth: true,
+ body: payload,
+ })
+}
+
+export function deleteInvoice(invoiceId: string) {
+ return apiRequest<{ ok: boolean }>(businessPath(`/invoices/${invoiceId}`), {
+ method: 'DELETE',
+ auth: true,
+ })
+}
diff --git a/apps/business/src/types/invoice.ts b/apps/business/src/types/invoice.ts
new file mode 100644
index 0000000..9dffa13
--- /dev/null
+++ b/apps/business/src/types/invoice.ts
@@ -0,0 +1,210 @@
+export type InvoiceStatus = 'draft' | 'issued' | 'approved' | '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 InvoiceKeyPoint {
+ id: string
+ text: string
+ sortOrder: number
+}
+
+export interface InvoiceAccount {
+ id: string
+ bankName: string
+ accountHolderName: string | null
+ cardNumber: string | null
+ iban: string | null
+ sortOrder: number
+}
+
+export interface Invoice {
+ id: string
+ publicId: string
+ businessId: string
+ userId: string
+ ownerScope: 'platform' | 'business'
+ issuerBusinessId: string | null
+ status: InvoiceStatus
+ name: string | null
+ topText: string | null
+ notes: string | null
+ invoiceTemplateId: string | null
+ publicUrl: string | null
+ issuedBy: string | null
+ issuedAt: string
+ createdAt: string
+ updatedAt: string
+ business?: {
+ id: string
+ name: string
+ nameFa: string | null
+ }
+ user?: {
+ id: string
+ firstName: string | null
+ lastName: string | null
+ cell: string
+ }
+ issuer?: {
+ id: string
+ firstName: string | null
+ lastName: string | null
+ } | null
+ items?: InvoiceItem[]
+ keyPoints?: InvoiceKeyPoint[]
+ accounts?: InvoiceAccount[]
+ subtotal?: number
+ total?: number
+}
+
+export interface InvoiceItemInput {
+ templateId?: string
+ title: string
+ duration?: string
+ worktime?: string
+ description?: string
+ price: number
+ discountedPrice?: number | null
+}
+
+export interface InvoiceKeyPointInput {
+ text: string
+}
+
+export interface InvoiceAccountInput {
+ bankName: string
+ accountHolderName?: string
+ cardNumber?: string
+ iban?: string
+}
+
+/** Business dashboards always bill a specific customer — `userId` is required. */
+export interface CreateInvoicePayload {
+ userId: string
+ items: InvoiceItemInput[]
+ name?: string
+ topText?: string
+ notes?: string
+ invoiceTemplateId?: string
+ keyPoints?: InvoiceKeyPointInput[]
+ accounts?: InvoiceAccountInput[]
+ status?: InvoiceStatus
+}
+
+export type UpdateInvoicePayload = {
+ userId?: string
+ 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
+ title: string
+ duration: string | null
+ worktime: string | null
+ description: string | null
+ price: number
+ discountedPrice: number | null
+ sortOrder: number
+}
+
+export interface InvoiceTemplate {
+ id: string
+ ownerScope: 'platform' | 'business'
+ businessId: string | null
+ name: string
+ topText: string | null
+ sortOrder: number
+ isActive: boolean
+ createdAt: string
+ updatedAt: string
+ items: InvoiceTemplateItem[]
+ keyPoints: InvoiceKeyPoint[]
+ accounts: InvoiceAccount[]
+}
+
+export interface InvoiceTemplateItemInput {
+ itemTemplateId?: string
+ title: string
+ duration?: string
+ worktime?: string
+ description?: string
+ price: number
+ discountedPrice?: number | null
+}
+
+export interface CreateInvoiceTemplatePayload {
+ name: string
+ topText?: string
+ items: InvoiceTemplateItemInput[]
+ keyPoints?: InvoiceKeyPointInput[]
+ accounts?: InvoiceAccountInput[]
+ sortOrder?: number
+}
+
+export type UpdateInvoiceTemplatePayload = Partial & {
+ isActive?: boolean
+}
+
+export interface InvoiceItemTemplatesResponse {
+ items: InvoiceItemTemplate[]
+}
+
+export interface InvoiceTemplatesListResponse {
+ items: InvoiceTemplate[]
+}
+
+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 & {
+ isActive?: boolean
+}
diff --git a/apps/business/src/utils/invoiceDraft.ts b/apps/business/src/utils/invoiceDraft.ts
new file mode 100644
index 0000000..135f15b
--- /dev/null
+++ b/apps/business/src/utils/invoiceDraft.ts
@@ -0,0 +1,260 @@
+import { formatIrtInput, parseIrtInput } from './irtPrice'
+import type { BusinessMessageKey } from '../i18n/messages'
+import type {
+ InvoiceAccountInput,
+ InvoiceItemInput,
+ InvoiceItemTemplate,
+ InvoiceKeyPointInput,
+ InvoiceTemplate,
+ InvoiceTemplateItemInput,
+} from '../types/invoice'
+
+type Translate = (key: BusinessMessageKey, vars?: Record) => string
+
+const defaultTranslate: Translate = (key, vars) => {
+ const fallback: Record = {
+ 'invoiceDraft.error.itemTitle': 'Each item needs a title.',
+ 'invoiceDraft.error.itemPrice': 'Price is required for "{title}".',
+ 'invoiceDraft.error.itemDiscountInvalid': 'Discounted price is invalid for "{title}".',
+ 'invoiceDraft.error.itemDiscountExceeds': 'Discounted price cannot exceed price for "{title}".',
+ }
+ let text = fallback[key] ?? key
+ if (vars) {
+ for (const [name, value] of Object.entries(vars)) {
+ text = text.replaceAll(`{${name}}`, String(value))
+ }
+ }
+ return text
+}
+
+export type DraftLineItem = {
+ key: string
+ itemTemplateId?: string
+ title: string
+ duration: string
+ worktime: string
+ description: string
+ price: string
+ discountedPrice: string
+}
+
+export type DraftKeyPoint = { key: string; text: string }
+
+export type DraftAccount = {
+ key: string
+ bankName: string
+ accountHolderName: string
+ cardNumber: string
+ iban: string
+}
+
+export function newKey() {
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+}
+
+export function emptyDraftItem(): DraftLineItem {
+ return {
+ key: newKey(),
+ title: '',
+ duration: '',
+ worktime: '',
+ description: '',
+ price: '',
+ discountedPrice: '',
+ }
+}
+
+export function emptyDraftKeyPoint(): DraftKeyPoint {
+ return { key: newKey(), text: '' }
+}
+
+export function emptyDraftAccount(): DraftAccount {
+ return { key: newKey(), bankName: '', accountHolderName: '', cardNumber: '', iban: '' }
+}
+
+export function draftItemFromItemTemplate(template: InvoiceItemTemplate): DraftLineItem {
+ return {
+ key: newKey(),
+ itemTemplateId: template.id,
+ title: template.title,
+ duration: template.duration ?? '',
+ worktime: template.worktime ?? '',
+ description: template.description ?? '',
+ price: formatIrtInput(String(Math.round(template.price))),
+ discountedPrice:
+ template.discountedPrice === null || template.discountedPrice === undefined
+ ? ''
+ : formatIrtInput(String(Math.round(template.discountedPrice))),
+ }
+}
+
+export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
+ name: string
+ topText: string
+ items: DraftLineItem[]
+ keyPoints: DraftKeyPoint[]
+ accounts: DraftAccount[]
+} {
+ return {
+ name: template.name,
+ topText: template.topText ?? '',
+ items:
+ template.items.length > 0
+ ? template.items.map((item) => ({
+ key: newKey(),
+ itemTemplateId: item.itemTemplateId ?? undefined,
+ title: item.title,
+ duration: item.duration ?? '',
+ worktime: item.worktime ?? '',
+ description: item.description ?? '',
+ price: formatIrtInput(String(Math.round(item.price))),
+ discountedPrice:
+ item.discountedPrice === null || item.discountedPrice === undefined
+ ? ''
+ : formatIrtInput(String(Math.round(item.discountedPrice))),
+ }))
+ : [emptyDraftItem()],
+ keyPoints:
+ template.keyPoints.length > 0
+ ? template.keyPoints.map((kp) => ({ key: newKey(), text: kp.text }))
+ : [],
+ accounts:
+ template.accounts.length > 0
+ ? template.accounts.map((acc) => ({
+ key: newKey(),
+ bankName: acc.bankName,
+ accountHolderName: acc.accountHolderName ?? '',
+ cardNumber: acc.cardNumber ?? '',
+ iban: acc.iban ?? '',
+ }))
+ : [],
+ }
+}
+
+export function 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[],
+ t: Translate = defaultTranslate,
+): InvoiceItemInput[] {
+ return items.map((item) => {
+ const title = item.title.trim()
+ const price = parseIrtInput(item.price)
+ if (!title) throw new Error(t('invoiceDraft.error.itemTitle'))
+ if (price === null) throw new Error(t('invoiceDraft.error.itemPrice', { title }))
+ const discountedPrice = item.discountedPrice.trim()
+ ? parseIrtInput(item.discountedPrice)
+ : null
+ if (item.discountedPrice.trim() && discountedPrice === null) {
+ throw new Error(t('invoiceDraft.error.itemDiscountInvalid', { title }))
+ }
+ if (discountedPrice !== null && discountedPrice > price) {
+ throw new Error(t('invoiceDraft.error.itemDiscountExceeds', { title }))
+ }
+ return {
+ templateId: item.itemTemplateId,
+ title,
+ duration: item.duration.trim() || undefined,
+ worktime: item.worktime.trim() || undefined,
+ description: item.description.trim() || undefined,
+ price,
+ discountedPrice,
+ }
+ })
+}
+
+export function buildTemplateItemsPayload(
+ items: DraftLineItem[],
+ t: Translate = defaultTranslate,
+): InvoiceTemplateItemInput[] {
+ return buildLineItemsPayload(items, t).map((item) => ({
+ itemTemplateId: item.templateId,
+ title: item.title,
+ duration: item.duration,
+ worktime: item.worktime,
+ description: item.description,
+ price: item.price,
+ discountedPrice: item.discountedPrice,
+ }))
+}
+
+export function buildKeyPointsPayload(points: DraftKeyPoint[]): InvoiceKeyPointInput[] {
+ return points
+ .map((p) => p.text.trim())
+ .filter(Boolean)
+ .map((text) => ({ text }))
+}
+
+export function buildAccountsPayload(accounts: DraftAccount[]): InvoiceAccountInput[] {
+ return accounts
+ .map((acc) => ({
+ bankName: acc.bankName.trim(),
+ accountHolderName: acc.accountHolderName.trim() || undefined,
+ cardNumber: acc.cardNumber.trim() || undefined,
+ iban: acc.iban.trim() || undefined,
+ }))
+ .filter((acc) => acc.bankName)
+}
diff --git a/apps/business/src/utils/monthlyActivity.ts b/apps/business/src/utils/monthlyActivity.ts
new file mode 100644
index 0000000..fb016ac
--- /dev/null
+++ b/apps/business/src/utils/monthlyActivity.ts
@@ -0,0 +1,45 @@
+import type { DailyActivityPoint } from '../services/dailyActivityService'
+import { buildLast12MonthKeys, formatMonthLabel } from './productActivity'
+
+export interface MonthActivityPoint {
+ monthKey: string
+ label: string
+ primary: number
+ secondary: number
+}
+
+function toMonthKey(dateKey: string): string {
+ return dateKey.slice(0, 7)
+}
+
+/** Roll daily dual series into the last 12 calendar months. */
+export function aggregateDailyActivityByMonth(
+ primaryItems: DailyActivityPoint[],
+ secondaryItems: DailyActivityPoint[],
+ locale: string,
+): MonthActivityPoint[] {
+ const monthKeys = buildLast12MonthKeys()
+ const primaryByMonth = new Map(monthKeys.map((key) => [key, 0]))
+ const secondaryByMonth = new Map(monthKeys.map((key) => [key, 0]))
+
+ for (const item of primaryItems) {
+ const key = toMonthKey(item.date)
+ if (primaryByMonth.has(key)) {
+ primaryByMonth.set(key, (primaryByMonth.get(key) ?? 0) + item.count)
+ }
+ }
+
+ for (const item of secondaryItems) {
+ const key = toMonthKey(item.date)
+ if (secondaryByMonth.has(key)) {
+ secondaryByMonth.set(key, (secondaryByMonth.get(key) ?? 0) + item.count)
+ }
+ }
+
+ return monthKeys.map((monthKey) => ({
+ monthKey,
+ label: formatMonthLabel(monthKey, locale),
+ primary: primaryByMonth.get(monthKey) ?? 0,
+ secondary: secondaryByMonth.get(monthKey) ?? 0,
+ }))
+}
diff --git a/apps/business/src/utils/productActivity.ts b/apps/business/src/utils/productActivity.ts
index ecb945f..4b6bfe2 100644
--- a/apps/business/src/utils/productActivity.ts
+++ b/apps/business/src/utils/productActivity.ts
@@ -14,12 +14,17 @@ function toMonthKey(iso: string): string {
return `${year}-${month}`
}
-function formatMonthLabel(monthKey: string, locale: string): string {
+export function formatMonthLabel(monthKey: string, locale: string): string {
const [year, month] = monthKey.split('-').map(Number)
- return new Date(year, month - 1, 1).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
+ const date = new Date(year, month - 1, 1)
+ if (locale === 'fa') {
+ return date.toLocaleString('fa-IR', {
+ month: 'long',
+ calendar: 'persian',
+ })
+ }
+ return date.toLocaleString('en-US', {
month: 'short',
- calendar: 'gregory',
- numberingSystem: 'latn',
})
}
diff --git a/apps/customer/src/components/Header.module.css b/apps/customer/src/components/Header.module.css
index debbda9..29dab4b 100644
--- a/apps/customer/src/components/Header.module.css
+++ b/apps/customer/src/components/Header.module.css
@@ -6,9 +6,9 @@
position: sticky;
top: 0;
z-index: 50;
- background: var(--glass-bg);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
+ background: var(--header-bg, var(--glass-bg));
+ backdrop-filter: blur(20px) saturate(150%);
+ -webkit-backdrop-filter: blur(20px) saturate(150%);
border-bottom: 1px solid var(--glass-border);
isolation: isolate;
transform: translateZ(0);
diff --git a/apps/customer/src/index.css b/apps/customer/src/index.css
index 00bf5ea..b176225 100644
--- a/apps/customer/src/index.css
+++ b/apps/customer/src/index.css
@@ -11,6 +11,11 @@
--bg-gradient-mid: #eef0f3;
--bg-gradient-end: #e8eaee;
--glass-bg: rgba(255, 255, 255, 0.72);
+ --header-bg: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--primary-light) 28%, #ffffff) 92%,
+ transparent
+ );
--glass-border: rgba(148, 163, 184, 0.28);
--glass-shadow: 0 8px 32px rgba(15, 23, 42, 0.08);
--surface: rgba(255, 255, 255, 0.82);
@@ -32,6 +37,7 @@ html[data-theme='dark'] {
--bg-gradient-end: #1a1d23;
--glass-bg: rgba(45, 49, 57, 0.9);
+ --header-bg: color-mix(in srgb, #2a2d34 92%, transparent);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
--modal-overlay-bg: rgba(0, 0, 0, 0.6);
diff --git a/apps/super-admin/src/components/Header.module.css b/apps/super-admin/src/components/Header.module.css
index df08b88..466d97d 100644
--- a/apps/super-admin/src/components/Header.module.css
+++ b/apps/super-admin/src/components/Header.module.css
@@ -6,9 +6,9 @@
position: sticky;
top: 0;
z-index: 50;
- background: var(--glass-bg);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
+ background: var(--header-bg, var(--glass-bg-strong, var(--glass-bg)));
+ backdrop-filter: blur(20px) saturate(150%);
+ -webkit-backdrop-filter: blur(20px) saturate(150%);
border-bottom: 1px solid var(--glass-border);
isolation: isolate;
transform: translateZ(0);
diff --git a/apps/super-admin/src/index.css b/apps/super-admin/src/index.css
index 09873fe..14c8957 100644
--- a/apps/super-admin/src/index.css
+++ b/apps/super-admin/src/index.css
@@ -12,6 +12,11 @@
--bg-gradient-end: #f4f7ff;
--glass-bg: rgba(255, 255, 255, 0.42);
--glass-bg-strong: rgba(255, 255, 255, 0.62);
+ --header-bg: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--primary-light) 42%, #ffffff) 90%,
+ transparent
+ );
--glass-border: rgba(255, 255, 255, 0.75);
--glass-shadow: 0 8px 32px rgba(30, 58, 138, 0.1);
--primary: #0a1628;
diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
index 7342ae1..0ae81ce 100644
--- a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
+++ b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
@@ -193,6 +193,7 @@ export function BusinessInvoicesPage() {
| Name |
+ Billed user |
Issued |
Status |
Total |
@@ -203,7 +204,7 @@ export function BusinessInvoicesPage() {
{!loading && invoices.length === 0 ? (
- |
+ |
No invoices yet for this business.
|
@@ -216,6 +217,15 @@ export function BusinessInvoicesPage() {
{invoice.items?.length ?? 0} item{(invoice.items?.length ?? 0) === 1 ? '' : 's'}
+
+ {invoice.user
+ ? [invoice.user.firstName, invoice.user.lastName].filter(Boolean).join(' ') ||
+ invoice.user.cell
+ : '—'}
+ {invoice.user?.cell ? (
+ {invoice.user.cell}
+ ) : null}
+ |
{formatDate(invoice.issuedAt)} |
**For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes.
-Last updated: August 9, 2026
+Last updated: August 11, 2026
---
@@ -188,6 +188,13 @@ Add to `/etc/hosts` (one line per tenant):
| `/store/items` | Store items (product variants) | Yes |
| `/store/settings` | Online sell + order process steps | Yes |
| `/customers` | Business customers list | Yes |
+| `/finance` | Finance hub (Invoices + Transactions tiles) | Yes |
+| `/invoices` | Business-issued invoices list (`?userId=` filter) | Yes |
+| `/invoices/new` | Issue invoice to a user | Yes |
+| `/invoices/:invoiceId/edit` | Edit invoice (locked when approved) | Yes |
+| `/invoices/templates` | Invoice templates + item templates | Yes |
+| `/invoices/templates/new` | Create invoice template | Yes |
+| `/invoices/templates/:templateId` | Edit invoice template | Yes |
| `/customer-products` | Customer user-product listings (admin API) | Yes |
| `/customer-products/new` | Admin create user product (under admin name) | Yes |
| `/customer-products/:id` | Customer user-product details | Yes |
@@ -352,49 +359,50 @@ Run in order from `MeshkeeApp Backend/database/migrations/`:
| `038_invoice_templates.sql` | Full invoice templates + key points / accounts |
| `039_invoice_account_holder.sql` | Account holder name on bank accounts |
| `040_invoice_public_id.sql` | Opaque 12-digit `public_id` for public links |
+| `041_invoice_status_approved.sql` | Invoice status `approved` |
+| `058_invoice_user_id.sql` | `invoices.user_id` billed user |
After schema changes: `npx prisma generate` and restart the backend.
-### Invoices (super-admin)
+### Invoices (super-admin + business)
-Two template layers + issued invoices:
+Billed party is always a **User** (`user_id`). `business_id` is tenant/context. Platform invoices: `owner_scope=platform` (super-admin). Business-issued: `owner_scope=business` + `issuer_business_id`.
| Layer | Purpose |
|-------|---------|
| **Invoice item templates** | Reusable line items (title, duration, worktime, desc, price, discounted) |
-| **Invoice templates** | Full blueprints: name, top text, items (from item templates or custom), duplicatable key points, duplicatable bank accounts (bank name, account holder, card, IBAN) |
-| **Invoices** | Issued to a business — start from an invoice template (editable) or blank |
+| **Invoice templates** | Full blueprints: name, top text, items, key points, bank accounts |
+| **Invoices** | Issued **to a user** — from template or blank |
| Endpoint | Purpose |
|----------|---------|
-| `GET/POST /invoice-item-templates` | Platform line-item presets |
-| `PATCH/DELETE /invoice-item-templates/:id` | Update/remove line-item preset |
+| `GET/POST /invoice-item-templates` | Platform line-item presets (super-admin) |
+| `PATCH/DELETE /invoice-item-templates/:id` | Update/remove platform preset |
| `GET/POST /invoice-templates` | Platform full invoice templates |
-| `GET/PATCH/DELETE /invoice-templates/:id` | Get / update / delete invoice template |
-| `GET/POST /businesses/:businessId/invoices` | List / issue invoices for a business |
-| `GET/PATCH/DELETE /businesses/:businessId/invoices/:invoiceId` | Detail, status update, delete |
-| `GET /public/invoices/:id` | Public show payload (issued/paid, no auth) |
+| `GET/PATCH/DELETE /invoice-templates/:id` | Platform template CRUD |
+| `GET/POST /businesses/:businessId/invoice-item-templates` | Business item templates |
+| `PATCH/DELETE .../invoice-item-templates/:id` | Business item template CRUD |
+| `GET/POST /businesses/:businessId/invoice-templates` | Business invoice templates |
+| `GET/PATCH/DELETE .../invoice-templates/:id` | Business invoice template CRUD |
+| `GET/POST /businesses/:businessId/invoices` | List / issue (`?userId=` filter; body `userId` required for business) |
+| `GET/PUT/PATCH/DELETE .../invoices/:invoiceId` | Detail, content, status, delete |
+| `GET /public/invoices/:publicId` | Public show (platform or business; issued/approved/paid) |
+| `POST /public/invoices/:publicId/approve` | Public approve (`issued` → `approved`) |
-**Invoice fields:** optional `name`, `topText`, `notes`, `invoiceTemplateId`, `status`, `publicUrl`, nested `items`, `keyPoints`, `accounts` (bank name, account holder, card, IBAN).
+Auth: platform template routes → super-admin. Business routes → `BusinessPermissionGuard` (`invoices.*` / `invoice_templates.*`); super-admin on invoice paths still sees **platform** invoices for that business.
-**Public invoice viewer (platform):**
-- Route: super-admin SPA `/invoices/:id` (`PublicInvoicePage`) — glass layout, print-to-PDF, Approve (`issued` → `approved`), “Issued by” Meshkee footer
-- Status `approved`: set from public show page; content becomes immutable for admins
-- Edit: list pencil → `/businesses/:businessId/invoices/:invoiceId/edit` (hidden when approved)
-- Links use opaque **12-digit `publicId`** (not sequential PK) — `GET /public/invoices/:publicId`
-- Local/dev link: current Vite origin (e.g. `https://meshkee.app:5174/invoices/{publicId}`)
-- Production link domain: `VITE_INVOICE_PUBLIC_DOMAIN` / `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`) — optional full origin override via `*_PUBLIC_BASE_URL`
-- Until `meshkee.com` proxies or hosts `/invoices/*`, production links may need that DNS/nginx wiring (viewer code ships with super-admin build)
+**Invoice fields:** `userId` + nested `user`, optional `name`, `topText`, `notes`, `invoiceTemplateId`, `status`, `publicUrl`, nested `items`, `keyPoints`, `accounts`.
-**Migrations:** `036_invoices.sql` … `040_invoice_public_id.sql`
+**Public invoice viewer:**
+- Super-admin SPA `/invoices/:publicId` (`PublicInvoicePage`) — print-to-PDF; opaque 12-digit `publicId`
+- Platform invoices: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default `meshkee.com`)
+- Business invoices: `https://{primaryBusinessDomain}/invoices/{publicId}` (e.g. `sanihome.ir`)
-**Super Admin UI:**
-- `/settings` — Invoice templates list + item templates (top text preview = one-line ellipsis)
-- `/settings/invoice-templates/new` · `/settings/invoice-templates/:id` — full-page template editor (not modal)
-- `/businesses/:businessId/invoices` — list + view modal
-- `/businesses/:businessId/invoices/new` — full-page issue form
-- `/invoices/:id` — public viewer (no auth)
-- Schema supports future `owner_scope=business` (business-owned templates)
+**Migrations:** `036_invoices.sql` … `041_invoice_status_approved.sql`, `058_invoice_user_id.sql`
+
+**Super Admin UI:** `/settings` templates; `/businesses/:id/invoices` list/issue/edit; public `/invoices/:publicId`
+
+**Business UI:** sidebar Finance group → `/finance` (hub), `/invoices`, `/transactions`; home Finance tile; Users row Receipt icon → `/invoices?userId=`; i18n en/fa via `useT`
---
@@ -569,7 +577,6 @@ Products overview page uses the same i18n + theme-aware `ProductActivityChart` (
## Suggested next work
- Point `meshkee.com/invoices/*` at the public invoice viewer (proxy or dedicated host)
-- Business-dashboard invoice templates + issue flow (`owner_scope=business`)
- Migrate business and super-admin fully onto `@meshkee/dashboard-core` / `@meshkee/dashboard-ui` (LocaleProvider already shared)
- Finish FA/EN coverage on remaining business form pages (many labels still English)
- Connect product comments to backend
@@ -577,6 +584,7 @@ Products overview page uses the same i18n + theme-aware `ProductActivityChart` (
- Enforce `onlineSellEnabled` on public website checkout
- Postman collection updates for variants endpoints
- Add `sanihome.ir` (and other tenants) in production Super Admin so tenant APIs resolve
+- Customer dashboard “my invoices” view
---
diff --git a/packages/dashboard-core/src/styles/tokens.css b/packages/dashboard-core/src/styles/tokens.css
index ce9f6dc..9537440 100644
--- a/packages/dashboard-core/src/styles/tokens.css
+++ b/packages/dashboard-core/src/styles/tokens.css
@@ -21,6 +21,12 @@
--bg-gradient-mid: color-mix(in srgb, var(--primary-light) 42%, #ffffff);
--bg-gradient-end: color-mix(in srgb, var(--primary-light) 18%, #ffffff);
--glass-bg: rgba(255, 255, 255, 0.55);
+ /* Sticky header over scrolling content — more opaque than --glass-bg; theme-tinted */
+ --header-bg: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--primary-light) 42%, #ffffff) 90%,
+ transparent
+ );
--glass-border: rgba(255, 255, 255, 0.7);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08);
--blur-glass: 28px;
|