diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx index cdae3efd..fabfc7ef 100644 --- a/app/(dashboard)/articles/page.tsx +++ b/app/(dashboard)/articles/page.tsx @@ -6,19 +6,11 @@ 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 { 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' @@ -28,9 +20,8 @@ import { 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 { cn, formatCurrency } from '@/lib/utils' import { compareArticles } from '@/lib/articles/sort' import Link from 'next/link' import { useCompany } from '@/contexts/CompanyContext' @@ -227,7 +218,9 @@ function ArticlesPageInner() { }, [filteredArticles, sortColumn, sortDir]) const visibleArticles = sortedArticles.slice(0, visibleCount) - function SortableHeader({ + // Sortable dry-table head: the button inherits the TH typography and only + // adds the direction affordance. + function SortableTh({ column, label, className, @@ -239,218 +232,163 @@ function ArticlesPageInner() { const isActive = sortColumn === column const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown return ( - + - + ) } return (
- - - - - - - - - {t('add_article')} - - - - -
- } - /> + {/* Page header (concept scene 27): title + export + Ny artikel */} +
+

{t('title')}

+
+ + + + + + + + {t('add_article')} + + + + +
+
- {/* Search */} -
- - { - setSearchTerm(e.target.value) - setVisibleCount(INITIAL_VISIBLE_ROWS) - }} - className="pl-10" - /> + {/* Toolbar: search */} +
+
+ + { + setSearchTerm(e.target.value) + setVisibleCount(INITIAL_VISIBLE_ROWS) + }} + className="h-9 pl-10" + /> +
- {/* Article list */} {isLoading ? ( - <> - {/* Desktop skeleton */} - - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - - {/* Mobile skeleton */} -
- {[1, 2, 3].map((i) => ( - - - - - - - - - - ))} -
- +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
) : sortedArticles.length === 0 ? ( - - - {searchTerm ? ( - - ) : ( - setIsDialogOpen(true) : undefined} - /> - )} - - + searchTerm ? ( + + ) : ( + setIsDialogOpen(true) : undefined} + /> + ) ) : ( <> - {/* Desktop table */} - - - - - - - - - - - - {t('col_status')} - - - - {visibleArticles.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')} +
+
+ + + + + + + + + + + + + {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.vat_rate} % + + {article.active ? ( + {t('status_active')} + ) : ( + + {t('status_inactive')} - - - ))} - -
-
-
- - {/* Mobile card list */} -
- {visibleArticles.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 })} -
-
-
- - ))} + )} + + + ))} + +
+ + {/* Footer count (concept pgnote) */} +

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

