* feat(home): concept scene 14: greeting + Att gora/Fortsatt panes Hem becomes the founder-approved two-panel layout: serif time-of-day greeting with date and company, the Att gora worklist restyled to the concept pane (eyebrow header, h-rows with count chips, hover chevrons) and a new Fortsatt pane listing in-progress work derived purely from draft state (lib/worklist/resume: journal drafts, invoice drafts/unsent, mid-lifecycle salary runs; deadline boost, cap 3, tested). A completed flow can never render as a resume row by construction: only draft-state rows are fetched. KPI tiles, revenue/expense cards and the deadline/tax widgets leave the page per dev_docs/last_session_resume.md section 8, which also prunes their fetches (journal-line YTD aggregation, unpaid totals, deadlines): the page got faster. Banners, checklist, build-assistant hero and the Skatteverket nudge survive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(home): serif pane titles for Att gora and Fortsatt Founder feedback: the uppercase eyebrow headers read as a stray font. Both pane titles are now the Hedvig display serif (text-lg) over the hairline, matching the page's heading language; the band headers inside Att gora keep their small uppercase form as grouping devices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(home): Geist pane titles for Att gora and Fortsatt Founder call: the pane titles use the body sans (14px medium), not the display serif. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): Geist section headers + drop stale-transactions chip Founder feedback: pane/section headers (Att gora, Fortsatt, the reports groups Lopande/Bokslut/Skatt & moms etc) render in Geist sentence case instead of uppercase eyebrows or serif. The global h1-h3 display-font rule moves into @layer base so utility classes like font-sans can actually override it (unlayered element rules beat Tailwind's layered utilities: this was silently eating the override). Also removes the 'N aldre an 14 dagar' chip from the Bokfora transaktioner row and its stale-count plumbing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): continuous nav crossfade + floating slide-over entrance The rail/full nav states now stay mounted and crossfade past each other (the inactive layer absolute, faded, nudged sideways, inert) while the aside width animates: the switch reads as one continuous motion instead of a DOM swap. The detail slide-over floats in from the right edge (slide-in-from-right-full, 300ms decelerating curve) per the concept, with a quicker ease-in exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): make transitions and enter/exit animations actually run Two silent app-wide animation killers found while chasing 'the nav still is not smooth': 1. The codebase uses the shadcn animate-in/out vocabulary everywhere but no animate plugin was ever installed: Tailwind v4 silently dropped every such class, so popovers, dialogs, menus and the slide-over all appeared instantly. globals.css now defines the exact subset in use (accEnter/accExit keyframes + var-driven utilities), plugin-free, composing with duration/ease via --tw-duration/--tw-ease and collapsing under prefers-reduced-motion. Dialog drops its bracket-variant slide classes (zoom+fade carries the entrance). 2. The scrollbar auto-hide block's universal '* { transition: scrollbar-color ... }' was unlayered, and unlayered rules beat Tailwind's layered transition-* utilities regardless of specificity: every width/margin/color transition in the app was dead. The rule now lives in @layer base. Verified: the aside animates 248->64 over 300ms and the slide-over runs accEnter at 0.3s with the decelerating curve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): finish the rr-mask session-replay masking sweep Main's #1105 switched one amount cell from the no-op sensitive-field class to rr-mask (rrweb's built-in text-masking class). The reskinned tables introduced more sensitive-field cells; all 12 occurrences now use rr-mask so financial amounts are masked in session replays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
216 lines
8.6 KiB
TypeScript
216 lines
8.6 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
|
import { Package, Plus } from 'lucide-react'
|
|
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
|
import type { Asset, AssetCategory } from '@/types'
|
|
import { CreateAssetDialog } from '@/components/bookkeeping/assets/CreateAssetDialog'
|
|
import { EditAssetDialog } from '@/components/bookkeeping/assets/EditAssetDialog'
|
|
|
|
/** GET /api/assets annotates each row with whether depreciation has posted. */
|
|
type AssetRow = Asset & { has_posted_depreciation?: boolean }
|
|
|
|
const CATEGORY_LABEL_KEYS: Record<AssetCategory, string> = {
|
|
immaterial: 'category_immaterial',
|
|
building: 'category_building',
|
|
land_improvement: 'category_land_improvement',
|
|
machinery: 'category_machinery',
|
|
equipment: 'category_equipment',
|
|
vehicle: 'category_vehicle',
|
|
computer: 'category_computer',
|
|
other_tangible: 'category_other_tangible',
|
|
}
|
|
|
|
export default function AssetsPage() {
|
|
const t = useTranslations('assets')
|
|
const router = useRouter()
|
|
const [assets, setAssets] = useState<AssetRow[] | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
const [editing, setEditing] = useState<AssetRow | null>(null)
|
|
|
|
const [reloadKey, setReloadKey] = useState(0)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
fetch('/api/assets')
|
|
.then(async (res) => {
|
|
if (cancelled) return
|
|
if (!res.ok) {
|
|
setError(t('load_failed'))
|
|
return
|
|
}
|
|
const { data } = (await res.json()) as { data: AssetRow[] }
|
|
if (cancelled) return
|
|
setError(null)
|
|
setAssets(data)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setError(t('load_failed'))
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [reloadKey, t])
|
|
|
|
const handleCreated = useCallback(() => {
|
|
setDialogOpen(false)
|
|
setReloadKey((k) => k + 1)
|
|
}, [])
|
|
|
|
const handleSaved = useCallback(() => {
|
|
setEditing(null)
|
|
setReloadKey((k) => k + 1)
|
|
}, [])
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Page header (concept scene 29): title + Ny tillgång */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
|
|
<Button onClick={() => setDialogOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t('new_asset')}
|
|
</Button>
|
|
</div>
|
|
|
|
{assets === null && !error && (
|
|
<div className="space-y-3">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-10 w-full" />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
|
|
{assets !== null && assets.length === 0 && (
|
|
<EmptyState
|
|
icon={Package}
|
|
title={t('empty_title')}
|
|
description={t('empty_description')}
|
|
actionLabel={t('new_asset')}
|
|
onAction={() => setDialogOpen(true)}
|
|
/>
|
|
)}
|
|
|
|
{assets !== null && assets.length > 0 && (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<thead>
|
|
<tr>
|
|
<th className={cn(TH_CLASS, 'w-full')}>{t('th_name')}</th>
|
|
<th className={cn(TH_CLASS, 'hidden md:table-cell')}>{t('th_category')}</th>
|
|
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('th_acquired')}</th>
|
|
<th className={cn(TH_CLASS, 'text-right')}>{t('th_acquisition_cost')}</th>
|
|
<th className={cn(TH_CLASS, 'hidden text-right md:table-cell')}>{t('th_useful_life')}</th>
|
|
<th className={cn(TH_CLASS, 'text-right')}>{t('th_status')}</th>
|
|
<th className={cn(TH_CLASS, 'w-[120px]')} aria-hidden="true"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="stagger-enter">
|
|
{assets.map((asset) => {
|
|
const years = Math.round(asset.useful_life_months / 12)
|
|
const disposed = !!asset.disposed_at
|
|
return (
|
|
<tr
|
|
key={asset.id}
|
|
className={cn(
|
|
'group transition-colors duration-150 hover:bg-secondary/35',
|
|
!disposed && 'cursor-pointer',
|
|
)}
|
|
onClick={!disposed ? () => setEditing(asset) : undefined}
|
|
>
|
|
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
|
<span className="block truncate">{asset.name}</span>
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground md:table-cell')}>
|
|
{t(CATEGORY_LABEL_KEYS[asset.category])}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
|
|
{formatDate(asset.acquisition_date)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
|
{formatCurrency(Number(asset.acquisition_cost))}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground md:table-cell')}>
|
|
{t('useful_life_format', { years, months: asset.useful_life_months })}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
|
|
{disposed ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<Badge variant="secondary" className="font-normal">
|
|
{t('status_disposed')}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{formatDate(asset.disposed_at!)}
|
|
</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">{t('status_active')}</span>
|
|
)}
|
|
</td>
|
|
{/* Row actions as hover-revealed quiet links (concept) */}
|
|
<td
|
|
className={cn(TD_CLASS, 'whitespace-nowrap text-right')}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{!disposed && (
|
|
<span className="inline-flex items-center gap-4 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover:opacity-100">
|
|
<button
|
|
type="button"
|
|
className={QUIET_LINK_CLASS}
|
|
onClick={() => setEditing(asset)}
|
|
>
|
|
{t('action_edit')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={QUIET_LINK_CLASS}
|
|
onClick={() => router.push(`/assets/${asset.id}/dispose`)}
|
|
>
|
|
{t('action_dispose')}
|
|
</button>
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Footer count (concept pgnote) */}
|
|
<p className="px-1 text-xs text-muted-foreground tabular-nums">
|
|
{t('count_footer', { count: assets.length })}
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
<CreateAssetDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={handleCreated} />
|
|
|
|
{editing && (
|
|
<EditAssetDialog
|
|
key={editing.id}
|
|
asset={editing}
|
|
open={editing !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditing(null)
|
|
}}
|
|
onSaved={handleSaved}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|