diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx index df8b1976..49cd7027 100644 --- a/app/(dashboard)/articles/page.tsx +++ b/app/(dashboard)/articles/page.tsx @@ -20,9 +20,16 @@ import { throwOnStructuredError, } from '@/lib/hooks/use-submit-with-account-activation' import { EmptyState } from '@/components/ui/empty-state' +import { ContextPicker } from '@/components/common/ContextPicker' import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { cn, formatCurrency } from '@/lib/utils' import { compareArticles } from '@/lib/articles/sort' +import { + ALL_CURRENCIES, + listArticleCurrencies, + matchesCurrencyScope, + resolveCurrencyScope, +} from '@/lib/articles/currency-scope' import Link from 'next/link' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' @@ -84,6 +91,9 @@ function ArticlesPageInner() { const sortParam = searchParams.get('sort') const dirParam = searchParams.get('dir') + // Currency scope (#1189). In the URL like the sort, so a filtered register + // survives opening an article and coming back, and can be linked to. + const currencyParam = searchParams.get('currency') // Default to the user's own article numbering (numeric-aware), issue #1053. const sortColumn: SortColumn = (SORTABLE_COLUMNS as ReadonlyArray).includes(sortParam ?? '') ? (sortParam as SortColumn) @@ -176,17 +186,34 @@ function ArticlesPageInner() { } } + const availableCurrencies = useMemo(() => listArticleCurrencies(articles), [articles]) + const currencyFilter = resolveCurrencyScope(currencyParam, availableCurrencies) + + const updateCurrencyFilter = useCallback( + (next: string) => { + setVisibleCount(INITIAL_VISIBLE_ROWS) + const params = new URLSearchParams(searchParams.toString()) + if (next === ALL_CURRENCIES) params.delete('currency') + else params.set('currency', next) + const query = params.toString() + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }) + }, + [searchParams, router, pathname], + ) + const filteredArticles = useMemo(() => { const term = searchTerm.trim().toLowerCase() - if (!term) return articles + if (!term && currencyFilter === ALL_CURRENCIES) return articles return articles.filter((a) => { + if (!matchesCurrencyScope(a, currencyFilter)) return false + if (!term) return true return ( a.name.toLowerCase().includes(term) || a.name_en?.toLowerCase().includes(term) || a.article_number?.toLowerCase().includes(term) ) }) - }, [articles, searchTerm]) + }, [articles, searchTerm, currencyFilter]) const sortedArticles = useMemo(() => { const arr = [...filteredArticles] @@ -286,7 +313,7 @@ function ArticlesPageInner() { - {/* Toolbar: search */} + {/* Toolbar: search, plus the currency scope far right (convention 8) */}
@@ -300,6 +327,25 @@ function ArticlesPageInner() { className="h-9 pl-10" />
+ {/* A single-currency register needs no scope: the chip would be a + control with one meaningful position. */} + {availableCurrencies.length > 1 && ( + ({ id: code, label: code })), + ]} + /> + )}
{isLoading ? ( @@ -313,7 +359,17 @@ function ArticlesPageInner() { ) : ( { + it('normalizes the code', () => { + expect(articleCurrency({ currency: 'eur' })).toBe('EUR') + }) + + it('treats a missing or blank currency as SEK', () => { + expect(articleCurrency({})).toBe('SEK') + expect(articleCurrency({ currency: null })).toBe('SEK') + expect(articleCurrency({ currency: '' })).toBe('SEK') + }) +}) + +describe('listArticleCurrencies', () => { + it('lists each present currency once, SEK first then alphabetically', () => { + expect( + listArticleCurrencies([ + { currency: 'USD' }, + { currency: 'EUR' }, + { currency: 'SEK' }, + { currency: 'EUR' }, + { currency: 'NOK' }, + ]), + ).toEqual(['SEK', 'EUR', 'NOK', 'USD']) + }) + + it('folds legacy blank rows into SEK', () => { + expect(listArticleCurrencies([{ currency: null }, { currency: 'sek' }])).toEqual(['SEK']) + }) + + it('is empty for an empty register, so the picker can stay hidden', () => { + expect(listArticleCurrencies([])).toEqual([]) + }) +}) + +describe('resolveCurrencyScope', () => { + const available = ['SEK', 'EUR'] + + it('defaults to no scope', () => { + expect(resolveCurrencyScope(null, available)).toBe(ALL_CURRENCIES) + expect(resolveCurrencyScope(undefined, available)).toBe(ALL_CURRENCIES) + }) + + it('accepts a present currency, case-insensitively', () => { + expect(resolveCurrencyScope('eur', available)).toBe('EUR') + }) + + // A stale or hand-edited link must not empty the register: the fallback is + // "show everything", which the user can see and recover from. + it('falls back to no scope for a currency that is not in the register', () => { + expect(resolveCurrencyScope('USD', available)).toBe(ALL_CURRENCIES) + expect(resolveCurrencyScope('nonsense', available)).toBe(ALL_CURRENCIES) + expect(resolveCurrencyScope('EUR', [])).toBe(ALL_CURRENCIES) + }) +}) + +describe('matchesCurrencyScope', () => { + it('keeps every article when unscoped', () => { + expect(matchesCurrencyScope({ currency: 'USD' }, ALL_CURRENCIES)).toBe(true) + }) + + it('keeps only the scoped currency', () => { + expect(matchesCurrencyScope({ currency: 'EUR' }, 'EUR')).toBe(true) + expect(matchesCurrencyScope({ currency: 'USD' }, 'EUR')).toBe(false) + }) + + it('matches legacy blank rows under SEK', () => { + expect(matchesCurrencyScope({ currency: null }, 'SEK')).toBe(true) + }) +}) diff --git a/lib/articles/currency-scope.ts b/lib/articles/currency-scope.ts new file mode 100644 index 00000000..dde4638f --- /dev/null +++ b/lib/articles/currency-scope.ts @@ -0,0 +1,65 @@ +/** + * Currency scoping for the article register (#1189). + * + * Article prices became first-class in non-SEK currencies (#1166), so a mixed + * register needs a way to look at one currency at a time. The scope is offered + * as a ContextPicker chip and lives in the URL, which means it arrives as + * untrusted text: everything here treats an unknown or stale code as "no + * scope" rather than as a filter that hides every row. + */ + +/** Sentinel for "no currency scope". Kept out of the URL when it is the value. */ +export const ALL_CURRENCIES = 'all' + +/** + * The register's own default. Rows written before multi-currency support, and + * any row with a blank currency, are SEK: pricing in Sweden without saying so + * means kronor. + */ +export const DEFAULT_CURRENCY = 'SEK' + +interface CurrencyBearingArticle { + currency?: string | null +} + +/** Normalized currency code of one article. */ +export function articleCurrency(article: CurrencyBearingArticle): string { + return (article.currency || DEFAULT_CURRENCY).toUpperCase() +} + +/** + * The currencies actually present in the register, SEK first and the rest + * alphabetically. Only these may be offered: a picker listing currencies + * nobody priced anything in would filter to an empty table. + */ +export function listArticleCurrencies(articles: CurrencyBearingArticle[]): string[] { + const present = new Set(articles.map(articleCurrency)) + return [...present].sort((a, b) => { + if (a === DEFAULT_CURRENCY) return -1 + if (b === DEFAULT_CURRENCY) return 1 + return a.localeCompare(b) + }) +} + +/** + * Resolve the scope to apply from the URL parameter. A code that is not in the + * register (last EUR article deleted, hand-edited or shared link) falls back to + * ALL_CURRENCIES: showing everything is recoverable, showing nothing reads as + * data loss. + */ +export function resolveCurrencyScope( + param: string | null | undefined, + availableCurrencies: string[], +): string { + if (!param) return ALL_CURRENCIES + const normalized = param.toUpperCase() + return availableCurrencies.includes(normalized) ? normalized : ALL_CURRENCIES +} + +/** True when the article belongs in the given scope. */ +export function matchesCurrencyScope( + article: CurrencyBearingArticle, + scope: string, +): boolean { + return scope === ALL_CURRENCIES || articleCurrency(article) === scope +} diff --git a/messages/en.json b/messages/en.json index 74596d9e..8acb7496 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4827,6 +4827,10 @@ "search_placeholder": "Search articles", "no_search_results_title": "No matches", "no_search_results_description": "No articles match \"{term}\".", + "no_search_results_in_currency_description": "No articles in {currency} match \"{term}\".", + "currency_filter_all": "All currencies", + "currency_filter_selected": "Currency {currency}", + "currency_filter_aria": "Filter articles by currency", "empty_title": "No articles yet", "empty_description": "Add articles for goods and services to fill in invoice rows quickly.", "empty_action": "Add article", diff --git a/messages/sv.json b/messages/sv.json index b8481e89..3f2b6373 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4827,6 +4827,10 @@ "search_placeholder": "Sök artiklar", "no_search_results_title": "Inga träffar", "no_search_results_description": "Inga artiklar matchar \"{term}\".", + "no_search_results_in_currency_description": "Inga artiklar i {currency} matchar \"{term}\".", + "currency_filter_all": "Alla valutor", + "currency_filter_selected": "Valuta {currency}", + "currency_filter_aria": "Filtrera artiklar på valuta", "empty_title": "Inga artiklar ännu", "empty_description": "Lägg till artiklar för varor och tjänster för att snabbt fylla i fakturarader.", "empty_action": "Lägg till artikel",