+ {visibleCount < sortedArticles.length && (
- } - /> + {/* Page header (concept scene 29): title + Ny tillgång */} +
+

{t('title')}

+ +
{assets === null && !error && ( - - - - - - +
+ {[1, 2, 3].map((i) => ( + + ))} +
)} - {error && ( - - {error} - - )} + {error &&

{error}

} {assets !== null && assets.length === 0 && ( 0 && ( - - - - - - {t('th_name')} - {t('th_category')} - {t('th_acquired')} - {t('th_acquisition_cost')} - {t('th_useful_life')} - {t('th_status')} - {t('th_actions')} - - - + <> +
+
+ + + + + + + + + + + + {assets.map((asset) => { const years = Math.round(asset.useful_life_months / 12) + const disposed = !!asset.disposed_at return ( - - {asset.name} - {t(CATEGORY_LABEL_KEYS[asset.category])} - + setEditing(asset) : undefined} + > + + + + + + + ) })} - -
{t('th_name')}{t('th_category')}{t('th_acquired')}{t('th_acquisition_cost')}{t('th_useful_life')}{t('th_status')}
+ {asset.name} + + {t(CATEGORY_LABEL_KEYS[asset.category])} + {formatDate(asset.acquisition_date)} - - + {formatCurrency(Number(asset.acquisition_cost))} - - + {t('useful_life_format', { years, months: asset.useful_life_months })} - - - {asset.disposed_at ? ( -
- {t('status_disposed')} +
+ {disposed ? ( + + + {t('status_disposed')} + - {formatDate(asset.disposed_at)} + {formatDate(asset.disposed_at!)} - + ) : ( - {t('status_active')} + {t('status_active')} )} - - - {!asset.disposed_at && ( -
-
e.stopPropagation()} + > + {!disposed && ( + + - - - - + + + )} - - +
-
-
+ + +
+ + {/* Footer count (concept pgnote) */} +

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

+ )} diff --git a/app/(dashboard)/chart-of-accounts/page.tsx b/app/(dashboard)/chart-of-accounts/page.tsx index 2ef5c13f..599bab64 100644 --- a/app/(dashboard)/chart-of-accounts/page.tsx +++ b/app/(dashboard)/chart-of-accounts/page.tsx @@ -1,5 +1,3 @@ -import { getTranslations } from 'next-intl/server' -import { PageHeader } from '@/components/ui/page-header' import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager' /** @@ -7,13 +5,10 @@ import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsMana * Bokföring. It is reference/configuration (activate/add/edit accounts, browse * the BAS catalog), not a daily task, so it lives beside the ledger rather than * as a co-equal tab inside it. Balances/lookup stay in Rapporter (Huvudbok). + * + * The manager owns the whole page chrome (concept scene 30 header with the + * BAS-version chip and the actions) since every control needs client state. */ -export default async function ChartOfAccountsPage() { - const t = await getTranslations('nav') - return ( -
- - -
- ) +export default function ChartOfAccountsPage() { + return } diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index 177797e9..7b6540fb 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -6,26 +6,17 @@ 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 { 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, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state' -import { PageHeader } from '@/components/ui/page-header' import { ReportExportMenu } from '@/components/reports/ReportExportMenu' -import { formatDate } from '@/lib/utils' +import { cn } from '@/lib/utils' import Link from 'next/link' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' @@ -64,15 +55,6 @@ const SORTABLE_COLUMNS: ReadonlyArray = [ ] const INITIAL_VISIBLE_ROWS = 100 -function getInitials(name: string): string { - return name - .split(' ') - .map((w) => w[0]) - .slice(0, 2) - .join('') - .toUpperCase() -} - function getIdentifier(customer: Customer): string { return customer.org_number || customer.personal_number || '' } @@ -230,6 +212,8 @@ function CustomersPageInner() { }, [filteredCustomers, sortColumn, sortDir]) const visibleCustomers = sortedCustomers.slice(0, visibleCount) + // Sortable dry-table head: the button inherits the uppercase hairline + // styling from TH_CLASS on the th. function SortableHeader({ column, label, @@ -242,226 +226,165 @@ function CustomersPageInner() { const isActive = sortColumn === column const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown return ( - + - + ) } return (
- - - - - - - - - {t('add_customer')} - - - - -
- } - /> + {/* Page header (concept scene 25): title + export + Ny kund */} +
+

{t('title')}

+
+ + + + + + + + {t('add_customer')} + + + + +
+
- {/* Search */} -
- - { - setSearchTerm(e.target.value) - setVisibleCount(INITIAL_VISIBLE_ROWS) - }} - className="pl-10" - /> + {/* Toolbar: search (concept) */} +
+
+ + { + setSearchTerm(e.target.value) + setVisibleCount(INITIAL_VISIBLE_ROWS) + }} + className="h-9 pl-10" + /> +
- {/* Customer list */} {isLoading ? ( - <> - {/* Desktop skeleton */} - - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - - {/* Mobile skeleton */} -
- {[1, 2, 3].map((i) => ( - - - - - - - - - - ))} -
- +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
) : sortedCustomers.length === 0 ? ( - - - {searchTerm ? ( - - ) : ( - setIsDialogOpen(true)} /> - )} - - + searchTerm ? ( + + ) : ( + setIsDialogOpen(true)} /> + ) ) : ( <> - {/* Desktop table */} - - - - - - - - - - - - - - - {visibleCustomers.map((customer) => { - const identifier = getIdentifier(customer) - return ( - router.push(`/customers/${customer.id}`)} - > - +
+
+ + + + + + + + + + + {visibleCustomers.map((customer) => { + const identifier = getIdentifier(customer) + return ( + router.push(`/customers/${customer.id}`)} + > +
+ e.stopPropagation()} > {customer.name} - - - {t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])} - - -
- {identifier || '-'} - {customer.org_number && customer.vat_number_validated && ( - - {t('verified')} - - )} -
-
- - {customer.email || '-'} - - - {customer.city || '-'} - - - {formatDate(customer.created_at)} - - - ) - })} - -
-
-
- - {/* Mobile card list */} -
- {visibleCustomers.map((customer) => ( - - - -
-
- {getInitials(customer.name)} -
-
- - {customer.name} - -

- {t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])} -

-
-
-
- -
- {customer.email &&

{customer.email}

} - {getIdentifier(customer) && ( -
- {getIdentifier(customer)} {customer.org_number && customer.vat_number_validated && ( - + {t('verified')} )} -
- )} - {customer.city && ( -

- {customer.city}, {customer.country} -

- )} -
-
-
- - ))} + + + + {t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])} + + + {identifier || ''} + + + {customer.email || ''} + + + {customer.city || ''} + + + ) + })} + +
+ + {/* Footer note (concept pgnote) */} +

