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:
Alireza Hassani
2026-08-11 15:17:03 +03:30
co-authored by Cursor
parent 722933dda9
commit 917b840ee5
44 changed files with 5136 additions and 105 deletions
@@ -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);
+10 -6
View File
@@ -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;
}
+65
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}
+22 -4
View File
@@ -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}>