ccdfed5fea
* feat: voucher linking, recovery ops, and salary overrides Adds reversible/correction-style write paths that customers and agents have been asking for, plus per-run salary employee overrides. Invoice → voucher linking - POST /api/invoices/[id]/link-to-voucher and GET /api/invoices/[id]/voucher-candidates - lib/invoices/voucher-matching.ts with full + pg test coverage - LinkVoucherPicker UI in PaymentBookingDialog - pending_operations.operation_type expanded with link_invoice_voucher (medium risk) and a (journal_entry_id, invoice_id) unique guard - MCP: gnubok_find_voucher_candidates_for_invoice and gnubok_link_invoice_to_voucher tools SIE undo - POST /api/import/sie/[id]/undo + undo_sie_import RPC - sie_imports.status gains 'undone' - ImportResultStep surfaces the action; structured error SIE_UNDO_FAILED Edit-recreate journal entries - POST /api/bookkeeping/journal-entries/[id]/edit-recreate - Bookkeeping detail page wires it into the existing edit flow Delete-last-voucher clears IB link - Trigger + pg test ensure deleting the last voucher of a period nulls the opening_balance_journal_entry_id link so a re-import lands cleanly Salary employee overrides - salary_run_employees gains per-run override fields + migration - lib/salary/effective-values.ts centralises resolved values; all payslip, payment, AGI, KU, and booking routes read through it - SalaryOverridePanel on the employee detail page Account classifier - lib/bookkeeping/account-classifier.ts + tests; AddAccountDialog uses it - backfill-import-accounts script updated Misc - toast: minor styling tweak - AGI generate-declaration: respect effective values - structured-errors: new LINK_INVOICE_VOUCHER namespace Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add link_invoice_voucher operation type to pending_operations * feat: refactor salary run calculations and update error handling for SIE imports * fix: PR review feedback on voucher linking and SIE recovery pg-real (blocking): - tests/pg/delete-last-voucher-ib: drop posted_at = now() from the seed UPDATE — journal_entries has no posted_at column. - lib/invoices/__tests__/voucher-matching.pg: seed the posted voucher before closing the fiscal period so enforce_period_lock doesn't block the INSERT during setup. voucher-matching error codes and rollback: - Add LINK_VOUCHER_DB_ERROR (HTTP 500) and return it on real invoice UPDATE / payment INSERT failures. Previously these returned LINK_VOUCHER_VOUCHER_NOT_FOUND (404) which the pending-op dispatcher auto-rejects on transient DB errors. - Log rollback failures explicitly so an invoice left in a half-linked state (advanced status, no payment row) surfaces for manual reconciliation instead of disappearing silently. resyncNextPeriodOpeningBalance ordering: - Create the new IB first, relink the period FK, then storno the old IB. Previously the storno ran first; if createJournalEntry failed the next period was left with a reversed IB and nothing to replace it, and executeSIEImport swallows the error as a non-fatal warning. replace_period_opening_balance_link: - Tighten role check to owner/admin (was owner/admin/member). Matches delete_last_voucher and undo_sie_import. Data minimisation: - /api/invoices/[id]/voucher-candidates and the matching MCP tools now project only the invoice and customer fields the matcher reads, instead of returning the full customer row. Schema bounds: - SalaryEmployeeOverrideSchema caps each numeric override at 10 MSEK to catch typos before they reach the ledger or AGI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): supply user_id when seeding voucher_sequences voucher_sequences.user_id is NOT NULL (per the multi-tenant refactor in 20260330130000). The previous test seed only set company_id / fiscal_period_id / voucher_series, which made the seed fail with a constraint violation on the latest pg-real run. Pass the same userId used elsewhere in the seed helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): scope delete-last-voucher RPC assertions inside the tx withUserContext always ROLLBACKs, so any DELETE the RPC performs is discarded when the callback returns. The previous test then queried journal_entries via a fresh getPool() connection that only saw the pre-RPC committed seed state — hence "expected '1' to be '0'". Move every post-RPC assertion (entry count, period FK clear, opening_balances_set flip, audit log entry, sie_imports clear) inside the same withUserContext callback so they observe the uncommitted state before ROLLBACK fires. Also fix the sie_imports INSERT: the column is `filename`, not `file_name`, and `sie_type` is NOT NULL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): assert against the IB-marker audit row directly DELETE on journal_entries fires two audit_log writes: the generic write_audit_log() trigger row ("Deleted journal_entries record") and the delete_last_voucher RPC's explicit "(was period IB)" entry. Both land at the same statement_timestamp(), so ORDER BY created_at DESC LIMIT 1 returned the trigger row non-deterministically in CI. Switch to a presence check with a LIKE filter on the IB marker so the test verifies what it actually cares about — that the RPC's IB-aware audit row exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(db): set company_id on delete_last_voucher audit_log rows 20260528120000_delete_last_voucher_clears_ib_link.sql inserts directly into audit_log without setting company_id. audit_log's SELECT policy filters company_id IN user_company_ids(), so those rows landed with company_id=NULL and were invisible to every reader — only the generic write_audit_log() trigger row remained visible. That broke BFL audit- trail intent: the "(was period IB)" provenance row was never readable. Republish delete_last_voucher with p_company_id populated on both audit_log INSERTs (draft path and posted path). Behavior is otherwise unchanged; the pg-real test for the IB-clear flow now sees the RPC-written marker row as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
592 lines
23 KiB
TypeScript
592 lines
23 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { AccountNumber } from '@/components/ui/account-number'
|
|
import { AddAccountDialog } from './AddAccountDialog'
|
|
import { EditAccountDialog } from './EditAccountDialog'
|
|
import {
|
|
Search,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Loader2,
|
|
CheckCircle2,
|
|
BookOpen,
|
|
} from 'lucide-react'
|
|
import type { BASAccount } from '@/types'
|
|
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ReferenceAccount extends BASReferenceAccount {
|
|
is_activated: boolean
|
|
is_active: boolean
|
|
is_system_account: boolean
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function ChartOfAccountsManager() {
|
|
const { toast } = useToast()
|
|
const t = useTranslations('chart_of_accounts')
|
|
|
|
const classLabel = (cls: number): string => {
|
|
if (cls < 1 || cls > 8) return ''
|
|
return t(`class_${cls}` as const)
|
|
}
|
|
|
|
const typeLabel = (type: string): string => {
|
|
const key = `type_${type}` as const
|
|
try {
|
|
return t(key)
|
|
} catch {
|
|
return type
|
|
}
|
|
}
|
|
|
|
// View state
|
|
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
|
|
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
|
|
|
|
// Data state
|
|
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
|
const [referenceAccounts, setReferenceAccounts] = useState<ReferenceAccount[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
// Dialog state
|
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
|
const [editAccount, setEditAccount] = useState<BASAccount | null>(null)
|
|
|
|
// Action states
|
|
const [togglingAccount, setTogglingAccount] = useState<string | null>(null)
|
|
const [deletingAccount, setDeletingAccount] = useState<string | null>(null)
|
|
const [activatingAccounts, setActivatingAccounts] = useState<Set<string>>(new Set())
|
|
|
|
// -------------------------------------------
|
|
// Data fetching
|
|
// -------------------------------------------
|
|
|
|
const fetchAccounts = useCallback(async () => {
|
|
const res = await fetch('/api/bookkeeping/accounts')
|
|
const { data } = await res.json()
|
|
setAccounts(data || [])
|
|
}, [])
|
|
|
|
const fetchReference = useCallback(async () => {
|
|
const res = await fetch('/api/bookkeeping/accounts/reference')
|
|
const { data } = await res.json()
|
|
setReferenceAccounts(data || [])
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
async function load() {
|
|
setLoading(true)
|
|
await Promise.all([fetchAccounts(), fetchReference()])
|
|
// Set K2 filter default based on company settings (plan_type)
|
|
if (hideK2Excluded === null) {
|
|
try {
|
|
const res = await fetch('/api/settings')
|
|
if (res.ok) {
|
|
const { data } = await res.json()
|
|
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
|
|
setHideK2Excluded(data?.plan_type === 'k1')
|
|
} else {
|
|
setHideK2Excluded(false)
|
|
}
|
|
} catch {
|
|
setHideK2Excluded(false)
|
|
}
|
|
}
|
|
setLoading(false)
|
|
}
|
|
load()
|
|
}, [fetchAccounts, fetchReference, hideK2Excluded])
|
|
|
|
const refreshAll = useCallback(async () => {
|
|
await Promise.all([fetchAccounts(), fetchReference()])
|
|
}, [fetchAccounts, fetchReference])
|
|
|
|
// -------------------------------------------
|
|
// Actions
|
|
// -------------------------------------------
|
|
|
|
async function toggleActive(account: BASAccount) {
|
|
setTogglingAccount(account.account_number)
|
|
try {
|
|
const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_active: !account.is_active }),
|
|
})
|
|
if (!res.ok) throw new Error(t('toast_update_failed'))
|
|
await refreshAll()
|
|
} catch {
|
|
toast({ title: t('toast_update_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setTogglingAccount(null)
|
|
}
|
|
}
|
|
|
|
async function deleteAccount(account: BASAccount) {
|
|
const confirmed = window.confirm(t('delete_confirm', { number: account.account_number, name: account.account_name }))
|
|
if (!confirmed) return
|
|
setDeletingAccount(account.account_number)
|
|
try {
|
|
const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
|
|
method: 'DELETE',
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json()
|
|
throw new Error(data.error || t('toast_delete_failed'))
|
|
}
|
|
toast({ title: t('toast_account_deleted'), description: `${account.account_number} ${account.account_name}` })
|
|
await refreshAll()
|
|
} catch (err) {
|
|
toast({
|
|
title: err instanceof Error ? err.message : t('toast_delete_failed'),
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setDeletingAccount(null)
|
|
}
|
|
}
|
|
|
|
async function activateBASAccount(accountNumber: string) {
|
|
setActivatingAccounts((prev) => new Set(prev).add(accountNumber))
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/accounts/activate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ account_numbers: [accountNumber] }),
|
|
})
|
|
if (!res.ok) throw new Error(t('toast_activate_failed'))
|
|
const { activated } = await res.json()
|
|
if (activated > 0) {
|
|
toast({ title: t('toast_activated_title'), description: t('toast_activated_description', { number: accountNumber }) })
|
|
}
|
|
await refreshAll()
|
|
} catch {
|
|
toast({ title: t('toast_activate_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setActivatingAccounts((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(accountNumber)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------
|
|
// Toggle class expansion
|
|
// -------------------------------------------
|
|
|
|
function toggleClass(cls: number) {
|
|
setExpandedClasses((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(cls)) {
|
|
next.delete(cls)
|
|
} else {
|
|
next.add(cls)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
// -------------------------------------------
|
|
// Filtered & grouped data
|
|
// -------------------------------------------
|
|
|
|
const filteredAccounts = useMemo(() => {
|
|
if (!searchQuery) return accounts
|
|
const q = searchQuery.toLowerCase()
|
|
return accounts.filter(
|
|
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
|
|
)
|
|
}, [accounts, searchQuery])
|
|
|
|
const groupedAccounts = useMemo(() => {
|
|
const grouped: Record<number, BASAccount[]> = {}
|
|
for (const a of filteredAccounts) {
|
|
const cls = a.account_class
|
|
if (!grouped[cls]) grouped[cls] = []
|
|
grouped[cls].push(a)
|
|
}
|
|
return grouped
|
|
}, [filteredAccounts])
|
|
|
|
const filteredReference = useMemo(() => {
|
|
let filtered = referenceAccounts
|
|
if (hideK2Excluded) {
|
|
filtered = filtered.filter((a) => !a.k2_excluded)
|
|
}
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase()
|
|
filtered = filtered.filter(
|
|
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
return filtered
|
|
}, [referenceAccounts, searchQuery, hideK2Excluded])
|
|
|
|
const groupedReference = useMemo(() => {
|
|
const grouped: Record<number, ReferenceAccount[]> = {}
|
|
for (const a of filteredReference) {
|
|
const cls = a.account_class
|
|
if (!grouped[cls]) grouped[cls] = []
|
|
grouped[cls].push(a)
|
|
}
|
|
return grouped
|
|
}, [filteredReference])
|
|
|
|
// -------------------------------------------
|
|
// Render
|
|
// -------------------------------------------
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
|
|
{t('loading')}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header controls */}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<Tabs
|
|
value={view}
|
|
onValueChange={(v) => {
|
|
setView(v as 'my-accounts' | 'bas-catalog')
|
|
setExpandedClasses(new Set())
|
|
}}
|
|
>
|
|
<TabsList>
|
|
<TabsTrigger value="my-accounts">
|
|
{t('tab_my_accounts')}
|
|
<Badge variant="secondary" className="ml-1.5 text-xs">
|
|
{accounts.length}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="bas-catalog">
|
|
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
|
|
{t('tab_bas_catalog')}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{view === 'my-accounts' && (
|
|
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
{t('add_own')}
|
|
</Button>
|
|
)}
|
|
|
|
{view === 'bas-catalog' && (
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Switch
|
|
checked={hideK2Excluded ?? false}
|
|
onCheckedChange={setHideK2Excluded}
|
|
className="scale-75"
|
|
/>
|
|
<span className="text-muted-foreground">{t('hide_k2_excluded')}</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={t('search_placeholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* My Accounts view */}
|
|
{view === 'my-accounts' && (
|
|
<div className="space-y-2">
|
|
{Object.entries(groupedAccounts)
|
|
.sort(([a], [b]) => Number(a) - Number(b))
|
|
.map(([cls, classAccounts]) => {
|
|
const classNum = Number(cls)
|
|
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
|
|
const activeCount = classAccounts.filter((a) => a.is_active).length
|
|
|
|
return (
|
|
<Card key={cls}>
|
|
<button
|
|
onClick={() => toggleClass(classNum)}
|
|
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 shrink-0" />
|
|
)}
|
|
<span className="font-semibold text-left">
|
|
{t('class_heading', { cls, label: classLabel(classNum) })}
|
|
</span>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{activeCount}/{classAccounts.length}
|
|
</Badge>
|
|
</div>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<CardContent className="pt-0 pb-4">
|
|
<table className="w-full text-sm">
|
|
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
|
<tr className="border-b text-left">
|
|
<th className="py-2 w-24">{t('col_account')}</th>
|
|
<th className="py-2">{t('col_name')}</th>
|
|
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
|
|
<th className="py-2 w-24 text-center">{t('col_type')}</th>
|
|
<th className="py-2 w-16 text-center">{t('col_active')}</th>
|
|
<th className="py-2 w-20 text-right"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{classAccounts.map((account) => (
|
|
<tr
|
|
key={account.id}
|
|
className={`border-b last:border-0 transition-opacity ${
|
|
!account.is_active ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<td className="py-2">
|
|
<AccountNumber number={account.account_number} name={account.account_name} />
|
|
</td>
|
|
<td className="py-2">
|
|
<span className="flex items-center gap-1.5">
|
|
{account.account_name}
|
|
{account.is_system_account && (
|
|
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
|
{t('system_badge')}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 text-center">
|
|
<span className="text-xs font-mono text-muted-foreground">
|
|
{account.sru_code || '\u2014'}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 text-center">
|
|
<Badge variant="outline" className="text-xs">
|
|
{typeLabel(account.account_type)}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-2 text-center">
|
|
<Switch
|
|
checked={account.is_active}
|
|
onCheckedChange={() => toggleActive(account)}
|
|
disabled={togglingAccount === account.account_number}
|
|
className="scale-75"
|
|
/>
|
|
</td>
|
|
<td className="py-2 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-10 w-10"
|
|
onClick={() => setEditAccount(account)}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{!account.is_system_account && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-10 w-10 text-destructive hover:text-destructive"
|
|
onClick={() => deleteAccount(account)}
|
|
disabled={deletingAccount === account.account_number}
|
|
>
|
|
{deletingAccount === account.account_number ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)
|
|
})}
|
|
|
|
{filteredAccounts.length === 0 && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
{searchQuery ? t('no_matches') : t('no_accounts')}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* BAS Catalog view */}
|
|
{view === 'bas-catalog' && (
|
|
<div className="space-y-2">
|
|
{Object.entries(groupedReference)
|
|
.sort(([a], [b]) => Number(a) - Number(b))
|
|
.map(([cls, classAccounts]) => {
|
|
const classNum = Number(cls)
|
|
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
|
|
const activatedCount = classAccounts.filter((a) => a.is_activated).length
|
|
|
|
return (
|
|
<Card key={cls}>
|
|
<button
|
|
onClick={() => toggleClass(classNum)}
|
|
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 shrink-0" />
|
|
)}
|
|
<span className="font-semibold text-left">
|
|
{t('class_heading', { cls, label: classLabel(classNum) })}
|
|
</span>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{t('active_count_label', { active: activatedCount, total: classAccounts.length })}
|
|
</Badge>
|
|
</div>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<CardContent className="pt-0 pb-4">
|
|
<table className="w-full text-sm">
|
|
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
|
<tr className="border-b text-left">
|
|
<th className="py-2 w-24">{t('col_account')}</th>
|
|
<th className="py-2">{t('col_name')}</th>
|
|
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
|
|
<th className="py-2 w-24 text-center">{t('col_type')}</th>
|
|
<th className="py-2 w-28 text-right">{t('col_status')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{classAccounts.map((account) => (
|
|
<tr
|
|
key={account.account_number}
|
|
className={`border-b last:border-0 ${
|
|
account.is_activated ? 'bg-muted/30' : ''
|
|
}`}
|
|
>
|
|
<td className="py-2">
|
|
<AccountNumber number={account.account_number} name={account.account_name} />
|
|
</td>
|
|
<td className="py-2">
|
|
<div>
|
|
<span>{account.account_name}</span>
|
|
{account.description && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
|
{account.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-2 text-center">
|
|
<span className="text-xs font-mono text-muted-foreground">
|
|
{account.sru_code || '\u2014'}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 text-center">
|
|
<Badge variant="outline" className="text-xs">
|
|
{typeLabel(account.account_type)}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-2 text-right">
|
|
{account.is_activated ? (
|
|
<span className="inline-flex items-center gap-1 text-xs text-success">
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
{t('activated')}
|
|
</span>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 text-xs"
|
|
onClick={() => activateBASAccount(account.account_number)}
|
|
disabled={activatingAccounts.has(account.account_number)}
|
|
>
|
|
{activatingAccounts.has(account.account_number) ? (
|
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
)}
|
|
{t('add')}
|
|
</Button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)
|
|
})}
|
|
|
|
{filteredReference.length === 0 && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
{t('no_matches')}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Dialogs */}
|
|
<AddAccountDialog
|
|
open={addDialogOpen}
|
|
onOpenChange={setAddDialogOpen}
|
|
onCreated={refreshAll}
|
|
/>
|
|
|
|
{editAccount && (
|
|
<EditAccountDialog
|
|
open={!!editAccount}
|
|
onOpenChange={(open) => { if (!open) setEditAccount(null) }}
|
|
account={editAccount}
|
|
onSaved={refreshAll}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|