diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 3275418..8b8517e 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/business/src/components/CustomerSearchSelect.tsx b/apps/business/src/components/CustomerSearchSelect.tsx new file mode 100644 index 0000000..5292b6b --- /dev/null +++ b/apps/business/src/components/CustomerSearchSelect.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const containerRef = useRef(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 ( +
+
+ + { + 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 ? ( + + ) : null} + +
+ + {open && !disabled ? ( +
    + {query.trim().length < 2 ? ( +
  • {t('issueInvoice.pickCustomerHint')}
  • + ) : loading ? ( +
  • {t('issueInvoice.loading')}
  • + ) : results.length === 0 ? ( +
  • {t('issueInvoice.pickCustomerNoResults')}
  • + ) : ( + results.map((customer) => ( +
  • + +
  • + )) + )} +
+ ) : null} +
+ ) +} diff --git a/apps/business/src/components/DailyActivityChart.module.css b/apps/business/src/components/DailyActivityChart.module.css index 516dac8..1e79f65 100644 --- a/apps/business/src/components/DailyActivityChart.module.css +++ b/apps/business/src/components/DailyActivityChart.module.css @@ -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) { diff --git a/apps/business/src/components/DailyActivityChart.tsx b/apps/business/src/components/DailyActivityChart.tsx index eee0c9b..6cd8fe5 100644 --- a/apps/business/src/components/DailyActivityChart.tsx +++ b/apps/business/src/components/DailyActivityChart.tsx @@ -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 + /** 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 (
@@ -119,43 +136,83 @@ export function DailyActivityChart({ ) : (
- {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 ( +
+
+
0 ? 4 : 0), + }} + title={t(primaryBarTitleKey, { + day: item.label, + month: item.label, + count: item.primary, + })} + /> +
0 ? 4 : 0), + }} + title={t(secondaryBarTitleKey, { + day: item.label, + month: item.label, + count: item.secondary, + })} + /> +
+ + {item.label} + +
+ ) + }) + : primaryItems.map((item, index) => { + const secondary = secondaryItems[index] + const secondaryCount = secondary?.count ?? 0 + const primaryHeight = (item.count / maxValue) * CHART_HEIGHT + const secondaryHeight = (secondaryCount / maxValue) * CHART_HEIGHT + const label = formatDayLabel(item.date, locale) - return ( -
-
-
0 ? 4 : 0) }} - title={t(primaryBarTitleKey, { day: label, count: item.count })} - /> -
0 ? 4 : 0), - }} - title={t(secondaryBarTitleKey, { - day: label, - count: secondaryCount, - })} - /> -
- - {label} - -
- ) - })} + return ( +
+
+
0 ? 4 : 0) }} + title={t(primaryBarTitleKey, { day: label, count: item.count })} + /> +
0 ? 4 : 0), + }} + title={t(secondaryBarTitleKey, { + day: label, + count: secondaryCount, + })} + /> +
+ + {label} + +
+ ) + })}
)} diff --git a/apps/business/src/components/Header.module.css b/apps/business/src/components/Header.module.css index f48668c..bee8285 100644 --- a/apps/business/src/components/Header.module.css +++ b/apps/business/src/components/Header.module.css @@ -6,9 +6,9 @@ position: sticky; top: 0; z-index: 50; - background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); + background: var(--header-bg); + backdrop-filter: blur(20px) saturate(150%); + -webkit-backdrop-filter: blur(20px) saturate(150%); border-bottom: 1px solid var(--glass-border); isolation: isolate; transform: translateZ(0); diff --git a/apps/business/src/components/Header.tsx b/apps/business/src/components/Header.tsx index e1f28be..ced182d 100644 --- a/apps/business/src/components/Header.tsx +++ b/apps/business/src/components/Header.tsx @@ -46,11 +46,15 @@ export function Header() { const membership = user?.businesses.find((b) => String(b.id) === String(activeBusinessId)) ?? user?.businesses[0] - const adminBadgeLabel = isSuperAdmin + const roleBadgeLabel = isSuperAdmin ? t('role.superAdmin') - : membership?.teamRole === 'admin' - ? t('role.admin') - : null + : membership?.isOwner + ? t('role.owner') + : membership?.teamRole === 'admin' + ? t('role.admin') + : membership?.teamRole + ? t('role.staff') + : null useEffect(() => { if (!menuOpen) return @@ -116,8 +120,8 @@ export function Header() { >
{displayName} - {adminBadgeLabel ? ( - {adminBadgeLabel} + {roleBadgeLabel ? ( + {roleBadgeLabel} ) : null}
) case 'products_added_1y': diff --git a/apps/business/src/components/InvoiceDraftFields.module.css b/apps/business/src/components/InvoiceDraftFields.module.css new file mode 100644 index 0000000..30d917e --- /dev/null +++ b/apps/business/src/components/InvoiceDraftFields.module.css @@ -0,0 +1,250 @@ +.itemsStack { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 12px; + max-height: min(48vh, 420px); + overflow: auto; + padding-right: 2px; +} + +.itemCard { + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.45); +} + +.itemCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + font-size: 12px; + font-weight: 700; + color: var(--text-secondary); +} + +.removeItemBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + color: #b91c1c; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.18); + flex-shrink: 0; +} + +.removeItemBtn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.14); +} + +.removeItemBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/** Matches compact field height; align with input (not label). */ +.removeFieldBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--field-height); + height: var(--field-height); + min-width: var(--field-height); + border-radius: var(--radius-sm); + color: #b91c1c; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.18); + flex-shrink: 0; +} + +.removeFieldBtn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.14); +} + +.itemGrid { + display: grid; + grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr); + gap: 10px; + align-items: end; +} + +.descField { + grid-column: 1 / -1; +} + +.itemActions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.itemActionsLeft { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.compactSelect { + width: min(260px, 100%); + min-height: 32px; + padding: 6px var(--select-padding-end) 6px 10px; + font-size: 12px; + font-family: var(--font-ui); + color: var(--text-primary); + background-color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-sm); + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 14px; + cursor: pointer; +} + +:global([dir='rtl']) .compactSelect { + padding: 6px 10px 6px var(--select-padding-end); + background-position: left 8px center; +} + +.compactBtn { + padding: 6px 10px; + font-size: 12px; + white-space: nowrap; +} + +.blockSection { + margin: 16px 0; + padding-top: 12px; + border-top: 1px solid rgba(148, 163, 184, 0.22); +} + +.blockHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.blockTitle { + margin: 0; + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +.templateHint { + margin: 0 0 12px; + font-size: 13px; + color: var(--text-secondary); +} + +.repeatStack { + display: flex; + flex-direction: column; + gap: 10px; +} + +.repeatRow { + display: flex; + align-items: center; + gap: 8px; +} + +.repeatRow > div { + flex: 1; + min-width: 0; +} + +.fieldNoLabel { + margin: 0; +} + +.fieldNoLabel label { + display: none; +} + +.flexGrow { + flex: 1; + min-width: 0; +} + +.accountRow { + display: grid; + grid-template-columns: 2fr 2fr 3fr 5fr var(--field-height); + align-items: end; + gap: 8px; +} + +.accountRowPlain { + align-items: center; +} + +.accountCol2, +.accountCol3, +.accountCol5 { + min-width: 0; +} + +.keyPointList { + margin: 0; + padding-inline-start: 18px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +@media (max-width: 900px) { + .itemGrid { + grid-template-columns: 1fr 1fr; + } + + .descField { + grid-column: 1 / -1; + } +} + +@media (max-width: 720px) { + .itemGrid { + grid-template-columns: 1fr; + } + + .descField { + grid-column: span 1; + } + + .accountRow { + grid-template-columns: 1fr var(--field-height); + } + + .accountCol2, + .accountCol3, + .accountCol5 { + grid-column: 1; + } + + .itemActions { + flex-direction: column; + align-items: stretch; + } + + .itemActionsLeft { + flex-direction: column; + align-items: stretch; + } + + .compactSelect { + width: 100%; + } +} diff --git a/apps/business/src/components/InvoiceDraftFields.tsx b/apps/business/src/components/InvoiceDraftFields.tsx new file mode 100644 index 0000000..7a0935c --- /dev/null +++ b/apps/business/src/components/InvoiceDraftFields.tsx @@ -0,0 +1,351 @@ +import { Pencil, Plus, X } from 'lucide-react' +import type { ReactNode } from 'react' +import { useT } from '../i18n/useT' +import type { InvoiceItemTemplate } from '../types/invoice' +import { formatIrtInput } from '../utils/irtPrice' +import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft' +import { + draftItemFromItemTemplate, + emptyDraftAccount, + emptyDraftItem, + emptyDraftKeyPoint, +} from '../utils/invoiceDraft' +import formStyles from './InvoiceForm.module.css' +import styles from './InvoiceDraftFields.module.css' + +function focusKeyPointInput(index: number) { + const el = document.querySelector(`input[data-keypoint-index="${index}"]`) + el?.focus() + el?.select() +} + +type Props = { + itemTemplates: InvoiceItemTemplate[] + items: DraftLineItem[] + keyPoints: DraftKeyPoint[] + accounts: DraftAccount[] + selectedItemTemplateId: string + onSelectedItemTemplateId: (id: string) => void + onItemsChange: (items: DraftLineItem[]) => void + onKeyPointsChange: (points: DraftKeyPoint[]) => void + onAccountsChange: (accounts: DraftAccount[]) => void + showItemTemplatePicker?: boolean + /** Rendered after line items (before key points / accounts), e.g. totals row. */ + afterItems?: ReactNode +} + +export function InvoiceDraftFields({ + itemTemplates, + items, + keyPoints, + accounts, + selectedItemTemplateId, + onSelectedItemTemplateId, + onItemsChange, + onKeyPointsChange, + onAccountsChange, + showItemTemplatePicker = true, + afterItems, +}: Props) { + const t = useT() + + function updateItem(key: string, patch: Partial) { + onItemsChange(items.map((item) => (item.key === key ? { ...item, ...patch } : item))) + } + + function removeItem(key: string) { + if (items.length <= 1) return + onItemsChange(items.filter((item) => item.key !== key)) + } + + function addFromItemTemplate() { + const template = itemTemplates.find((entry) => entry.id === selectedItemTemplateId) + if (!template) return + const onlyEmpty = + items.length === 1 && + !items[0].title.trim() && + !items[0].price.trim() && + !items[0].description.trim() + onItemsChange(onlyEmpty ? [draftItemFromItemTemplate(template)] : [...items, draftItemFromItemTemplate(template)]) + onSelectedItemTemplateId('') + } + + return ( + <> +
+ {items.map((item, index) => ( +
+
+ {t('invoiceDraft.itemLabel', { index: index + 1 })} + +
+
+
+ + updateItem(item.key, { title: e.target.value })} + placeholder={t('invoiceDraft.fieldTitlePlaceholder')} + /> +
+
+ + updateItem(item.key, { duration: e.target.value })} + placeholder={t('invoiceDraft.fieldDurationPlaceholder')} + /> +
+
+ + updateItem(item.key, { worktime: e.target.value })} + placeholder={t('invoiceDraft.fieldWorktimePlaceholder')} + /> +
+
+ + updateItem(item.key, { price: formatIrtInput(e.target.value) })} + placeholder="0" + /> +
+
+ + + updateItem(item.key, { discountedPrice: formatIrtInput(e.target.value) }) + } + placeholder={t('invoiceDraft.fieldDiscountedPricePlaceholder')} + /> +
+
+ +