mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add business Finance hub with invoices and templates.
Ship invoice issue/list/templates under Finance, fix tenant theme reset and owner role badge, and show customers-joined charts by Persian months. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
722933dda9
commit
917b840ee5
@@ -23,6 +23,12 @@ import { StorePage } from './pages/StorePage'
|
||||
import { StoreItemsPage } from './pages/StoreItemsPage'
|
||||
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
|
||||
import { CustomersPage } from './pages/CustomersPage'
|
||||
import { InvoicesPage } from './pages/InvoicesPage'
|
||||
import { IssueInvoicePage } from './pages/IssueInvoicePage'
|
||||
import { InvoiceTemplatesPage } from './pages/InvoiceTemplatesPage'
|
||||
import { InvoiceTemplateEditorPage } from './pages/InvoiceTemplateEditorPage'
|
||||
import { TransactionsPage } from './pages/TransactionsPage'
|
||||
import { FinancePage } from './pages/FinancePage'
|
||||
import { CustomerProductsPage } from './pages/CustomerProductsPage'
|
||||
import { CustomerProductDetailsPage } from './pages/CustomerProductDetailsPage'
|
||||
import { AddCustomerProductPage } from './pages/AddCustomerProductPage'
|
||||
@@ -86,6 +92,14 @@ function App() {
|
||||
<Route path="store/cards" element={<ShoppingCardsPage />} />
|
||||
<Route path="store/settings" element={<StoreSettingsPage />} />
|
||||
<Route path="customers" element={<CustomersPage />} />
|
||||
<Route path="finance" element={<FinancePage />} />
|
||||
<Route path="invoices" element={<InvoicesPage />} />
|
||||
<Route path="invoices/new" element={<IssueInvoicePage />} />
|
||||
<Route path="invoices/templates" element={<InvoiceTemplatesPage />} />
|
||||
<Route path="invoices/templates/new" element={<InvoiceTemplateEditorPage />} />
|
||||
<Route path="invoices/templates/:templateId" element={<InvoiceTemplateEditorPage />} />
|
||||
<Route path="invoices/:invoiceId/edit" element={<IssueInvoicePage />} />
|
||||
<Route path="transactions" element={<TransactionsPage />} />
|
||||
<Route path="customer-products" element={<CustomerProductsPage />} />
|
||||
<Route path="customer-products/new" element={<AddCustomerProductPage />} />
|
||||
<Route path="customer-products/:id/edit" element={<AddCustomerProductPage />} />
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Search, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import { searchCustomers, type BusinessCustomer } from '../services/customerService'
|
||||
import styles from './CategorySearchSelect.module.css'
|
||||
|
||||
interface CustomerSearchSelectProps {
|
||||
value: BusinessCustomer | null
|
||||
onChange: (customer: BusinessCustomer | null) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
function customerLabel(customer: BusinessCustomer, withPhone = false) {
|
||||
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
|
||||
const phone = formatCellForDisplay(customer.cellNumber)
|
||||
if (withPhone && name && phone) {
|
||||
return `${name} · ${phone}`
|
||||
}
|
||||
return name || phone
|
||||
}
|
||||
|
||||
export function CustomerSearchSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
id,
|
||||
}: CustomerSearchSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<BusinessCustomer[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selectedLabel = value ? customerLabel(value, true) : ''
|
||||
const searchPlaceholder = placeholder ?? t('issueInvoice.pickCustomerPlaceholder')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}, [disabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const q = query.trim()
|
||||
if (q.length < 2) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await searchCustomers(q, 20, controller.signal)
|
||||
setResults(res.items)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setResults([])
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [query, open])
|
||||
|
||||
function selectOption(customer: BusinessCustomer) {
|
||||
onChange(customer)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<div
|
||||
className={[
|
||||
styles.inputWrap,
|
||||
open ? styles.inputWrapOpen : '',
|
||||
disabled ? styles.inputWrapDisabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
placeholder={value ? selectedLabel : searchPlaceholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) setOpen(true)
|
||||
}}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{value && !open && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={() => onChange(null)}
|
||||
aria-label={t('issueInvoice.changeCustomer')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && !disabled ? (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
{query.trim().length < 2 ? (
|
||||
<li className={styles.noResults}>{t('issueInvoice.pickCustomerHint')}</li>
|
||||
) : loading ? (
|
||||
<li className={styles.noResults}>{t('issueInvoice.loading')}</li>
|
||||
) : results.length === 0 ? (
|
||||
<li className={styles.noResults}>{t('issueInvoice.pickCustomerNoResults')}</li>
|
||||
) : (
|
||||
results.map((customer) => (
|
||||
<li key={customer.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.option,
|
||||
value?.id === customer.id ? styles.optionSelected : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => selectOption(customer)}
|
||||
role="option"
|
||||
aria-selected={value?.id === customer.id}
|
||||
>
|
||||
<span className={styles.optionLabel}>{customerLabel(customer)}</span>
|
||||
<span className={styles.optionSecondary} dir="ltr">
|
||||
{formatCellForDisplay(customer.cellNumber)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -94,6 +94,10 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chartMonth .group {
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -107,6 +111,10 @@
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.barMonth {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.barPrimary {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -127,8 +135,9 @@
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-en), var(--font-ui), sans-serif;
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DailyActivityPoint,
|
||||
DualDailyActivityResponse,
|
||||
} from '../services/dailyActivityService'
|
||||
import { aggregateDailyActivityByMonth } from '../utils/monthlyActivity'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from './DailyActivityChart.module.css'
|
||||
@@ -22,6 +23,8 @@ interface DailyActivityChartProps {
|
||||
primaryBarTitleKey: BusinessMessageKey
|
||||
secondaryBarTitleKey: BusinessMessageKey
|
||||
load: (signal: AbortSignal) => Promise<DualDailyActivityResponse>
|
||||
/** Day bars (default) or roll up into last 12 months. */
|
||||
granularity?: 'day' | 'month'
|
||||
}
|
||||
|
||||
function formatDayLabel(dateKey: string, locale: string): string {
|
||||
@@ -43,6 +46,7 @@ export function DailyActivityChart({
|
||||
primaryBarTitleKey,
|
||||
secondaryBarTitleKey,
|
||||
load,
|
||||
granularity = 'day',
|
||||
}: DailyActivityChartProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
@@ -82,14 +86,27 @@ export function DailyActivityChart({
|
||||
return () => controller.abort()
|
||||
}, [load, t, errorKey])
|
||||
|
||||
const monthItems = useMemo(() => {
|
||||
if (granularity !== 'month') return []
|
||||
return aggregateDailyActivityByMonth(primaryItems, secondaryItems, locale)
|
||||
}, [granularity, primaryItems, secondaryItems, locale])
|
||||
|
||||
const maxValue = useMemo(() => {
|
||||
if (granularity === 'month') {
|
||||
const peak = Math.max(
|
||||
...monthItems.map((item) => item.primary),
|
||||
...monthItems.map((item) => item.secondary),
|
||||
0,
|
||||
)
|
||||
return peak > 0 ? peak : 1
|
||||
}
|
||||
const peak = Math.max(
|
||||
...primaryItems.map((item) => item.count),
|
||||
...secondaryItems.map((item) => item.count),
|
||||
0,
|
||||
)
|
||||
return peak > 0 ? peak : 1
|
||||
}, [primaryItems, secondaryItems])
|
||||
}, [granularity, monthItems, primaryItems, secondaryItems])
|
||||
|
||||
return (
|
||||
<section className={styles.card} aria-label={t(titleKey)}>
|
||||
@@ -119,43 +136,83 @@ export function DailyActivityChart({
|
||||
) : (
|
||||
<div className={styles.chartWrap}>
|
||||
<div
|
||||
className={styles.chart}
|
||||
className={`${styles.chart}${granularity === 'month' ? ` ${styles.chartMonth}` : ''}`}
|
||||
style={{ height: CHART_HEIGHT + 28 }}
|
||||
role="img"
|
||||
aria-label={t(titleKey)}
|
||||
>
|
||||
{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)
|
||||
{granularity === 'month'
|
||||
? monthItems.map((item) => {
|
||||
const primaryHeight = (item.primary / maxValue) * CHART_HEIGHT
|
||||
const secondaryHeight = (item.secondary / maxValue) * CHART_HEIGHT
|
||||
return (
|
||||
<div key={item.monthKey} className={styles.group}>
|
||||
<div className={styles.bars} style={{ gap: BAR_GAP }}>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barPrimary} ${styles.barMonth}`}
|
||||
style={{
|
||||
height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0),
|
||||
}}
|
||||
title={t(primaryBarTitleKey, {
|
||||
day: item.label,
|
||||
month: item.label,
|
||||
count: item.primary,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barAccent} ${styles.barMonth}`}
|
||||
style={{
|
||||
height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0),
|
||||
}}
|
||||
title={t(secondaryBarTitleKey, {
|
||||
day: item.label,
|
||||
month: item.label,
|
||||
count: item.secondary,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={styles.label}
|
||||
lang={locale === 'fa' ? 'fa' : 'en'}
|
||||
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
: 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 (
|
||||
<div key={item.date} className={styles.group}>
|
||||
<div className={styles.bars} style={{ gap: BAR_GAP }}>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barPrimary}`}
|
||||
style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }}
|
||||
title={t(primaryBarTitleKey, { day: label, count: item.count })}
|
||||
/>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barAccent}`}
|
||||
style={{
|
||||
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
|
||||
}}
|
||||
title={t(secondaryBarTitleKey, {
|
||||
day: label,
|
||||
count: secondaryCount,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.label} lang="en" dir="ltr">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div key={item.date} className={styles.group}>
|
||||
<div className={styles.bars} style={{ gap: BAR_GAP }}>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barPrimary}`}
|
||||
style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }}
|
||||
title={t(primaryBarTitleKey, { day: label, count: item.count })}
|
||||
/>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barAccent}`}
|
||||
style={{
|
||||
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
|
||||
}}
|
||||
title={t(secondaryBarTitleKey, {
|
||||
day: label,
|
||||
count: secondaryCount,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.label} lang="en" dir="ltr">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<div className={styles.profileInfo}>
|
||||
<span className={styles.name}>{displayName}</span>
|
||||
{adminBadgeLabel ? (
|
||||
<span className={styles.roleBadge}>{adminBadgeLabel}</span>
|
||||
{roleBadgeLabel ? (
|
||||
<span className={styles.roleBadge}>{roleBadgeLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronDown
|
||||
|
||||
@@ -76,6 +76,7 @@ export function HomeChartSlot({ chartId }: HomeChartSlotProps) {
|
||||
primaryBarTitleKey="home.chart.customersJoined.bar"
|
||||
secondaryBarTitleKey="home.chart.customersJoined.activeBar"
|
||||
load={loadCustomersYear}
|
||||
granularity="month"
|
||||
/>
|
||||
)
|
||||
case 'products_added_1y':
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLInputElement>(`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<DraftLineItem>) {
|
||||
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 (
|
||||
<>
|
||||
<div className={styles.itemsStack}>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.key} className={styles.itemCard}>
|
||||
<div className={styles.itemCardHeader}>
|
||||
<span>{t('invoiceDraft.itemLabel', { index: index + 1 })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeItemBtn}
|
||||
onClick={() => removeItem(item.key)}
|
||||
disabled={items.length <= 1}
|
||||
aria-label={t('invoiceDraft.removeItem')}
|
||||
title={t('invoiceDraft.removeItem')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.itemGrid}>
|
||||
<div className={formStyles.field}>
|
||||
<label>{t('invoiceDraft.fieldTitle')}</label>
|
||||
<input
|
||||
value={item.title}
|
||||
onChange={(e) => updateItem(item.key, { title: e.target.value })}
|
||||
placeholder={t('invoiceDraft.fieldTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label>{t('invoiceDraft.fieldDuration')}</label>
|
||||
<input
|
||||
value={item.duration}
|
||||
onChange={(e) => updateItem(item.key, { duration: e.target.value })}
|
||||
placeholder={t('invoiceDraft.fieldDurationPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label>{t('invoiceDraft.fieldWorktime')}</label>
|
||||
<input
|
||||
value={item.worktime}
|
||||
onChange={(e) => updateItem(item.key, { worktime: e.target.value })}
|
||||
placeholder={t('invoiceDraft.fieldWorktimePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label>{t('invoiceDraft.fieldPrice')}</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={item.price}
|
||||
onChange={(e) => updateItem(item.key, { price: formatIrtInput(e.target.value) })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label>{t('invoiceDraft.fieldDiscountedPrice')}</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={item.discountedPrice}
|
||||
onChange={(e) =>
|
||||
updateItem(item.key, { discountedPrice: formatIrtInput(e.target.value) })
|
||||
}
|
||||
placeholder={t('invoiceDraft.fieldDiscountedPricePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${formStyles.field} ${styles.descField}`}>
|
||||
<label>{t('invoiceDraft.fieldDescription')}</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(item.key, { description: e.target.value })}
|
||||
placeholder={t('invoiceDraft.fieldDescriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.itemActions}>
|
||||
{showItemTemplatePicker ? (
|
||||
<div className={styles.itemActionsLeft}>
|
||||
<select
|
||||
id="item-template-pick"
|
||||
className={styles.compactSelect}
|
||||
value={selectedItemTemplateId}
|
||||
onChange={(e) => onSelectedItemTemplateId(e.target.value)}
|
||||
aria-label={t('invoiceDraft.addFromTemplateAria')}
|
||||
>
|
||||
<option value="">{t('invoiceDraft.addFromTemplate')}</option>
|
||||
{itemTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
|
||||
onClick={addFromItemTemplate}
|
||||
disabled={!selectedItemTemplateId}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('invoiceDraft.add')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
|
||||
onClick={() => onItemsChange([...items, emptyDraftItem()])}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t('invoiceDraft.addCustomItem')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{afterItems}
|
||||
|
||||
<div className={styles.blockSection}>
|
||||
<div className={styles.blockHeader}>
|
||||
<h4 className={styles.blockTitle}>{t('invoiceDraft.keyPoints')}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||
onClick={() => onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('invoiceDraft.addPoint')}
|
||||
</button>
|
||||
</div>
|
||||
{keyPoints.length === 0 ? (
|
||||
<p className={styles.templateHint}>{t('invoiceDraft.noKeyPoints')}</p>
|
||||
) : (
|
||||
<div className={styles.repeatStack}>
|
||||
{keyPoints.map((point, index) => (
|
||||
<div key={point.key} className={styles.repeatRow}>
|
||||
<div className={`${formStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
|
||||
<input
|
||||
data-keypoint-index={index}
|
||||
value={point.text}
|
||||
onChange={(e) =>
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeFieldBtn}
|
||||
onClick={() => onKeyPointsChange(keyPoints.filter((p) => p.key !== point.key))}
|
||||
aria-label={t('invoiceDraft.removeKeyPoint')}
|
||||
title={t('invoiceDraft.removeKeyPoint')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.blockSection}>
|
||||
<div className={styles.blockHeader}>
|
||||
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('invoiceDraft.addAccount')}
|
||||
</button>
|
||||
</div>
|
||||
{accounts.length === 0 ? (
|
||||
<p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p>
|
||||
) : (
|
||||
<div className={styles.repeatStack}>
|
||||
{accounts.map((acc, index) => {
|
||||
const showLabels = index === 0
|
||||
return (
|
||||
<div
|
||||
key={acc.key}
|
||||
className={`${styles.accountRow} ${showLabels ? '' : styles.accountRowPlain}`}
|
||||
>
|
||||
<div className={`${formStyles.field} ${styles.accountCol2}`}>
|
||||
{showLabels ? <label>{t('invoiceDraft.bankName')}</label> : null}
|
||||
<input
|
||||
value={acc.bankName}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder={t('invoiceDraft.bankName')}
|
||||
aria-label={t('invoiceDraft.bankName')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${formStyles.field} ${styles.accountCol2}`}>
|
||||
{showLabels ? <label>{t('invoiceDraft.accountHolder')}</label> : null}
|
||||
<input
|
||||
value={acc.accountHolderName}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder={t('invoiceDraft.accountHolder')}
|
||||
aria-label={t('invoiceDraft.accountHolder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${formStyles.field} ${styles.accountCol3}`}>
|
||||
{showLabels ? <label>{t('invoiceDraft.cardNumber')}</label> : null}
|
||||
<input
|
||||
value={acc.cardNumber}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder={t('invoiceDraft.optional')}
|
||||
aria-label={t('invoiceDraft.cardNumber')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${formStyles.field} ${styles.accountCol5}`}>
|
||||
{showLabels ? <label>{t('invoiceDraft.iban')}</label> : null}
|
||||
<input
|
||||
value={acc.iban}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, iban: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder={t('invoiceDraft.optional')}
|
||||
aria-label={t('invoiceDraft.iban')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeFieldBtn}
|
||||
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
|
||||
aria-label={t('invoiceDraft.removeAccount')}
|
||||
title={t('invoiceDraft.removeAccount')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${styles.modal} ${sizeClass}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
lang={locale}
|
||||
dir={dir}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label={closeLabel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -352,3 +352,18 @@ export function RichTextEditor({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Strip tags for single-line list previews. */
|
||||
export function richTextToPlain(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function isEmptyRichText(html: string | null | undefined): boolean {
|
||||
return richTextToPlain(html).length === 0
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div key={item.id} className={styles.navGroup}>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'customers.edit': 'ویرایش مشتری',
|
||||
'customers.sms': 'ارسال پیامک',
|
||||
'customers.tickets': 'تیکتها',
|
||||
'customers.invoices': 'مشاهده فاکتورها',
|
||||
'customers.remove': 'حذف مشتری',
|
||||
'customers.add': 'افزودن مشتری',
|
||||
'customers.error.load': 'بارگذاری مشتریان ممکن نشد.',
|
||||
@@ -2663,6 +2901,222 @@ const fa: Record<MessageKey, string> = {
|
||||
'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')] },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<option value="managers">{t('customers.filter.access.managers')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={filterStyles.filterSpacerCol4} aria-hidden="true" />
|
||||
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsCol1}`}>
|
||||
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsEnd}`}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
@@ -532,6 +537,16 @@ export function CustomersPage() {
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('customers.invoices')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => handleViewInvoices(customer)}
|
||||
aria-label={t('customers.invoices')}
|
||||
>
|
||||
<Receipt size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('customers.sms')}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { FileText, ArrowLeftRight } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const financeSections: {
|
||||
icon: typeof FileText
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
href: string
|
||||
}[] = [
|
||||
{
|
||||
icon: FileText,
|
||||
titleKey: 'nav.finance.invoices',
|
||||
descKey: 'finance.card.invoices.desc',
|
||||
href: '/invoices',
|
||||
},
|
||||
{
|
||||
icon: ArrowLeftRight,
|
||||
titleKey: 'nav.finance.transactions',
|
||||
descKey: 'finance.card.transactions.desc',
|
||||
href: '/transactions',
|
||||
},
|
||||
]
|
||||
|
||||
export function FinancePage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('title.finance') },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="FINANCE MANAGEMENT">{t('title.finance')}</PageTitle>
|
||||
<p className={styles.pageSubtitle}>{t('finance.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{financeSections.map((section) => (
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Users,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Globe,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
@@ -69,6 +69,13 @@ const sections: {
|
||||
href: '/customers',
|
||||
countKey: 'customers',
|
||||
},
|
||||
{
|
||||
icon: Wallet,
|
||||
titleKey: 'home.card.finance.title',
|
||||
descKey: 'home.card.finance.desc',
|
||||
linkKey: 'home.card.finance.link',
|
||||
href: '/finance',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
titleKey: 'home.card.blog.title',
|
||||
@@ -89,13 +96,6 @@ const sections: {
|
||||
countKey: 'portfolios',
|
||||
moduleId: 'portfolio',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
titleKey: 'home.card.website.title',
|
||||
descKey: 'home.card.website.desc',
|
||||
linkKey: 'home.card.website.link',
|
||||
href: '/website',
|
||||
},
|
||||
]
|
||||
|
||||
type SectionCounts = Partial<Record<CountKey, number>>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { InvoiceDraftFields } from '../components/InvoiceDraftFields'
|
||||
import { isEmptyRichText, RichTextEditor } from '../components/RichTextEditor'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createInvoiceTemplate,
|
||||
getInvoiceTemplate,
|
||||
listInvoiceItemTemplates,
|
||||
updateInvoiceTemplate,
|
||||
} from '../services/invoiceService'
|
||||
import type { InvoiceItemTemplate } from '../types/invoice'
|
||||
import {
|
||||
buildAccountsPayload,
|
||||
buildKeyPointsPayload,
|
||||
buildTemplateItemsPayload,
|
||||
draftsFromInvoiceTemplate,
|
||||
emptyDraftItem,
|
||||
type DraftAccount,
|
||||
type DraftKeyPoint,
|
||||
type DraftLineItem,
|
||||
} from '../utils/invoiceDraft'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import formStyles from '../components/InvoiceForm.module.css'
|
||||
import tableStyles from './InvoicesPage.module.css'
|
||||
|
||||
export function InvoiceTemplateEditorPage() {
|
||||
const { templateId } = useParams()
|
||||
const isEdit = Boolean(templateId)
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
const [error, setError] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [topText, setTopText] = useState('')
|
||||
const [items, setItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const itemsRes = await listInvoiceItemTemplates(controller.signal)
|
||||
setItemTemplates(itemsRes.items.filter((i) => i.isActive))
|
||||
|
||||
if (templateId) {
|
||||
const template = await getInvoiceTemplate(templateId, controller.signal)
|
||||
const drafts = draftsFromInvoiceTemplate(template)
|
||||
setName(drafts.name)
|
||||
setTopText(drafts.topText)
|
||||
setItems(drafts.items)
|
||||
setKeyPoints(drafts.keyPoints)
|
||||
setAccounts(drafts.accounts)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : t('invoiceTemplateEditor.loadError'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [templateId])
|
||||
|
||||
async function handleSave() {
|
||||
setFormError('')
|
||||
if (!name.trim()) {
|
||||
setFormError(t('invoiceTemplateEditor.nameRequired'))
|
||||
return
|
||||
}
|
||||
let lineItems
|
||||
try {
|
||||
lineItems = buildTemplateItemsPayload(items, t)
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : t('invoiceTemplateEditor.saveError'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
topText: isEmptyRichText(topText) ? undefined : topText,
|
||||
items: lineItems,
|
||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||
accounts: buildAccountsPayload(accounts),
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (templateId) {
|
||||
await updateInvoiceTemplate(templateId, payload)
|
||||
showToast(t('invoiceTemplateEditor.updated'), 'success')
|
||||
} else {
|
||||
await createInvoiceTemplate(payload)
|
||||
showToast(t('invoiceTemplateEditor.created'), 'success')
|
||||
}
|
||||
navigate('/invoices/templates')
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : t('invoiceTemplateEditor.saveError'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<Link to="/invoices/templates" className={formStyles.backLink}>
|
||||
<ArrowLeft size={16} />
|
||||
{t('invoiceTemplateEditor.backToTemplates')}
|
||||
</Link>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? t('invoiceTemplateEditor.editTitle') : t('invoiceTemplateEditor.addTitle')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('invoiceTemplateEditor.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className={formStyles.alertError}>{error}</p> : null}
|
||||
{loading ? <p className={tableStyles.meta}>{t('invoiceTemplateEditor.loading')}</p> : null}
|
||||
|
||||
{!loading ? (
|
||||
<section className={formStyles.section}>
|
||||
<div className={formStyles.metaGrid}>
|
||||
<div className={`${formStyles.field} ${formStyles.span2}`}>
|
||||
<label htmlFor="tpl-name">{t('invoiceTemplateEditor.name')}</label>
|
||||
<input
|
||||
id="tpl-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('invoiceTemplateEditor.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${formStyles.field} ${formStyles.fullField}`}>
|
||||
<label>{t('invoiceTemplateEditor.topText')}</label>
|
||||
<RichTextEditor
|
||||
value={topText}
|
||||
onChange={setTopText}
|
||||
placeholder={t('invoiceTemplateEditor.topTextPlaceholder')}
|
||||
editorMinHeight={88}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InvoiceDraftFields
|
||||
itemTemplates={itemTemplates}
|
||||
items={items}
|
||||
keyPoints={keyPoints}
|
||||
accounts={accounts}
|
||||
selectedItemTemplateId={selectedItemTemplateId}
|
||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||
onItemsChange={setItems}
|
||||
onKeyPointsChange={setKeyPoints}
|
||||
onAccountsChange={setAccounts}
|
||||
/>
|
||||
|
||||
{formError ? <p className={formStyles.alertError}>{formError}</p> : null}
|
||||
|
||||
<div className={formStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||
onClick={() => navigate('/invoices/templates')}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('invoiceTemplateEditor.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting
|
||||
? t('invoiceTemplateEditor.saving')
|
||||
: isEdit
|
||||
? t('invoiceTemplateEditor.save')
|
||||
: t('invoiceTemplateEditor.create')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
.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;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 2px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
max-width: min(560px, 100%);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topTextPreview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hint a {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Invoice templates list: name+subtext ~ half width (6/12) */
|
||||
.invoiceTemplatesTable col.nameCol {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.invoiceTemplatesTable col.countCol {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.invoiceTemplatesTable col.actionsCol {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
/* Item templates: title+desc ~ half width (6/12), rest share the other half */
|
||||
.itemTemplatesTable col.nameCol {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.itemTemplatesTable col.durationCol,
|
||||
.itemTemplatesTable col.worktimeCol {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.itemTemplatesTable col.priceCol,
|
||||
.itemTemplatesTable col.discountedCol {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.itemTemplatesTable col.actionsCol {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.nameCell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nameCell .subText,
|
||||
.nameCellDesc,
|
||||
.nameCell .topTextPreview {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sectionHeader {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createInvoiceItemTemplate,
|
||||
deleteInvoiceItemTemplate,
|
||||
deleteInvoiceTemplate,
|
||||
listInvoiceItemTemplates,
|
||||
listInvoiceTemplates,
|
||||
updateInvoiceItemTemplate,
|
||||
} from '../services/invoiceService'
|
||||
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
|
||||
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import formStyles from '../components/InvoiceForm.module.css'
|
||||
import tableStyles from './InvoicesPage.module.css'
|
||||
import styles from './InvoiceTemplatesPage.module.css'
|
||||
|
||||
type ItemDraft = {
|
||||
title: string
|
||||
duration: string
|
||||
worktime: string
|
||||
description: string
|
||||
price: string
|
||||
discountedPrice: string
|
||||
}
|
||||
|
||||
const EMPTY_ITEM_DRAFT: ItemDraft = {
|
||||
title: '',
|
||||
duration: '',
|
||||
worktime: '',
|
||||
description: '',
|
||||
price: '',
|
||||
discountedPrice: '',
|
||||
}
|
||||
|
||||
function itemDraftFromTemplate(template: InvoiceItemTemplate): ItemDraft {
|
||||
return {
|
||||
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 InvoiceTemplatesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [itemEditorOpen, setItemEditorOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<InvoiceItemTemplate | null>(null)
|
||||
const [itemDraft, setItemDraft] = useState<ItemDraft>(EMPTY_ITEM_DRAFT)
|
||||
const [itemSubmitting, setItemSubmitting] = useState(false)
|
||||
const [itemFormError, setItemFormError] = useState('')
|
||||
const [removeItemTarget, setRemoveItemTarget] = useState<InvoiceItemTemplate | null>(null)
|
||||
const [removeTplTarget, setRemoveTplTarget] = useState<InvoiceTemplate | null>(null)
|
||||
|
||||
function itemDraftToPayload(draft: ItemDraft) {
|
||||
const price = parseIrtInput(draft.price)
|
||||
if (!draft.title.trim()) throw new Error(t('invoiceDraft.error.itemTitle'))
|
||||
if (price === null) throw new Error(t('invoiceDraft.error.itemPrice', { title: draft.title.trim() }))
|
||||
const discountedPrice = draft.discountedPrice.trim() ? parseIrtInput(draft.discountedPrice) : null
|
||||
if (draft.discountedPrice.trim() && discountedPrice === null) {
|
||||
throw new Error(t('invoiceDraft.error.itemDiscountInvalid', { title: draft.title.trim() }))
|
||||
}
|
||||
if (discountedPrice !== null && discountedPrice > price) {
|
||||
throw new Error(t('invoiceDraft.error.itemDiscountExceeds', { title: draft.title.trim() }))
|
||||
}
|
||||
return {
|
||||
title: draft.title.trim(),
|
||||
duration: draft.duration.trim() || undefined,
|
||||
worktime: draft.worktime.trim() || undefined,
|
||||
description: draft.description.trim() || undefined,
|
||||
price,
|
||||
discountedPrice,
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [items, templates] = await Promise.all([
|
||||
listInvoiceItemTemplates(signal),
|
||||
listInvoiceTemplates(signal),
|
||||
])
|
||||
setItemTemplates(items.items)
|
||||
setInvoiceTemplates(templates.items)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : t('invoiceTemplates.loadError'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void reload(controller.signal)
|
||||
return () => controller.abort()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function openCreateItem() {
|
||||
setEditingItem(null)
|
||||
setItemDraft(EMPTY_ITEM_DRAFT)
|
||||
setItemFormError('')
|
||||
setItemEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEditItem(template: InvoiceItemTemplate) {
|
||||
setEditingItem(template)
|
||||
setItemDraft(itemDraftFromTemplate(template))
|
||||
setItemFormError('')
|
||||
setItemEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSaveItem() {
|
||||
setItemFormError('')
|
||||
let payload
|
||||
try {
|
||||
payload = itemDraftToPayload(itemDraft)
|
||||
} catch (err) {
|
||||
setItemFormError(err instanceof Error ? err.message : t('invoiceTemplates.itemSaveError'))
|
||||
return
|
||||
}
|
||||
setItemSubmitting(true)
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateInvoiceItemTemplate(editingItem.id, payload)
|
||||
showToast(t('invoiceTemplates.itemUpdated'), 'success')
|
||||
} else {
|
||||
await createInvoiceItemTemplate(payload)
|
||||
showToast(t('invoiceTemplates.itemCreated'), 'success')
|
||||
}
|
||||
setItemEditorOpen(false)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setItemFormError(err instanceof ApiError ? err.message : t('invoiceTemplates.itemSaveError'))
|
||||
} finally {
|
||||
setItemSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveItem() {
|
||||
if (!removeItemTarget) return
|
||||
try {
|
||||
await deleteInvoiceItemTemplate(removeItemTarget.id)
|
||||
showToast(t('invoiceTemplates.itemRemoved'), 'success')
|
||||
setRemoveItemTarget(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : t('invoiceTemplates.itemRemoveError'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveTpl() {
|
||||
if (!removeTplTarget) return
|
||||
try {
|
||||
await deleteInvoiceTemplate(removeTplTarget.id)
|
||||
showToast(t('invoiceTemplates.templateRemoved'), 'success')
|
||||
setRemoveTplTarget(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : t('invoiceTemplates.templateRemoveError'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('nav.finance'), href: '/finance' },
|
||||
{ label: t('title.invoices'), href: '/invoices' },
|
||||
{ label: t('title.invoiceTemplates') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="INVOICE TEMPLATES">{t('title.invoiceTemplates')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('invoiceTemplates.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className={formStyles.alertError}>{error}</p> : null}
|
||||
|
||||
<section className={formStyles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitleRow}>
|
||||
<FileStack size={18} />
|
||||
<div>
|
||||
<h3 className={styles.sectionTitle}>{t('invoiceTemplates.section.templates.title')}</h3>
|
||||
<p className={styles.sectionSubtitle}>{t('invoiceTemplates.section.templates.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
onClick={() => navigate('/invoices/templates/new')}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('invoiceTemplates.addTemplate')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.meta}>
|
||||
{loading
|
||||
? t('invoices.loading')
|
||||
: invoiceTemplates.length === 1
|
||||
? t('invoiceTemplates.countTemplatesOne', { count: invoiceTemplates.length })
|
||||
: t('invoiceTemplates.countTemplatesMany', { count: invoiceTemplates.length })}
|
||||
</div>
|
||||
</div>
|
||||
<table className={`${tableStyles.table} ${styles.invoiceTemplatesTable}`}>
|
||||
<colgroup>
|
||||
<col className={styles.nameCol} />
|
||||
<col className={styles.countCol} />
|
||||
<col className={styles.countCol} />
|
||||
<col className={styles.countCol} />
|
||||
<col className={styles.actionsCol} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.name')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.items')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.keyPoints')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.accounts')}</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>
|
||||
{t('invoiceTemplates.col.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && invoiceTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={5}>
|
||||
<div className={styles.emptyState}>
|
||||
<SettingsIcon size={20} />
|
||||
<span>{t('invoiceTemplates.emptyTemplates')}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{invoiceTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
<td className={`${tableStyles.td} ${styles.nameCell}`}>
|
||||
<div className={tableStyles.itemTitle}>{template.name}</div>
|
||||
{template.topText && !isEmptyRichText(template.topText) ? (
|
||||
<div
|
||||
className={`${tableStyles.subText} ${styles.topTextPreview}`}
|
||||
title={richTextToPlain(template.topText)}
|
||||
>
|
||||
{richTextToPlain(template.topText)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className={tableStyles.td}>{template.items.length}</td>
|
||||
<td className={tableStyles.td}>{template.keyPoints.length}</td>
|
||||
<td className={tableStyles.td}>{template.accounts.length}</td>
|
||||
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||
<div className={tableStyles.rowActions}>
|
||||
<Tooltip label={t('invoiceTemplates.editTemplate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.actionBtn}
|
||||
onClick={() => navigate(`/invoices/templates/${template.id}`)}
|
||||
aria-label={t('invoiceTemplates.editTemplate')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('invoiceTemplates.removeTemplate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveTplTarget(template)}
|
||||
aria-label={t('invoiceTemplates.removeTemplate')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${formStyles.section} ${formStyles.sectionSpaced}`}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitleRow}>
|
||||
<FileText size={18} />
|
||||
<div>
|
||||
<h3 className={styles.sectionTitle}>{t('invoiceTemplates.section.items.title')}</h3>
|
||||
<p className={styles.sectionSubtitle}>{t('invoiceTemplates.section.items.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
onClick={openCreateItem}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('invoiceTemplates.addItem')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.meta}>
|
||||
{loading
|
||||
? t('invoices.loading')
|
||||
: itemTemplates.length === 1
|
||||
? t('invoiceTemplates.countItemsOne', { count: itemTemplates.length })
|
||||
: t('invoiceTemplates.countItemsMany', { count: itemTemplates.length })}
|
||||
</div>
|
||||
</div>
|
||||
<table className={`${tableStyles.table} ${styles.itemTemplatesTable}`}>
|
||||
<colgroup>
|
||||
<col className={styles.nameCol} />
|
||||
<col className={styles.durationCol} />
|
||||
<col className={styles.worktimeCol} />
|
||||
<col className={styles.priceCol} />
|
||||
<col className={styles.discountedCol} />
|
||||
<col className={styles.actionsCol} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.title')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.duration')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.worktime')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.price')}</th>
|
||||
<th className={tableStyles.th}>{t('invoiceTemplates.col.discounted')}</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>
|
||||
{t('invoiceTemplates.col.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && itemTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
<div className={styles.emptyState}>
|
||||
<SettingsIcon size={20} />
|
||||
<span>{t('invoiceTemplates.emptyItems')}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{itemTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
<td className={`${tableStyles.td} ${styles.nameCell}`}>
|
||||
<div className={tableStyles.itemTitle}>{template.title}</div>
|
||||
{template.description ? (
|
||||
<div className={`${tableStyles.subText} ${styles.nameCellDesc}`}>
|
||||
{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}>
|
||||
<Tooltip label={t('invoiceTemplates.editItem')}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.actionBtn}
|
||||
onClick={() => openEditItem(template)}
|
||||
aria-label={t('invoiceTemplates.editItem')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('invoiceTemplates.removeItem')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveItemTarget(template)}
|
||||
aria-label={t('invoiceTemplates.removeItem')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className={styles.hint}>{t('invoiceTemplates.hint')}</p>
|
||||
|
||||
<Modal
|
||||
open={itemEditorOpen}
|
||||
title={editingItem ? t('invoiceTemplates.itemModalEditTitle') : t('invoiceTemplates.itemModalAddTitle')}
|
||||
onClose={() => !itemSubmitting && setItemEditorOpen(false)}
|
||||
wide
|
||||
>
|
||||
<div className={formStyles.formGrid}>
|
||||
<div className={`${formStyles.field} ${formStyles.span2}`}>
|
||||
<label htmlFor="item-title">{t('invoiceDraft.fieldTitle')}</label>
|
||||
<input
|
||||
id="item-title"
|
||||
value={itemDraft.title}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, title: e.target.value }))}
|
||||
placeholder={t('invoiceDraft.fieldTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="item-duration">{t('invoiceDraft.fieldDuration')}</label>
|
||||
<input
|
||||
id="item-duration"
|
||||
value={itemDraft.duration}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, duration: e.target.value }))}
|
||||
placeholder={t('invoiceDraft.fieldDurationPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="item-worktime">{t('invoiceDraft.fieldWorktime')}</label>
|
||||
<input
|
||||
id="item-worktime"
|
||||
value={itemDraft.worktime}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, worktime: e.target.value }))}
|
||||
placeholder={t('invoiceDraft.fieldWorktimePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="item-price">{t('invoiceDraft.fieldPrice')}</label>
|
||||
<input
|
||||
id="item-price"
|
||||
inputMode="numeric"
|
||||
value={itemDraft.price}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, price: formatIrtInput(e.target.value) }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="item-discount">{t('invoiceDraft.fieldDiscountedPrice')}</label>
|
||||
<input
|
||||
id="item-discount"
|
||||
inputMode="numeric"
|
||||
value={itemDraft.discountedPrice}
|
||||
onChange={(e) =>
|
||||
setItemDraft((d) => ({ ...d, discountedPrice: formatIrtInput(e.target.value) }))
|
||||
}
|
||||
placeholder={t('invoiceDraft.optional')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${formStyles.field} ${formStyles.span2}`}>
|
||||
<label htmlFor="item-desc">{t('invoiceDraft.fieldDescription')}</label>
|
||||
<textarea
|
||||
id="item-desc"
|
||||
rows={3}
|
||||
value={itemDraft.description}
|
||||
onChange={(e) => setItemDraft((d) => ({ ...d, description: e.target.value }))}
|
||||
placeholder={t('invoiceDraft.fieldDescriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{itemFormError ? <p className={formStyles.alertError}>{itemFormError}</p> : null}
|
||||
<div className={formStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||
onClick={() => setItemEditorOpen(false)}
|
||||
disabled={itemSubmitting}
|
||||
>
|
||||
{t('invoiceTemplates.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
onClick={() => void handleSaveItem()}
|
||||
disabled={itemSubmitting}
|
||||
>
|
||||
{itemSubmitting
|
||||
? t('invoiceTemplates.savingItem')
|
||||
: editingItem
|
||||
? t('invoiceTemplates.saveItem')
|
||||
: t('invoiceTemplates.createItem')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeTplTarget}
|
||||
title={t('invoiceTemplates.deleteTemplateTitle')}
|
||||
message={
|
||||
removeTplTarget
|
||||
? t('invoiceTemplates.deleteTemplateMessage', { name: removeTplTarget.name })
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTplTarget(null)}
|
||||
onConfirm={() => void handleRemoveTpl()}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeItemTarget}
|
||||
title={t('invoiceTemplates.deleteItemTitle')}
|
||||
message={
|
||||
removeItemTarget
|
||||
? t('invoiceTemplates.deleteItemMessage', { title: removeItemTarget.title })
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveItemTarget(null)}
|
||||
onConfirm={() => void handleRemoveItem()}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
.tablePanel {
|
||||
margin-top: 12px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* 12-col feel: name ≈ 4, customer ≈ 2, rest share remaining */
|
||||
.colName {
|
||||
width: 33.333%;
|
||||
}
|
||||
|
||||
.colCustomer {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.colIssued {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.colStatus {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.colTotal {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.colLink {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.colActions {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.th,
|
||||
.td {
|
||||
padding: 10px 12px;
|
||||
text-align: start;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
vertical-align: middle;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.th {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.td {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.subText {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdCustomer {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.customerStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.customerCell {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.customerPhone {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.recipientHeading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.recipientPrefix {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.recipientName {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.recipientPhoneChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thActions,
|
||||
.tdActions {
|
||||
text-align: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.actionBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.actionBtnDanger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.actionBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pagination > div:first-child {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.statusChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border: 1px solid transparent;
|
||||
transition: filter 0.2s;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.statusChipBtn {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.statusChipBtn:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.statusChipBtn:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.statusChipBtn:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.statusOptionList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusOption {
|
||||
min-height: 32px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statusOptionSelected {
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.status_draft,
|
||||
.status_issued {
|
||||
color: #0f172a;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
border-color: rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.status_approved {
|
||||
color: #0e7490;
|
||||
background: rgba(6, 182, 212, 0.14);
|
||||
border-color: rgba(6, 182, 212, 0.32);
|
||||
}
|
||||
|
||||
.status_paid {
|
||||
color: #15803d;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
border-color: rgba(34, 197, 94, 0.32);
|
||||
}
|
||||
|
||||
.status_cancelled {
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.28);
|
||||
}
|
||||
|
||||
.publicLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.linkRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.filterBanner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filterBannerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterBannerCancel {
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filterBannerCancel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.filterBannerClear {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.filterBannerClear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detailMeta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.detailMeta .statusChipBtn {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.blockSection {
|
||||
margin: 16px 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.blockTitle {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.keyPointList {
|
||||
margin: 0;
|
||||
padding-inline-start: 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detailMeta {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detailMeta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
inset-inline-end: 32px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 32px;
|
||||
z-index: 110;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fab {
|
||||
inset-inline-end: 20px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 20px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Copy, Eye, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { isEmptyRichText } from '../components/RichTextEditor'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { getInvoicePublicUrlFallback } from '../lib/config'
|
||||
import { getActiveBusinessDomain } from '../lib/businessContext'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import { deleteInvoice, listInvoices, updateInvoiceStatus } from '../services/invoiceService'
|
||||
import type { BusinessCustomer } from '../services/customerService'
|
||||
import type { Invoice, InvoiceStatus } from '../types/invoice'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import formStyles from '../components/InvoiceForm.module.css'
|
||||
import styles from './InvoicesPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'approved', 'paid', 'cancelled']
|
||||
|
||||
function formatDate(value: string, locale: 'en' | 'fa') {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
||||
return d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
}
|
||||
|
||||
function statusLabelKey(status: InvoiceStatus): BusinessMessageKey {
|
||||
return `invoices.status.${status}` as BusinessMessageKey
|
||||
}
|
||||
|
||||
function statusClass(status: InvoiceStatus) {
|
||||
return styles[`status_${status}` as keyof typeof styles] ?? styles.status_draft
|
||||
}
|
||||
|
||||
function canEditInvoice(status: InvoiceStatus) {
|
||||
return status !== 'approved'
|
||||
}
|
||||
|
||||
function allowedStatusOptions(current: InvoiceStatus): InvoiceStatus[] {
|
||||
if (current !== 'approved') return STATUS_OPTIONS
|
||||
return STATUS_OPTIONS.filter(
|
||||
(option) => option === 'approved' || option === 'paid' || option === 'cancelled',
|
||||
)
|
||||
}
|
||||
|
||||
function customerName(invoice: Invoice): string {
|
||||
const name = [invoice.user?.firstName, invoice.user?.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || ''
|
||||
}
|
||||
|
||||
export function InvoicesPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const filterUserId = searchParams.get('userId') || ''
|
||||
const presetCustomer = (location.state as { presetCustomer?: BusinessCustomer } | null)
|
||||
?.presetCustomer
|
||||
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
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 [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null)
|
||||
const [statusTarget, setStatusTarget] = useState<Invoice | null>(null)
|
||||
const [statusUpdating, setStatusUpdating] = useState<InvoiceStatus | null>(null)
|
||||
const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null)
|
||||
|
||||
async function reload(signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const list = await listInvoices(
|
||||
{ page, pageSize: PAGE_SIZE, userId: filterUserId || undefined },
|
||||
signal,
|
||||
)
|
||||
setInvoices(list.items)
|
||||
setTotalPages(list.totalPages)
|
||||
setTotal(list.total)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : t('invoices.loadError'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void reload(controller.signal)
|
||||
return () => controller.abort()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, filterUserId])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [filterUserId])
|
||||
|
||||
const filterCustomerMatch = useMemo(() => {
|
||||
if (!filterUserId) return undefined
|
||||
return invoices.find((inv) => inv.userId === filterUserId)
|
||||
}, [invoices, filterUserId])
|
||||
|
||||
const filterCustomerName = useMemo(() => {
|
||||
if (!filterUserId) return ''
|
||||
if (presetCustomer && presetCustomer.id === filterUserId) {
|
||||
const fromPreset = [presetCustomer.firstName, presetCustomer.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
if (fromPreset) return fromPreset
|
||||
if (presetCustomer.label?.trim()) return presetCustomer.label.trim()
|
||||
if (presetCustomer.cellNumber) return formatCellForDisplay(presetCustomer.cellNumber)
|
||||
}
|
||||
if (filterCustomerMatch) {
|
||||
const fromInvoice = customerName(filterCustomerMatch)
|
||||
if (fromInvoice) return fromInvoice
|
||||
}
|
||||
return ''
|
||||
}, [filterUserId, presetCustomer, filterCustomerMatch])
|
||||
|
||||
const issueState =
|
||||
presetCustomer && presetCustomer.id === filterUserId
|
||||
? { presetCustomer }
|
||||
: filterCustomerMatch?.user
|
||||
? {
|
||||
presetCustomer: {
|
||||
id: filterCustomerMatch.user.id,
|
||||
cellNumber: filterCustomerMatch.user.cell,
|
||||
firstName: filterCustomerMatch.user.firstName,
|
||||
lastName: filterCustomerMatch.user.lastName,
|
||||
email: null,
|
||||
label: filterCustomerName || filterCustomerMatch.user.cell,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
function clearFilter() {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('userId')
|
||||
setSearchParams(next, { replace: true, state: null })
|
||||
}
|
||||
|
||||
function openStatusModal(invoice: Invoice) {
|
||||
setStatusTarget(invoice)
|
||||
}
|
||||
|
||||
async function handleStatusSelect(next: InvoiceStatus) {
|
||||
if (!statusTarget || statusUpdating) return
|
||||
if (next === statusTarget.status) {
|
||||
setStatusTarget(null)
|
||||
return
|
||||
}
|
||||
|
||||
setStatusUpdating(next)
|
||||
try {
|
||||
const updated = await updateInvoiceStatus(statusTarget.id, { status: next })
|
||||
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
|
||||
setDetailInvoice((current) => (current?.id === updated.id ? updated : current))
|
||||
showToast(t('invoices.statusUpdated'), 'success')
|
||||
setStatusTarget(null)
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : t('invoices.statusUpdateError'), 'error')
|
||||
} finally {
|
||||
setStatusUpdating(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!removeTarget) return
|
||||
try {
|
||||
await deleteInvoice(removeTarget.id)
|
||||
showToast(t('invoices.removed'), 'success')
|
||||
setRemoveTarget(null)
|
||||
if (detailInvoice?.id === removeTarget.id) setDetailInvoice(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : t('invoices.removeError'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function invoicePublicUrl(invoice: Invoice) {
|
||||
return (
|
||||
invoice.publicUrl ||
|
||||
getInvoicePublicUrlFallback(invoice.publicId, getActiveBusinessDomain())
|
||||
)
|
||||
}
|
||||
|
||||
async function copyPublicLink(invoice: Invoice) {
|
||||
const url = invoicePublicUrl(invoice)
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
showToast(t('invoices.linkCopied'), 'success')
|
||||
} catch {
|
||||
showToast(t('invoices.linkCopyError'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const issueHref = filterUserId ? `/invoices/new?userId=${filterUserId}` : '/invoices/new'
|
||||
const countLabel =
|
||||
total === 1 ? t('invoices.countOne', { count: total }) : t('invoices.countMany', { count: total })
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('nav.finance'), href: '/finance' },
|
||||
{ label: t('title.invoices') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="INVOICES">{t('title.invoices')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('invoices.subtitle')}</p>
|
||||
</div>
|
||||
<Link to="/invoices/templates" className={`${formStyles.btn} ${formStyles.btnGhost}`}>
|
||||
{t('invoices.templatesLink')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{filterUserId ? (
|
||||
<div className={styles.filterBanner}>
|
||||
<span>
|
||||
{filterCustomerName
|
||||
? t('invoices.filterBannerNamed', { name: filterCustomerName })
|
||||
: t('invoices.filterBannerGeneric')}
|
||||
</span>
|
||||
<button type="button" className={styles.filterBannerCancel} onClick={clearFilter}>
|
||||
{t('invoices.filterCancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className={pageStyles.pageSubtitleContent}>{error}</p> : null}
|
||||
|
||||
<div className={styles.tablePanel}>
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.meta}>{loading ? t('invoices.loading') : countLabel}</div>
|
||||
</div>
|
||||
<table className={styles.table}>
|
||||
<colgroup>
|
||||
<col className={styles.colName} />
|
||||
<col className={styles.colCustomer} />
|
||||
<col className={styles.colIssued} />
|
||||
<col className={styles.colStatus} />
|
||||
<col className={styles.colTotal} />
|
||||
<col className={styles.colLink} />
|
||||
<col className={styles.colActions} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th}>{t('invoices.col.name')}</th>
|
||||
<th className={styles.th}>{t('invoices.col.customer')}</th>
|
||||
<th className={styles.th}>{t('invoices.col.issued')}</th>
|
||||
<th className={styles.th}>{t('invoices.col.status')}</th>
|
||||
<th className={styles.th}>{t('invoices.col.total')}</th>
|
||||
<th className={styles.th}>{t('invoices.col.link')}</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>{t('invoices.col.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && invoices.length === 0 ? (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={7}>
|
||||
{t('invoices.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{invoices.map((invoice) => {
|
||||
const itemCount = invoice.items?.length ?? 0
|
||||
const name = customerName(invoice)
|
||||
return (
|
||||
<tr key={invoice.id}>
|
||||
<td className={styles.td}>
|
||||
<div className={styles.itemTitle}>{invoice.name || `#${invoice.id}`}</div>
|
||||
<div className={styles.subText}>
|
||||
{itemCount === 1
|
||||
? t('invoices.itemCountOne', { count: itemCount })
|
||||
: t('invoices.itemCountMany', { count: itemCount })}
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdCustomer}`}>
|
||||
<div className={styles.customerStack}>
|
||||
<div className={styles.customerCell}>{name || '—'}</div>
|
||||
{invoice.user?.cell ? (
|
||||
<div className={styles.customerPhone} dir="ltr">
|
||||
{formatCellForDisplay(invoice.user.cell)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.td}>{formatDate(invoice.issuedAt, locale)}</td>
|
||||
<td className={styles.td}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(invoice.status)}`}
|
||||
onClick={() => openStatusModal(invoice)}
|
||||
title={t('invoices.changeStatus')}
|
||||
aria-label={t('invoices.changeStatusAria', {
|
||||
status: t(statusLabelKey(invoice.status)),
|
||||
})}
|
||||
>
|
||||
{t(statusLabelKey(invoice.status))}
|
||||
</button>
|
||||
</td>
|
||||
<td className={styles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
|
||||
<td className={styles.td}>
|
||||
<div className={styles.linkRow}>
|
||||
<a
|
||||
className={styles.publicLink}
|
||||
href={invoicePublicUrl(invoice)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={invoicePublicUrl(invoice)}
|
||||
>
|
||||
{invoicePublicUrl(invoice).replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
<Tooltip label={t('invoices.copyLink')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyLinkBtn}
|
||||
onClick={() => void copyPublicLink(invoice)}
|
||||
aria-label={t('invoices.copyLinkAria')}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdActions}`}>
|
||||
<div className={styles.rowActions}>
|
||||
<Tooltip label={t('invoices.view')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setDetailInvoice(invoice)}
|
||||
aria-label={t('invoices.viewAria')}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
canEditInvoice(invoice.status)
|
||||
? t('invoices.edit')
|
||||
: t('invoices.editDisabledTitle')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
disabled={!canEditInvoice(invoice.status)}
|
||||
onClick={() => navigate(`/invoices/${invoice.id}/edit`)}
|
||||
aria-label={
|
||||
canEditInvoice(invoice.status)
|
||||
? t('invoices.editAria')
|
||||
: t('invoices.editDisabledAria')
|
||||
}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('invoices.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(invoice)}
|
||||
aria-label={t('invoices.removeAria')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className={styles.pagination}>
|
||||
<div>{countLabel}</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={!!detailInvoice}
|
||||
title={detailInvoice ? detailInvoice.name || t('invoices.detailFallbackTitle') : ''}
|
||||
onClose={() => setDetailInvoice(null)}
|
||||
xl
|
||||
>
|
||||
{detailInvoice ? (
|
||||
<>
|
||||
<div className={styles.detailMeta}>
|
||||
<div>
|
||||
<span className={styles.detailLabel}>{t('invoices.detail.billedTo')}</span>
|
||||
<strong>{customerName(detailInvoice) || '—'}</strong>
|
||||
{detailInvoice.user?.cell ? (
|
||||
<div className={styles.subText} dir="ltr">
|
||||
{formatCellForDisplay(detailInvoice.user.cell)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.detailLabel}>{t('invoices.detail.status')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(detailInvoice.status)}`}
|
||||
onClick={() => openStatusModal(detailInvoice)}
|
||||
title={t('invoices.changeStatus')}
|
||||
aria-label={t('invoices.changeStatusAria', {
|
||||
status: t(statusLabelKey(detailInvoice.status)),
|
||||
})}
|
||||
>
|
||||
{t(statusLabelKey(detailInvoice.status))}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.detailLabel}>{t('invoices.detail.total')}</span>
|
||||
<strong>{formatIrtPrice(detailInvoice.total ?? 0)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailMeta} style={{ gridTemplateColumns: '1fr' }}>
|
||||
<div>
|
||||
<span className={styles.detailLabel}>{t('invoices.detail.link')}</span>
|
||||
<div className={styles.linkRow}>
|
||||
<a
|
||||
className={styles.publicLink}
|
||||
href={invoicePublicUrl(detailInvoice)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{invoicePublicUrl(detailInvoice).replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
<Tooltip label={t('invoices.copyLink')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyLinkBtn}
|
||||
onClick={() => void copyPublicLink(detailInvoice)}
|
||||
aria-label={t('invoices.copyLinkAria')}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailInvoice.topText && !isEmptyRichText(detailInvoice.topText) ? (
|
||||
<div
|
||||
className={styles.detailNotes}
|
||||
dangerouslySetInnerHTML={{ __html: detailInvoice.topText }}
|
||||
/>
|
||||
) : null}
|
||||
{detailInvoice.notes ? (
|
||||
<p className={styles.detailNotes}>
|
||||
{t('invoices.detail.notes', { notes: 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>{item.duration}</span> : null}
|
||||
{item.worktime ? <span>{item.worktime}</span> : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<p className={styles.detailItemDesc}>{item.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(detailInvoice.keyPoints?.length ?? 0) > 0 ? (
|
||||
<div className={styles.blockSection}>
|
||||
<h4 className={styles.blockTitle}>{t('invoiceDraft.keyPoints')}</h4>
|
||||
<ul className={styles.keyPointList}>
|
||||
{detailInvoice.keyPoints!.map((kp) => (
|
||||
<li key={kp.id}>{kp.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(detailInvoice.accounts?.length ?? 0) > 0 ? (
|
||||
<div className={styles.blockSection}>
|
||||
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
||||
<div className={styles.detailItems}>
|
||||
{detailInvoice.accounts!.map((acc) => (
|
||||
<div key={acc.id} className={styles.detailItem}>
|
||||
<strong>{acc.bankName}</strong>
|
||||
<div className={styles.detailItemMeta}>
|
||||
{acc.accountHolderName ? <span>{acc.accountHolderName}</span> : null}
|
||||
{acc.cardNumber ? <span dir="ltr">{acc.cardNumber}</span> : null}
|
||||
{acc.iban ? <span dir="ltr">{acc.iban}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={!!statusTarget}
|
||||
title={t('invoices.changeStatus')}
|
||||
onClose={() => {
|
||||
if (!statusUpdating) setStatusTarget(null)
|
||||
}}
|
||||
>
|
||||
{statusTarget ? (
|
||||
<div className={styles.statusOptionList} role="listbox" aria-label={t('invoices.col.status')}>
|
||||
{allowedStatusOptions(statusTarget.status).map((option) => {
|
||||
const selected = statusTarget.status === option
|
||||
const saving = statusUpdating === option
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`${styles.statusChip} ${styles.statusChipBtn} ${styles.statusOption} ${statusClass(option)} ${selected ? styles.statusOptionSelected : ''}`}
|
||||
disabled={Boolean(statusUpdating)}
|
||||
onClick={() => void handleStatusSelect(option)}
|
||||
>
|
||||
{saving ? t('issueInvoice.saving') : t(statusLabelKey(option))}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeTarget}
|
||||
title={t('invoices.deleteTitle')}
|
||||
message={t('invoices.deleteMessage')}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
onConfirm={() => void handleRemove()}
|
||||
/>
|
||||
|
||||
<Tooltip label={t('invoices.issue')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.fab}
|
||||
onClick={() => navigate(issueHref, { state: issueState })}
|
||||
aria-label={t('invoices.issue')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { CustomerSearchSelect } from '../components/CustomerSearchSelect'
|
||||
import { InvoiceDraftFields } from '../components/InvoiceDraftFields'
|
||||
import { isEmptyRichText, RichTextEditor } from '../components/RichTextEditor'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createInvoice,
|
||||
getInvoice,
|
||||
listInvoiceItemTemplates,
|
||||
listInvoiceTemplates,
|
||||
updateInvoice,
|
||||
} from '../services/invoiceService'
|
||||
import { listCustomers, type BusinessCustomer } from '../services/customerService'
|
||||
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
|
||||
import {
|
||||
buildAccountsPayload,
|
||||
buildKeyPointsPayload,
|
||||
buildLineItemsPayload,
|
||||
draftsFromInvoice,
|
||||
draftsFromInvoiceTemplate,
|
||||
emptyDraftItem,
|
||||
type DraftAccount,
|
||||
type DraftKeyPoint,
|
||||
type DraftLineItem,
|
||||
} from '../utils/invoiceDraft'
|
||||
import { formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import formStyles from '../components/InvoiceForm.module.css'
|
||||
import styles from './InvoicesPage.module.css'
|
||||
|
||||
function effectivePrice(price: number, discountedPrice: number | null | undefined) {
|
||||
if (discountedPrice !== null && discountedPrice !== undefined && discountedPrice < price) {
|
||||
return discountedPrice
|
||||
}
|
||||
return price
|
||||
}
|
||||
|
||||
function customerFromInvoiceUser(user: {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
cell: string
|
||||
}): BusinessCustomer {
|
||||
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||
return {
|
||||
id: user.id,
|
||||
cellNumber: user.cell,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: null,
|
||||
label: name || user.cell,
|
||||
}
|
||||
}
|
||||
|
||||
export function IssueInvoicePage() {
|
||||
const { invoiceId = '' } = useParams()
|
||||
const isEdit = Boolean(invoiceId)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
|
||||
const urlUserId = searchParams.get('userId') || ''
|
||||
const presetCustomer = (location.state as { presetCustomer?: BusinessCustomer } | null)
|
||||
?.presetCustomer
|
||||
|
||||
const [itemTemplates, setItemTemplates] = useState<InvoiceItemTemplate[]>([])
|
||||
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [locked, setLocked] = useState(false)
|
||||
|
||||
const [billedCustomer, setBilledCustomer] = useState<BusinessCustomer | null>(
|
||||
presetCustomer && presetCustomer.id === urlUserId ? presetCustomer : null,
|
||||
)
|
||||
const [billedUserId, setBilledUserId] = useState(urlUserId)
|
||||
const customerLocked = isEdit || Boolean(urlUserId)
|
||||
|
||||
const [sourceTemplateId, setSourceTemplateId] = useState('')
|
||||
const [invoiceTemplateId, setInvoiceTemplateId] = useState<string | undefined>()
|
||||
const [invoiceName, setInvoiceName] = useState('')
|
||||
const [topText, setTopText] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [draftItems, setDraftItems] = useState<DraftLineItem[]>([emptyDraftItem()])
|
||||
const [keyPoints, setKeyPoints] = useState<DraftKeyPoint[]>([])
|
||||
const [accounts, setAccounts] = useState<DraftAccount[]>([])
|
||||
const [selectedItemTemplateId, setSelectedItemTemplateId] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const listPath = '/invoices'
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setLocked(false)
|
||||
try {
|
||||
const [items, templates] = await Promise.all([
|
||||
listInvoiceItemTemplates(controller.signal),
|
||||
listInvoiceTemplates(controller.signal),
|
||||
])
|
||||
setItemTemplates(items.items.filter((i) => i.isActive))
|
||||
setInvoiceTemplates(templates.items.filter((i) => i.isActive))
|
||||
|
||||
if (invoiceId) {
|
||||
const invoice = await getInvoice(invoiceId, controller.signal)
|
||||
if (invoice.status === 'approved') {
|
||||
setLocked(true)
|
||||
setError(t('issueInvoice.approvedLocked'))
|
||||
}
|
||||
if (invoice.user) {
|
||||
setBilledCustomer(customerFromInvoiceUser(invoice.user))
|
||||
}
|
||||
setBilledUserId(invoice.userId)
|
||||
const drafts = draftsFromInvoice(invoice)
|
||||
setSourceTemplateId('')
|
||||
setInvoiceTemplateId(invoice.invoiceTemplateId ?? undefined)
|
||||
setInvoiceName(drafts.name)
|
||||
setTopText(drafts.topText)
|
||||
setNotes(drafts.notes)
|
||||
setDraftItems(drafts.items)
|
||||
setKeyPoints(drafts.keyPoints)
|
||||
setAccounts(drafts.accounts)
|
||||
setSelectedItemTemplateId('')
|
||||
} else if (urlUserId && !presetCustomer) {
|
||||
const listed = await listCustomers({ page: 1, pageSize: 100 }, controller.signal)
|
||||
const match = listed.items.find((c) => c.id === urlUserId)
|
||||
if (match) {
|
||||
setBilledCustomer({
|
||||
id: match.id,
|
||||
cellNumber: match.cellNumber,
|
||||
firstName: match.firstName,
|
||||
lastName: match.lastName,
|
||||
email: match.email,
|
||||
label: match.label,
|
||||
})
|
||||
setBilledUserId(match.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : t('issueInvoice.loadError'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invoiceId])
|
||||
|
||||
function resetBlankDraft() {
|
||||
setSourceTemplateId('')
|
||||
setInvoiceTemplateId(undefined)
|
||||
setInvoiceName('')
|
||||
setTopText('')
|
||||
setNotes('')
|
||||
setDraftItems([emptyDraftItem()])
|
||||
setKeyPoints([])
|
||||
setAccounts([])
|
||||
setSelectedItemTemplateId('')
|
||||
setFormError('')
|
||||
}
|
||||
|
||||
function applyInvoiceTemplate(templateId: string) {
|
||||
if (locked) return
|
||||
setSourceTemplateId(templateId)
|
||||
if (!templateId) {
|
||||
resetBlankDraft()
|
||||
return
|
||||
}
|
||||
const template = invoiceTemplates.find((tpl) => tpl.id === templateId)
|
||||
if (!template) return
|
||||
const drafts = draftsFromInvoiceTemplate(template)
|
||||
setInvoiceTemplateId(template.id)
|
||||
setInvoiceName(drafts.name)
|
||||
setTopText(drafts.topText)
|
||||
setDraftItems(drafts.items)
|
||||
setKeyPoints(drafts.keyPoints)
|
||||
setAccounts(drafts.accounts)
|
||||
setSelectedItemTemplateId('')
|
||||
setFormError('')
|
||||
}
|
||||
|
||||
function handleCustomerChange(customer: BusinessCustomer | null) {
|
||||
setBilledCustomer(customer)
|
||||
setBilledUserId(customer?.id ?? '')
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (locked) return
|
||||
setFormError('')
|
||||
|
||||
if (!billedUserId) {
|
||||
setFormError(t('issueInvoice.recipientRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
let items
|
||||
try {
|
||||
items = buildLineItemsPayload(draftItems, t)
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : t('issueInvoice.loadError'))
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateInvoice(invoiceId, {
|
||||
userId: billedUserId,
|
||||
items,
|
||||
name: invoiceName.trim() || null,
|
||||
topText: isEmptyRichText(topText) ? null : topText,
|
||||
notes: notes.trim() || null,
|
||||
invoiceTemplateId: invoiceTemplateId ?? null,
|
||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||
accounts: buildAccountsPayload(accounts),
|
||||
})
|
||||
showToast(t('issueInvoice.updated'), 'success')
|
||||
} else {
|
||||
await createInvoice({
|
||||
userId: billedUserId,
|
||||
items,
|
||||
name: invoiceName.trim() || undefined,
|
||||
topText: isEmptyRichText(topText) ? undefined : topText,
|
||||
notes: notes.trim() || undefined,
|
||||
invoiceTemplateId,
|
||||
keyPoints: buildKeyPointsPayload(keyPoints),
|
||||
accounts: buildAccountsPayload(accounts),
|
||||
})
|
||||
showToast(t('issueInvoice.issued'), 'success')
|
||||
}
|
||||
navigate(listPath)
|
||||
} catch (err) {
|
||||
setFormError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: isEdit
|
||||
? t('issueInvoice.updateError')
|
||||
: t('issueInvoice.createError'),
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<Link to={listPath} className={formStyles.backLink}>
|
||||
<ArrowLeft size={16} />
|
||||
{t('issueInvoice.backToInvoices')}
|
||||
</Link>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? t('title.editInvoice') : t('title.issueInvoice')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit ? t('issueInvoice.subtitleEdit') : t('issueInvoice.subtitleNew')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className={formStyles.alertError}>{error}</p> : null}
|
||||
{loading ? <p className={styles.meta}>{t('issueInvoice.loading')}</p> : null}
|
||||
|
||||
{!loading && !locked ? (
|
||||
<section className={formStyles.section}>
|
||||
<div className={`${formStyles.metaGrid}${isEdit ? ` ${formStyles.metaGridTwo}` : ''}`}>
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="invoice-recipient">{t('issueInvoice.recipient')}</label>
|
||||
<CustomerSearchSelect
|
||||
id="invoice-recipient"
|
||||
value={billedCustomer}
|
||||
onChange={handleCustomerChange}
|
||||
disabled={customerLocked}
|
||||
/>
|
||||
</div>
|
||||
{!isEdit ? (
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="source-template">{t('issueInvoice.startFromTemplate')}</label>
|
||||
<select
|
||||
id="source-template"
|
||||
value={sourceTemplateId}
|
||||
onChange={(e) => applyInvoiceTemplate(e.target.value)}
|
||||
>
|
||||
<option value="">{t('issueInvoice.blankInvoice')}</option>
|
||||
{invoiceTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={formStyles.field}>
|
||||
<label htmlFor="invoice-name">{t('issueInvoice.name')}</label>
|
||||
<input
|
||||
id="invoice-name"
|
||||
value={invoiceName}
|
||||
onChange={(e) => setInvoiceName(e.target.value)}
|
||||
placeholder={t('issueInvoice.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEdit && invoiceTemplates.length === 0 ? (
|
||||
<p className={styles.subText}>
|
||||
{t('issueInvoice.noTemplatesHint').split('{link}')[0]}
|
||||
<Link to="/invoices/templates">{t('issueInvoice.noTemplatesLink')}</Link>
|
||||
{t('issueInvoice.noTemplatesHint').split('{link}')[1]}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={`${formStyles.field} ${formStyles.fullField}`}>
|
||||
<label>{t('issueInvoice.topText')}</label>
|
||||
<RichTextEditor
|
||||
value={topText}
|
||||
onChange={setTopText}
|
||||
placeholder={t('issueInvoice.topTextPlaceholder')}
|
||||
editorMinHeight={88}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${formStyles.field} ${formStyles.fullField}`}>
|
||||
<label htmlFor="invoice-notes">{t('issueInvoice.notes')}</label>
|
||||
<input
|
||||
id="invoice-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder={t('issueInvoice.notesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InvoiceDraftFields
|
||||
itemTemplates={itemTemplates}
|
||||
items={draftItems}
|
||||
keyPoints={keyPoints}
|
||||
accounts={accounts}
|
||||
selectedItemTemplateId={selectedItemTemplateId}
|
||||
onSelectedItemTemplateId={setSelectedItemTemplateId}
|
||||
onItemsChange={setDraftItems}
|
||||
onKeyPointsChange={setKeyPoints}
|
||||
onAccountsChange={setAccounts}
|
||||
afterItems={
|
||||
<div className={formStyles.totalRow}>
|
||||
<span className={formStyles.totalLabel}>{t('issueInvoice.totalLabel')}</span>
|
||||
<strong className={formStyles.totalValue}>{formatIrtPrice(createTotal)}</strong>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{formError ? <p className={formStyles.alertError}>{formError}</p> : null}
|
||||
|
||||
<div className={formStyles.actionsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||
onClick={() => navigate(listPath)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('issueInvoice.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnPrimary}`}
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting
|
||||
? isEdit
|
||||
? t('issueInvoice.saving')
|
||||
: t('issueInvoice.issuing')
|
||||
: isEdit
|
||||
? t('issueInvoice.save')
|
||||
: t('issueInvoice.issue')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!loading && locked ? (
|
||||
<div className={formStyles.actionsRow} style={{ justifyContent: 'flex-start' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||
onClick={() => navigate(listPath)}
|
||||
>
|
||||
{t('issueInvoice.backToInvoices')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('nav.finance'), href: '/finance' },
|
||||
{ label: t('title.transactions') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="TRANSACTIONS">{t('title.transactions')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('transactions.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className={formStyles.section}>
|
||||
<p className={styles.placeholderLead}>{t('transactions.lead')}</p>
|
||||
<p className={styles.placeholderNote}>{t('transactions.note')}</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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<InvoiceItemTemplatesResponse>(businessPath('/invoice-item-templates'), {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function createInvoiceItemTemplate(payload: CreateInvoiceItemTemplatePayload) {
|
||||
return apiRequest<InvoiceItemTemplate>(businessPath('/invoice-item-templates'), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInvoiceItemTemplate(
|
||||
templateId: string,
|
||||
payload: UpdateInvoiceItemTemplatePayload,
|
||||
) {
|
||||
return apiRequest<InvoiceItemTemplate>(
|
||||
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<InvoiceTemplatesListResponse>(businessPath('/invoice-templates'), {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function getInvoiceTemplate(templateId: string, signal?: AbortSignal) {
|
||||
return apiRequest<InvoiceTemplate>(businessPath(`/invoice-templates/${templateId}`), {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function createInvoiceTemplate(payload: CreateInvoiceTemplatePayload) {
|
||||
return apiRequest<InvoiceTemplate>(businessPath('/invoice-templates'), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInvoiceTemplate(templateId: string, payload: UpdateInvoiceTemplatePayload) {
|
||||
return apiRequest<InvoiceTemplate>(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<InvoicesListResponse>(businessPath(`/invoices${qs ? `?${qs}` : ''}`), {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function getInvoice(invoiceId: string, signal?: AbortSignal) {
|
||||
return apiRequest<Invoice>(businessPath(`/invoices/${invoiceId}`), {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function createInvoice(payload: CreateInvoicePayload) {
|
||||
return apiRequest<Invoice>(businessPath('/invoices'), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInvoice(invoiceId: string, payload: UpdateInvoicePayload) {
|
||||
return apiRequest<Invoice>(businessPath(`/invoices/${invoiceId}`), {
|
||||
method: 'PUT',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInvoiceStatus(
|
||||
invoiceId: string,
|
||||
payload: { status: InvoiceStatus; notes?: string },
|
||||
) {
|
||||
return apiRequest<Invoice>(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,
|
||||
})
|
||||
}
|
||||
@@ -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<CreateInvoiceTemplatePayload> & {
|
||||
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<CreateInvoiceItemTemplatePayload> & {
|
||||
isActive?: boolean
|
||||
}
|
||||
@@ -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, string | number>) => string
|
||||
|
||||
const defaultTranslate: Translate = (key, vars) => {
|
||||
const fallback: Record<string, string> = {
|
||||
'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)
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -193,6 +193,7 @@ export function BusinessInvoicesPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={tableStyles.th}>Name</th>
|
||||
<th className={tableStyles.th}>Billed user</th>
|
||||
<th className={tableStyles.th}>Issued</th>
|
||||
<th className={tableStyles.th}>Status</th>
|
||||
<th className={tableStyles.th}>Total</th>
|
||||
@@ -203,7 +204,7 @@ export function BusinessInvoicesPage() {
|
||||
<tbody>
|
||||
{!loading && invoices.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
<td className={tableStyles.td} colSpan={7}>
|
||||
No invoices yet for this business.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -216,6 +217,15 @@ export function BusinessInvoicesPage() {
|
||||
{invoice.items?.length ?? 0} item{(invoice.items?.length ?? 0) === 1 ? '' : 's'}
|
||||
</div>
|
||||
</td>
|
||||
<td className={tableStyles.td}>
|
||||
{invoice.user
|
||||
? [invoice.user.firstName, invoice.user.lastName].filter(Boolean).join(' ') ||
|
||||
invoice.user.cell
|
||||
: '—'}
|
||||
{invoice.user?.cell ? (
|
||||
<div className={tableStyles.subText}>{invoice.user.cell}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td>
|
||||
<td className={tableStyles.td}>
|
||||
<button
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface Invoice {
|
||||
id: string
|
||||
publicId: string
|
||||
businessId: string
|
||||
userId: string
|
||||
ownerScope: 'platform' | 'business'
|
||||
issuerBusinessId: string | null
|
||||
status: InvoiceStatus
|
||||
@@ -65,6 +66,12 @@ export interface Invoice {
|
||||
name: string
|
||||
nameFa: string | null
|
||||
}
|
||||
user?: {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
cell: string
|
||||
}
|
||||
issuer?: {
|
||||
id: string
|
||||
firstName: string | null
|
||||
@@ -85,6 +92,10 @@ export type PublicInvoice = {
|
||||
topText: string | null
|
||||
issuedAt: string
|
||||
business?: Invoice['business']
|
||||
user?: {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
}
|
||||
items?: InvoiceItem[]
|
||||
keyPoints?: InvoiceKeyPoint[]
|
||||
accounts?: InvoiceAccount[]
|
||||
@@ -115,6 +126,7 @@ export interface InvoiceAccountInput {
|
||||
}
|
||||
|
||||
export interface CreateInvoicePayload {
|
||||
userId?: string
|
||||
items: InvoiceItemInput[]
|
||||
name?: string
|
||||
topText?: string
|
||||
@@ -126,6 +138,7 @@ export interface CreateInvoicePayload {
|
||||
}
|
||||
|
||||
export type UpdateInvoicePayload = {
|
||||
userId?: string
|
||||
items: InvoiceItemInput[]
|
||||
name?: string | null
|
||||
topText?: string | null
|
||||
|
||||
+37
-29
@@ -3,7 +3,7 @@
|
||||
> **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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user