+ {t('count_summary', { count: customers.length })} +

+ {visibleCount < sortedCustomers.length && (
- } - /> + {/* Page header (concept scene 31): title + quiet Tagga historik. + "Tagga historik" stays Swedish like the workbench it opens (PR6). */} +
+

{t('dimensions')}

+ + Tagga historik + +
) diff --git a/app/(dashboard)/salary/employees/page.tsx b/app/(dashboard)/salary/employees/page.tsx index 22d566d9..e7036415 100644 --- a/app/(dashboard)/salary/employees/page.tsx +++ b/app/(dashboard)/salary/employees/page.tsx @@ -5,15 +5,13 @@ import dynamic from 'next/dynamic' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' -import { Card, CardContent } from '@/components/ui/card' -import { Skeleton } from "@/components/ui/skeleton" +import { Skeleton } from '@/components/ui/skeleton' import { Button } from '@/components/ui/button' -import { PageHeader } from '@/components/ui/page-header' import { EmptyState } from '@/components/ui/empty-state' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Plus, ArrowLeft, UserCircle } from 'lucide-react' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { Plus, UserCircle } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' -import { formatCurrency } from '@/lib/utils' +import { cn, formatCurrency } from '@/lib/utils' import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton' import type { Employee } from '@/types' @@ -28,6 +26,11 @@ const EMPLOYMENT_LABEL_KEYS: Record = { board_member: 'employment_board_member', } +/** + * Anställda register (concept scene 28): header + dry-table. Tax table, + * jämkning, förmåner and semester are configured on the employee detail + * page; the register is the roster. + */ export default function EmployeesPage() { const t = useTranslations('employees') const [employees, setEmployees] = useState([]) @@ -61,91 +64,90 @@ export default function EmployeesPage() { return (
-
- -
- - - {t('new_employee')} - - ) : undefined - } - /> -
+ {/* Page header (concept scene 28) */} +
+

{t('title')}

+ {canWrite && ( + + )}
{loading ? ( -
+
{[1, 2, 3].map(i => ( - + ))}
) : employees.length === 0 ? ( - - - - - + ) : ( - - - - - - {t('th_name')} - {t('th_personnummer')} - {t('th_type')} - {t('th_salary')} - {t('th_employment_degree')} - {t('th_tax_table')} - - - +
+
+
+ + + + + + + + + + + {employees.map(emp => ( - - - + router.push(`/salary/employees/${emp.id}`)} + > + + + + + + + ))} - -
{t('th_name')}{t('th_personnummer')}{t('th_type')}{t('th_salary')}{t('th_employment_degree')}{t('th_tax_table')}
+ e.stopPropagation()} + > {emp.first_name} {emp.last_name} - - + {emp.personnummer} - - + {EMPLOYMENT_LABEL_KEYS[emp.employment_type] ? t(EMPLOYMENT_LABEL_KEYS[emp.employment_type]) : emp.employment_type} - - + {emp.salary_type === 'hourly' ? emp.hourly_rate ? `${formatCurrency(emp.hourly_rate)}${t('hourly_suffix')}` : '-' : emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '-'} - - + {emp.employment_degree}% - - + {emp.tax_table_number ? t('tax_table_format', { table: emp.tax_table_number, column: emp.tax_column ?? '' }) : '-'} - - +
-
-
+ + +
+ {/* Footer note (concept pgnote) */} +

+ {t('registered_count', { count: employees.length })} +

