diff --git a/.gitignore b/.gitignore index b6e739ad..7a285ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,11 @@ supabase/.branches/ # Local-only SIE test fixtures — may contain real/scrubbed company data, never commit tests/fixtures/sie/ +# Local-only DESTRUCTIVE duplicate-cleanup tooling — one-off, run by hand against +# real räkenskapsinformation. Deliberately NOT committed so it can never run in +# CI/cron and so its logic isn't mistaken for a supported product feature. +scripts/delete-duplicate-transactions.ts + # Diagnostic/cleanup tooling under /scripts is tracked, but the DATA those # scripts read or emit (ledger dumps, reconciliation exports) is real customer # räkenskapsinformation — never commit it. Keep the .ts/.sql tooling, ignore the data. diff --git a/app/(dashboard)/assets/page.tsx b/app/(dashboard)/assets/page.tsx index e204c223..9c2ae7de 100644 --- a/app/(dashboard)/assets/page.tsx +++ b/app/(dashboard)/assets/page.tsx @@ -10,7 +10,7 @@ import { Badge } from '@/components/ui/badge' import { PageHeader } from '@/components/ui/page-header' import { EmptyState } from '@/components/ui/empty-state' import { Skeleton } from '@/components/ui/skeleton' -import { Package, Plus } from 'lucide-react' +import { Package, Pencil, Plus } from 'lucide-react' import { Table, TableBody, @@ -22,6 +22,10 @@ import { import { 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 = { immaterial: 'category_immaterial', @@ -36,9 +40,10 @@ const CATEGORY_LABEL_KEYS: Record = { export default function AssetsPage() { const t = useTranslations('assets') - const [assets, setAssets] = useState(null) + const [assets, setAssets] = useState(null) const [error, setError] = useState(null) const [dialogOpen, setDialogOpen] = useState(false) + const [editing, setEditing] = useState(null) const [reloadKey, setReloadKey] = useState(0) @@ -51,7 +56,7 @@ export default function AssetsPage() { setError(t('load_failed')) return } - const { data } = (await res.json()) as { data: Asset[] } + const { data } = (await res.json()) as { data: AssetRow[] } if (cancelled) return setError(null) setAssets(data) @@ -69,6 +74,11 @@ export default function AssetsPage() { setReloadKey((k) => k + 1) }, []) + const handleSaved = useCallback(() => { + setEditing(null) + setReloadKey((k) => k + 1) + }, []) + return (
{!asset.disposed_at && ( - - - + + + +
)} @@ -167,6 +187,18 @@ export default function AssetsPage() { )} + + {editing && ( + { + if (!open) setEditing(null) + }} + onSaved={handleSaved} + /> + )} ) } diff --git a/app/(dashboard)/invoices/[id]/edit/page.tsx b/app/(dashboard)/invoices/[id]/edit/page.tsx new file mode 100644 index 00000000..00cc5e2e --- /dev/null +++ b/app/(dashboard)/invoices/[id]/edit/page.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useEffect, useState, use } from 'react' +import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' +import { createClient } from '@/lib/supabase/client' +import { useToast } from '@/components/ui/use-toast' +import { Loader2 } from 'lucide-react' +import InvoiceEditor, { type InvoiceForEdit } from '@/components/invoices/InvoiceEditor' +import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft' +import type { InvoiceItem } from '@/types' + +/** + * Edit an existing DRAFT invoice. Loads the invoice + items, guards that it is + * still an editable draft (no committed verifikat, not sent, not self-billed), + * then hands it to the shared in edit mode. The PATCH route + * enforces the same guard server-side; this just avoids opening a dead form. + */ +export default function EditInvoicePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const router = useRouter() + const { toast } = useToast() + const supabase = createClient() + const t = useTranslations('invoice_detail') + + const [invoice, setInvoice] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let cancelled = false + ;(async () => { + const { data, error } = await supabase + .from('invoices') + .select('*, customer:customers(*), items:invoice_items(*)') + .eq('id', id) + .single() + + if (cancelled) return + + if (error || !data) { + toast({ + title: t('load_failed_title'), + description: t('load_failed_description'), + variant: 'destructive', + }) + router.replace('/invoices') + return + } + + // Only drafts (no committed verifikat, not sent, not a received + // self-billing document) may be edited — shared predicate, the same one + // the PATCH route enforces server-side. + const editable = isEditableInvoiceDraft(data) + if (!editable) { + toast({ + title: t('edit_not_allowed_title'), + description: t('edit_not_allowed_description'), + variant: 'destructive', + }) + router.replace(`/invoices/${id}`) + return + } + + // The editor's field array expects items in display order. + if (Array.isArray(data.items)) { + data.items.sort((a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order) + } + + setInvoice(data as InvoiceForEdit) + setIsLoading(false) + })() + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + + if (isLoading || !invoice) { + return ( +
+ +
+ ) + } + + return +} diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index bb246a67..a66cd1b2 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -14,6 +14,7 @@ import { formatCurrency, formatDate, cn } from '@/lib/utils' import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules' import { invoiceDisplayNumber } from '@/lib/invoices/display' import { getDisplayTotal } from '@/lib/invoices/rounding' +import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft' import { Loader2, ArrowLeft, @@ -31,6 +32,7 @@ import { Trash2, Lock, CalendarClock, + Pencil, } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog' @@ -519,6 +521,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st // Self-billing invoices we received: the document is the counterparty's, so // there is no own PDF to render and no send step — it arrives already booked. const isSelfBilled = !!invoice.is_self_billed + // A draft (no committed verifikat, not sent, not self-billed) can be edited + // in place — header + lines — via /invoices/{id}/edit. Sent/paid invoices are + // immutable (BFL); they are corrected with a credit note instead. + const isEditableDraft = isEditableInvoiceDraft(invoice) const hasAccruedItems = invoice.items.some(itemHasAccrual) return (
@@ -559,6 +565,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {/* Actions */}
+ {isEditableDraft && canWrite && ( + + + + )} {isProforma && invoice.status !== 'cancelled' && ( -
-

- {titleText} - {numberPreview && !isSelfBilled && ( - - ({numberPreview}) - - )} -

-

{subtitleText}

-
- -
- - setMode(v as 'invoice' | 'self_billed')}> - - {t('mode_invoice')} - {t('mode_self_billed')} - - - - {hasBankDetails === false && !isSelfBilled && ( -
- -

{t('bank_missing_warning')}

- -
- )} - -
-
- {/* Main content */} -
- {/* Customer selection */} - - - {isSelfBilled ? <>{ts('customer_label')} : <>{t('customer_card_title')}} - {isSelfBilled ? ts('issuer_card_description') : t('customer_card_description')} - - - ( - - )} - /> - - {errors.customer_id && ( -

{errors.customer_id.message}

- )} - - {isSelfBilled && ( -
-
- - - {errors.external_invoice_number && ( -

{errors.external_invoice_number.message}

- )} -
-
- - -
-
- )} - -
-
- - {/* Invoice items */} - - - {t('items_card_title')} - {t('items_card_description')} - - -
- - {fields.map((field, index) => { - const isTextRow = watchItems[index]?.line_type === 'text' - const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0) - const lineVat = vatRegistered && !isTextRow - ? Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100 - : 0 - // Free-text / blank row: just a description field (may be left - // empty for a spacer) and a delete button. - if (isTextRow) { - return ( - -
-
-
- - -
- -
-
-
- ) - } - // Per-row action button. On real invoices it's a ⋮ menu that - // holds both the ROT/RUT skattereduktion choice and delete; - // proformas/delivery notes have no deduction model, so they - // keep a plain trash button (a one-item menu would be noise). - const renderRowActions = (triggerClassName: string) => - isInvoiceDoc ? ( - - - - - - {t('deduction_menu_label')} - { - const next = v === 'none' ? null : (v as 'rot' | 'rut') - setValue(`items.${index}.deduction_type`, next, { shouldDirty: true }) - if (next === null) { - setValue(`items.${index}.work_type`, null) - setValue(`items.${index}.labor_hours`, null) - setValue(`items.${index}.housing_designation`, null) - setValue(`items.${index}.apartment_number`, null) - } else if (watchItems[index]?.accrual_balance_account != null) { - // ROT/RUT och periodisering kombineras aldrig - // på samma rad — avdraget vinner. - setValue(`items.${index}.accrual_period_start`, null) - setValue(`items.${index}.accrual_period_end`, null) - setValue(`items.${index}.accrual_balance_account`, null) - } - }} - > - {t('deduction_none')} - {t('deduction_rot')} - {t('deduction_rut')} - - {canUseAccrual && !watchItems[index]?.deduction_type && ( - <> - - toggleAccrual(index)} className="py-2"> - - {watchItems[index]?.accrual_balance_account != null - ? ta('row_menu_remove') - : ta('row_menu_add')} - - - )} - - remove(index)} - > - - {t('remove_row')} - - - - ) : ( - - ) - return ( - -
- {/* Article picker (artikelregister). Optional — leave on - "Egen rad" to type a free-text line. Selecting an - article pre-fills description, unit, price, VAT and any - revenue-account override. */} -
-
- - ( - - )} - /> -
- {canWrite && ( - - )} -
- - {/* Description + mobile delete button */} -
-
- - - {errors.items?.[index]?.description && ( -

- {errors.items[index].description?.message} -

- )} -
- {renderRowActions('shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden')} -
- - {/* Antal, Enhet, à-pris */} -
-
- - -
-
- - ( - - )} - /> -
-
- - -
-
- - {/* Moms — hidden entirely when the company is not - momsregistrerad (no VAT may be charged). */} - {vatRegistered && ( -
- - ( - - )} - /> -
- )} - - {/* Desktop row actions (⋮ menu or trash). An invisible - label spacer mirrors the field columns (same Label + - space-y-2), so the button sits on the input row — not - high against the labels, nor low at the row bottom. */} -
- -
- {renderRowActions('')} -
-
- - {/* ROT/RUT-avdrag strip — only when a deduction is active - on this row (chosen via the ⋮ menu). A leading tag shows - which reduction applies; the work-type + hours are - required for the Skatteverket claim. Rows with no - deduction render nothing here and stay clean. */} - {isInvoiceDoc && watchItems[index]?.deduction_type && ( -
-
- - {watchItems[index]?.deduction_type === 'rot' ? 'ROT(30)' : 'RUT(50)'} - - { - const opts = - watchItems[index]?.deduction_type === 'rot' - ? ROT_WORK_TYPES - : RUT_WORK_TYPES - return ( - - ) - }} - /> - - v === '' || Number.isNaN(v) ? null : Number(v), - })} - /> - {(() => { - const amt = computeDeduction({ - unit_price: watchItems[index]?.unit_price || 0, - quantity: watchItems[index]?.quantity || 0, - deduction_type: watchItems[index]?.deduction_type, - }) - return amt > 0 ? ( - - −{formatCurrency(amt, watchCurrency)} - - ) : null - })()} -
- {/* Labor-only disclosure (Skatteverket fakturamodellen). - 30%/50% applies to the full line total — the seller - must ensure the line is 100% labor; material has - to be invoiced separately. */} -
- -

