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
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user