Files
accounted/app/(dashboard)/deadlines/page.tsx
T
Jakob WennbergandClaude Opus 4.8 5ac560ce41 fix: generate tax deadlines for the installed base + correct 2893 label carryover (#1029)
* fix(bookkeeping): refresh correction line description on account change

When editing an ändringsverifikation, CorrectionEntryDialog pre-filled each
line's description from the original entry but never re-derived it when the
user changed the account, so a description carried over from the old account
(e.g. 2393 "Lån från närstående personer, långfristig del") stayed stale on
the newly chosen account (e.g. 2893, the kortfristig account). The regular
JournalEntryForm already auto-fills on account change; this mirrors it.

The refresh is guarded: it only overwrites the description when it is empty or
still equals the previously selected account's name, so a memo the user typed
themselves is preserved. Logic is extracted into a pure, unit-tested helper.

Note: the wrong text on an already-posted correction cannot be repaired (line
descriptions of posted verifikat are immutable per BFL / migration 017); this
prevents recurrence on future corrections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(deadlines): generate tax deadlines for the installed base

Automatic tax deadlines only regenerated when a tax-relevant settings field
changed value (didTaxFieldsChange). Companies fill those fields once at
onboarding, so a later save changed nothing and generated nothing; the annual
cron was the only unconditional trigger. As a result only ~5 of ~776 real
companies had any system deadlines, and the /deadlines empty state told users
to "check the tax settings" that were already complete.

- Settings save now also regenerates when the company has zero system
  deadlines yet (safe first-time backfill; cannot reset is_completed/status).
  Decision extracted into shouldRegenerateTaxDeadlines() with tests.
- The empty-state banner gets a "Generera nu" action wired to the existing
  /api/tax-deadlines/generate route (previously it had no caller). New sv/en
  strings.
- generateNewYearDeadlines (annual cron) paginates company_settings via
  fetchAllRows: a plain .select() silently caps at 1000 rows, leaving
  companies beyond the cap without next-year deadlines.
- scripts/backfill-tax-deadlines.ts: one-off that reruns the real generator
  for non-sandbox companies with zero system deadlines.

Known gap (follow-up): moms_period='yearly' has no deadline config, so annual
VAT filers get no momsdeklaration deadline yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(deadlines): address review feedback + fix settings-route test

- settings/route.ts: fail safe when the system-deadline count query errors.
  A null count on error was treated as 0, which would trigger a
  delete+regenerate and reset is_completed/status on a transient failure;
  now a count error keeps the self-heal off (CodeRabbit, Major).
- Update app/api/settings/__tests__/route.test.ts (added on main via the
  withRouteContext refactor) for the extra deadline-count query and the new
  shouldRegenerateTaxDeadlines export; add self-heal / no-regen cases.
- Soften the "no deadlines created" copy: zero generated rows can also mean
  no applicable obligations (or the moms_yearly gap), not just incomplete
  settings (CodeRabbit, Minor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:55:41 +02:00

332 lines
11 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DeadlineList } from '@/components/deadlines/DeadlineList'
import { PageHeader } from '@/components/ui/page-header'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { AlertTriangle, ArrowRight, CalendarClock, Loader2 } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils'
import type { Deadline } from '@/types'
const supabase = createClient()
export default function DeadlinesPage() {
const { company } = useCompany()
const t = useTranslations('deadlines')
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
const [isLoading, setIsLoading] = useState(true)
const [isGenerating, setIsGenerating] = useState(false)
const { toast } = useToast()
const fetchData = useCallback(async () => {
if (!company) return
setIsLoading(true)
try {
const today = new Date().toISOString().split('T')[0]
const [deadlinesRes, customersRes, overdueRes] = await Promise.all([
supabase.from('deadlines').select('*, customer:customers(name)').eq('company_id', company.id).order('due_date', { ascending: true }),
supabase.from('customers').select('id, name').eq('company_id', company.id).order('name', { ascending: true }),
supabase.from('invoices').select('total_sek, total').eq('company_id', company.id).in('status', ['sent', 'unpaid']).lt('due_date', today),
])
if (deadlinesRes.error) throw deadlinesRes.error
if (customersRes.error) throw customersRes.error
if (overdueRes.error) throw overdueRes.error
const overdueCount = overdueRes.data?.length || 0
const overdueTotal = (overdueRes.data || []).reduce(
(sum, inv) => sum + (inv.total_sek || inv.total || 0),
0
)
setDeadlines(deadlinesRes.data || [])
setCustomers(customersRes.data || [])
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
} catch {
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [toast, t])
useEffect(() => {
fetchData()
}, [fetchData])
const handleGenerateSystemDeadlines = async () => {
setIsGenerating(true)
try {
const response = await fetch('/api/tax-deadlines/generate', { method: 'POST' })
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || t('generate_failed_description'))
}
if ((result.created ?? 0) === 0) {
// Nothing was generated: the tax settings are genuinely incomplete.
// Point the user to fill them in (the banner's settings link stays visible).
toast({
title: t('generate_none_title'),
description: t('generate_none_description'),
})
return
}
toast({
title: t('generate_success_title'),
description: t('generate_success_description', { count: result.created }),
})
fetchData()
} catch (error) {
toast({
title: t('generate_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
} finally {
setIsGenerating(false)
}
}
const handleDeadlineCreate = async (
data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>
) => {
try {
const response = await fetch('/api/deadlines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create deadline')
}
toast({
title: t('created_title'),
description: t('created_description'),
})
fetchData()
} catch (error) {
toast({
title: t('create_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
throw error
}
}
const handleDeadlineToggle = async (deadline: Deadline) => {
const wasCompleted = deadline.is_completed
const newCompleted = !wasCompleted
try {
const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_completed: newCompleted }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to toggle deadline')
}
fetchData()
if (newCompleted) {
toast({
title: t('marked_done', { title: deadline.title }),
action: (
<ToastAction altText={t('undo')} onClick={async () => {
try {
await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_completed: false }),
})
} catch {
toast({
title: t('undo_failed'),
variant: 'destructive',
})
}
}}>
{t('undo')}
</ToastAction>
),
})
} else {
toast({ title: t('marked_not_done', { title: deadline.title }) })
}
} catch (error) {
toast({
title: t('toggle_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
}
}
const handleDeadlineEdit = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(deadline),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to edit deadline')
}
toast({
title: t('updated_title'),
description: t('updated_description'),
})
fetchData()
} catch (error) {
toast({
title: t('update_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
}
}
const handleDeadlineDelete = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete deadline')
}
toast({ title: t('deleted_title') })
fetchData()
} catch (error) {
toast({
title: t('delete_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
}
}
if (isLoading) {
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-lg border p-4">
<div className="flex items-center gap-4">
<div className="w-12 space-y-1">
<Skeleton className="h-5 w-8 mx-auto" />
<Skeleton className="h-3 w-6 mx-auto" />
</div>
<Skeleton className="w-px h-8" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
</div>
</div>
)
}
// Statutory deadlines (moms, arbetsgivardeklaration, F-skatt) are generated
// from the company's tax settings — none present usually means those
// settings were never filled in, so point there instead of letting the page
// read as an empty manual todo list.
const hasSystemDeadlines = deadlines.some((d) => d.source === 'system')
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
{!hasSystemDeadlines && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 rounded-lg border px-4 py-3">
<div className="flex items-center gap-3">
<CalendarClock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<p className="text-sm">
<span className="font-medium">{t('no_system_deadlines_title')}</span>
<span className="text-muted-foreground ml-1.5">
{t('no_system_deadlines_description')}
</span>
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button size="sm" onClick={handleGenerateSystemDeadlines} disabled={isGenerating}>
{isGenerating && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{t('generate_action')}
</Button>
<Link
href="/settings/tax"
className="group inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('generate_open_settings')}
<ArrowRight className="h-4 w-4 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
</div>
)}
{/* Overdue invoices alert */}
{overdueInvoices.count > 0 && (
<Link href="/invoices?status=unpaid" className="group block">
<div className="flex items-center justify-between rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 transition-colors hover:bg-destructive/10">
<div className="flex items-center gap-3">
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
<p className="text-sm">
<span className="font-medium">{t('overdue_invoices', { count: overdueInvoices.count })}</span>
<span className="text-muted-foreground ml-2 tabular-nums">
{formatCurrency(overdueInvoices.total)}
</span>
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:translate-x-0.5 transition-transform" />
</div>
</Link>
)}
<DeadlineList
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
onDeadlineEdit={handleDeadlineEdit}
onDeadlineDelete={handleDeadlineDelete}
/>
</div>
)
}