'use client' import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react' import dynamic from 'next/dynamic' import { useLocale, useTranslations } from 'next-intl' import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { Plus, Search, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { useSubmitWithAccountActivation, 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' import type { Article, ArticleType, CreateArticleInput } from '@/types' const ArticleForm = dynamic( () => import('@/components/articles/ArticleForm'), { loading: () => (
), }, ) const ARTICLE_TYPE_LABEL_KEYS: Record = { vara: 'type_vara', tjanst: 'type_tjanst', } type SortColumn = 'name' | 'article_number' | 'type' | 'unit' | 'price_excl_vat' | 'vat_rate' type SortDir = 'asc' | 'desc' const SORTABLE_COLUMNS: ReadonlyArray = [ 'name', 'article_number', 'type', 'unit', 'price_excl_vat', 'vat_rate', ] const INITIAL_VISIBLE_ROWS = 100 function compareStrings(a: string, b: string): number { return a.localeCompare(b, 'sv', { sensitivity: 'base' }) } function ArticlesPageInner() { const { company } = useCompany() const { canWrite } = useCanWrite() const [articles, setArticles] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS) const [isDialogOpen, setIsDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) const { toast } = useToast() const supabase = createClient() const t = useTranslations('articles') const tCommon = useTranslations('common') const errorLocale = useLocale() as ErrorLocale const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() 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) : 'article_number' const sortDir: SortDir = dirParam === 'desc' ? 'desc' : 'asc' const updateSort = useCallback( (column: SortColumn) => { setVisibleCount(INITIAL_VISIBLE_ROWS) const params = new URLSearchParams(searchParams.toString()) let nextDir: SortDir = 'asc' if (column === sortColumn) { nextDir = sortDir === 'asc' ? 'desc' : 'asc' } params.set('sort', column) params.set('dir', nextDir) router.replace(`${pathname}?${params.toString()}`, { scroll: false }) }, [searchParams, sortColumn, sortDir, router, pathname] ) async function fetchArticles() { if (!company) return setIsLoading(true) const { data, error } = await supabase .from('articles') .select('*') .eq('company_id', company.id) .order('name', { ascending: true }) if (error) { toast({ title: t('load_failed_title'), description: t('load_failed_description'), variant: 'destructive', }) } else { setArticles(data || []) } setIsLoading(false) } useEffect(() => { fetchArticles() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Create runs through useSubmitWithAccountActivation so an ACCOUNTS_NOT_IN_CHART // response (revenue account not yet activated) opens the standard // activate-and-retry dialog instead of failing: same UX as the journal entry form. const pendingCreateRef = useRef(null) const submitCreate = useCallback(async () => { const response = await fetch('/api/articles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pendingCreateRef.current), }) return (await throwOnStructuredError(response)) as { data: Article } }, []) const { runSubmit: runCreate, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation, } = useSubmitWithAccountActivation(submitCreate) async function handleCreateArticle(data: CreateArticleInput) { setIsCreating(true) pendingCreateRef.current = data try { const result = await runCreate() toast({ title: t('created_title'), description: t('created_description', { name: data.name }), }) setArticles([...articles, result.data]) setIsDialogOpen(false) } catch (err) { // The user closing the activation dialog is not an error worth toasting. if (!(err instanceof Error && err.message === 'cancelled')) { const body = (err as { body?: unknown }).body toast({ title: t('create_failed_title'), description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }), variant: 'destructive', }) } } finally { setIsCreating(false) } } 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 && 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, currencyFilter]) const sortedArticles = useMemo(() => { const arr = [...filteredArticles] arr.sort((a, b) => { let cmp = 0 switch (sortColumn) { case 'price_excl_vat': cmp = a.price_excl_vat - b.price_excl_vat break case 'vat_rate': cmp = a.vat_rate - b.vat_rate break case 'name': cmp = compareStrings(a.name || '', b.name || '') break case 'article_number': cmp = compareArticles(a, b) break case 'type': cmp = compareStrings(a.type || '', b.type || '') break case 'unit': cmp = compareStrings(a.unit || '', b.unit || '') break } return sortDir === 'asc' ? cmp : -cmp }) return arr }, [filteredArticles, sortColumn, sortDir]) const visibleArticles = sortedArticles.slice(0, visibleCount) // Sortable dry-table head: the button inherits the TH typography and only // adds the direction affordance. function SortableTh({ column, label, className, }: { column: SortColumn label: string className?: string }) { const isActive = sortColumn === column const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown return ( ) } return (
{/* Page header (concept scene 27): title + export + Ny artikel */}

{t('title')}

{t('add_article')} setIsDialogOpen(false)} />
{/* Toolbar: search, plus the currency scope far right (convention 8) */}
{ setSearchTerm(e.target.value) setVisibleCount(INITIAL_VISIBLE_ROWS) }} 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 ? (
{[1, 2, 3, 4, 5].map((i) => ( ))}
) : sortedArticles.length === 0 ? ( searchTerm ? ( ) : ( setIsDialogOpen(true) : undefined} /> ) ) : ( <>
{visibleArticles.map((article) => ( router.push(`/articles/${article.id}`)} > ))}
{t('col_status')}
{article.article_number || '-'} e.stopPropagation()} > {article.name} {t(ARTICLE_TYPE_LABEL_KEYS[article.type])} {article.unit} {formatCurrency(article.price_excl_vat, article.currency)} {article.vat_rate} % {article.active ? ( {t('status_active')} ) : ( {t('status_inactive')} )}
{/* Footer count (concept pgnote) */}

{t('count_footer', { count: sortedArticles.length })}

{visibleCount < sortedArticles.length && (
)} )}
) } export default function ArticlesPage() { return (
{[1, 2, 3, 4].map((i) => ( ))}
} >
) }