+
)} {showNewEmployee && ( diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx index 95f20d9c..f0ede455 100644 --- a/app/(dashboard)/suppliers/page.tsx +++ b/app/(dashboard)/suppliers/page.tsx @@ -2,19 +2,20 @@ import { useState, useEffect } from 'react' import dynamic from 'next/dynamic' +import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { EmptyState } from '@/components/ui/empty-state' import { Skeleton } from '@/components/ui/skeleton' -import { PageHeader } from '@/components/ui/page-header' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { useToast } from '@/components/ui/use-toast' import { Plus, Search, Building2, Lock } from 'lucide-react' import Link from 'next/link' +import { cn } from '@/lib/utils' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { Supplier, SupplierType, CreateSupplierInput } from '@/types' @@ -39,6 +40,8 @@ const SUPPLIER_TYPE_KEYS: Record = { } const INITIAL_VISIBLE_ROWS = 100 +// "Betalsätt" cell (concept scene 26): the supplier's primary payment route, +// e.g. "BG 5050-1055". First match wins, mirroring the detail page's order. function getPaymentInfo(supplier: Supplier, t: (key: string) => string): { label: string; value: string } | null { if (supplier.bankgiro) return { label: t('label_bg'), value: supplier.bankgiro } if (supplier.plusgiro) return { label: t('label_pg'), value: supplier.plusgiro } @@ -47,12 +50,6 @@ function getPaymentInfo(supplier: Supplier, t: (key: string) => string): { label return null } -function formatLocation(supplier: Supplier): string | null { - if (supplier.city && supplier.country) return `${supplier.city}, ${supplier.country}` - if (supplier.city) return supplier.city - return null -} - export default function SuppliersPage() { const { company } = useCompany() const { canWrite } = useCanWrite() @@ -66,6 +63,7 @@ export default function SuppliersPage() { const { toast } = useToast() const supabase = createClient() const tCommon = useTranslations('common') + const router = useRouter() async function fetchSuppliers() { if (!company) return @@ -90,6 +88,7 @@ export default function SuppliersPage() { useEffect(() => { fetchSuppliers() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) async function handleCreateSupplier(data: CreateSupplierInput) { @@ -131,148 +130,137 @@ export default function SuppliersPage() { return (
- - - - - - - - - {t('add_supplier')} - - - - -
- } - /> + {/* Page header (concept scene 26): title + export + Ny leverantör */} +
+

{t('title')}

+
+ + + + + + + + {t('add_supplier')} + + + + +
+
- {/* Search */} -
- - { - setSearchTerm(e.target.value) - setVisibleCount(INITIAL_VISIBLE_ROWS) - }} - className="pl-10" - /> + {/* Toolbar: search (concept) */} +
+
+ + { + setSearchTerm(e.target.value) + setVisibleCount(INITIAL_VISIBLE_ROWS) + }} + className="h-9 pl-10" + /> +
- {/* Supplier list */} {isLoading ? ( -
- {[1, 2, 3].map((i) => ( - - - - -
- - - - +
+ {[1, 2, 3, 4].map((i) => ( + ))}
) : filteredSuppliers.length === 0 ? ( - - - {searchTerm ? ( - - ) : ( - setIsDialogOpen(true) : undefined} - /> - )} - - + searchTerm ? ( + + ) : ( + setIsDialogOpen(true) : undefined} + /> + ) ) : ( <> -
- {visibleSuppliers.map((supplier) => { - const payment = getPaymentInfo(supplier, t) - const location = formatLocation(supplier) - return ( - - - -
-

- {supplier.name} -

-

+

+ + + + + + + + + + + + {visibleSuppliers.map((supplier) => { + const payment = getPaymentInfo(supplier, t) + return ( + router.push(`/suppliers/${supplier.id}`)} + > + + + + + + + ) + })} + +
{t('th_name')}{t('th_type')}{t('th_payment')}{t('th_email')}{t('th_org_number')}
+ e.stopPropagation()} + > + {supplier.name} + + {t(SUPPLIER_TYPE_KEYS[supplier.supplier_type])} -

- - -
- {supplier.org_number && ( -
-
{t('label_org_number')}
-
{supplier.org_number}
-
- )} - {payment && ( -
-
{payment.label}
-
{payment.value}
-
- )} - {supplier.email && ( -
-
{t('label_email')}
-
{supplier.email}
-
- )} - {location && ( -
-
{t('label_location')}
-
{location}
-
- )} - {!supplier.org_number && !payment && !supplier.email && !location && ( -

{t('no_contact_info')}

- )} -
- - - - ) - })} +
+ {payment ? `${payment.label} ${payment.value}` : ''} + + {supplier.email || ''} + + {supplier.org_number || ''} +
+ + {/* Footer note (concept pgnote) */} +

+ {t('count_summary', { count: suppliers.length })} +

+ {visibleCount < filteredSuppliers.length && (
+ +
+
+ ) + + const switchView = (next: 'my-accounts' | 'bas-catalog') => { + setView(next) + setCollapsedMyClasses(new Set()) + setExpandedCatalogClasses(new Set()) + if (next === 'bas-catalog') void ensureReferenceLoaded() } - return ( -
- {/* Header controls */} -
- { - const next = v as 'my-accounts' | 'bas-catalog' - setView(next) - setExpandedClasses(new Set()) - if (next === 'bas-catalog') void ensureReferenceLoaded() - }} + // Concept band row (Klass N: label), clickable to fold the class away. + const bandRow = ( + cls: number, + open: boolean, + onToggle: () => void, + countLabel: string, + colSpan: number, + ) => ( + + + + + + ) - {view === 'my-accounts' && ( -
- - -
- )} + return ( +
+ {header} + {/* Toolbar: seg + search; the K2 filter rides far right in catalog view */} +
+
+ + +
+
+ + setSearchQuery(e.target.value)} + className="h-9 pl-10" + /> +
{view === 'bas-catalog' && ( -