'use client' import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react' 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/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 ArticleForm from '@/components/articles/ArticleForm' 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 { PageHeader } from '@/components/ui/page-header' import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { formatCurrency } from '@/lib/utils' 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 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', ] 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 [isDialogOpen, setIsDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) const { toast } = useToast() const supabase = createClient() const t = useTranslations('articles') const errorLocale = useLocale() as ErrorLocale const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() const sortParam = searchParams.get('sort') const dirParam = searchParams.get('dir') const sortColumn: SortColumn = (SORTABLE_COLUMNS as ReadonlyArray).includes(sortParam ?? '') ? (sortParam as SortColumn) : 'name' const sortDir: SortDir = dirParam === 'desc' ? 'desc' : 'asc' const updateSort = useCallback( (column: SortColumn) => { 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) .eq('active', true) .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 filteredArticles = useMemo(() => { const term = searchTerm.trim().toLowerCase() if (!term) return articles return articles.filter((a) => { return ( a.name.toLowerCase().includes(term) || a.name_en?.toLowerCase().includes(term) || a.article_number?.toLowerCase().includes(term) ) }) }, [articles, searchTerm]) 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 = compareStrings(a.article_number || '', b.article_number || '') 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]) function SortableHeader({ column, label, className, }: { column: SortColumn label: string className?: string }) { const isActive = sortColumn === column const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown return ( ) } return (
{t('add_article')}
} /> {/* Search */}
setSearchTerm(e.target.value)} className="pl-10" />
{/* Article list */} {isLoading ? ( <> {/* Desktop skeleton */} {[1, 2, 3, 4, 5].map((i) => ( ))} {/* Mobile skeleton */}
{[1, 2, 3].map((i) => ( ))}
) : sortedArticles.length === 0 ? ( {searchTerm ? ( ) : ( setIsDialogOpen(true) : undefined} /> )} ) : ( <> {/* Desktop table */} {t('col_status')} {sortedArticles.map((article) => ( router.push(`/articles/${article.id}`)} > {article.article_number || '—'} e.stopPropagation()} > {article.name} {t(ARTICLE_TYPE_LABEL_KEYS[article.type])} {article.unit} {formatCurrency(article.price_excl_vat)} {article.vat_rate} % {article.active ? t('status_active') : t('status_inactive')} ))}
{/* Mobile card list */}
{sortedArticles.map((article) => (
{article.name}
{t(ARTICLE_TYPE_LABEL_KEYS[article.type])} {article.article_number && ( {article.article_number} )}
{formatCurrency(article.price_excl_vat)}
{t('per_unit', { unit: article.unit })} {t('vat_label_value', { rate: article.vat_rate })}
))}
)} ) } export default function ArticlesPage() { return ( ) }