{t('deduction_labor_only_warning')}

-
-
- )} - - {/* Periodisering (förutbetald intäkt) — activated via the - row's ⋮ menu. Intäkten krediteras 29xx vid bokning och - löses upp månadsvis över perioden; momsen påverkas inte. */} - {canUseAccrual && watchItems[index]?.accrual_balance_account != null && ( -
- { - setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true }) - setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true }) - setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true }) - }} - onRemove={() => toggleAccrual(index)} - /> - {errors.items?.[index]?.accrual_period_end && ( -

- {errors.items[index].accrual_period_end?.message} -

- )} -
- )} - - {/* Mobile summary row */} -
- {t('row_label', { index: index + 1 })} - {formatCurrency(lineTotal + lineVat, watchCurrency)} -
-
-
- ) - })} -
- -
- - {/* Free-text / blank row — explanatory text under an item, or - an empty spacer. Carries no amounts and never books. Not - offered for a received självfaktura: that is a faithful - revenue-only transcription, and the self-billed endpoint - (SelfBillingInvoiceItemSchema) has no line_type and rejects - zero-amount rows. */} - {!isSelfBilled && ( - - )} -
-
-
-
- - {/* ROT/RUT-avdrag claim info. Surfaces only when any item has - a deduction_type set — keeps the form quiet for the 90%+ - of users who don't sell ROT/RUT-eligible services. */} - {isInvoiceDoc && hasAnyDeduction && ( - - - {t('deduction_card_title')} - {t('deduction_card_description')} - - -
- - -

- {t('deduction_personnummer_hint')} -

-
- {hasAnyRotLine && ( -
- - -

- {t('deduction_housing_hint')} -

-
- )} - {(deductionByKind.rot > ROT_MAX || deductionByKind.rut > RUT_MAX) && ( -
- {t('deduction_cap_over')} - {deductionByKind.rot > ROT_MAX && ` (ROT ${ROT_MAX.toLocaleString('sv-SE')} kr)`} - {deductionByKind.rut > RUT_MAX && ` (RUT ${RUT_MAX.toLocaleString('sv-SE')} kr)`} - {'. '} - {t('deduction_cap_check')} -
- )} -
-
- )} - - {/* Notes */} - - - {t('notes_card_title')} - {t('notes_card_description')} - - -