diff --git a/app/(dashboard)/articles/[id]/page.tsx b/app/(dashboard)/articles/[id]/page.tsx new file mode 100644 index 00000000..7db4ff50 --- /dev/null +++ b/app/(dashboard)/articles/[id]/page.tsx @@ -0,0 +1,329 @@ +'use client' + +import { useState, useEffect, use } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { useTranslations } from 'next-intl' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useToast } from '@/components/ui/use-toast' +import ArticleForm from '@/components/articles/ArticleForm' +import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' +import { + ArrowLeft, + Package, + Wrench, + Edit2, + Archive, + Loader2, + Lock, +} from 'lucide-react' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatCurrency } from '@/lib/utils' +import type { Article, ArticleType, CreateArticleInput } from '@/types' + +const ARTICLE_TYPE_KEY: Record = { + vara: 'type_vara', + tjanst: 'type_tjanst', +} + +const articleTypeIcons: Record = { + vara: Package, + tjanst: Wrench, +} + +export default function ArticleDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const router = useRouter() + const { toast } = useToast() + const { canWrite } = useCanWrite() + const t = useTranslations('article_detail') + const [article, setArticle] = useState
(null) + const [isLoading, setIsLoading] = useState(true) + const [isEditOpen, setIsEditOpen] = useState(false) + const [isUpdating, setIsUpdating] = useState(false) + const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() + + useEffect(() => { + fetchArticle() + }, [id]) + + async function fetchArticle() { + setIsLoading(true) + try { + const response = await fetch(`/api/articles/${id}`) + if (!response.ok) { + throw new Error('Not found') + } + const { data } = await response.json() + setArticle(data) + } catch { + toast({ + title: t('load_failed_title'), + description: t('load_failed_description'), + variant: 'destructive', + }) + router.push('/articles') + } finally { + setIsLoading(false) + } + } + + async function handleUpdate(data: CreateArticleInput) { + setIsUpdating(true) + try { + const response = await fetch(`/api/articles/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + throw new Error('Update failed') + } + + toast({ + title: t('updated_title'), + description: data.name, + }) + setIsEditOpen(false) + fetchArticle() + } catch { + toast({ + title: t('update_failed_title'), + description: t('retry'), + variant: 'destructive', + }) + } finally { + setIsUpdating(false) + } + } + + async function handleDeactivate() { + if (!article) return + const ok = await confirmAction({ + title: t('deactivate_confirm_title', { name: article.name }), + description: t('deactivate_confirm_description'), + confirmLabel: t('deactivate_confirm_label'), + variant: 'destructive', + }) + if (!ok) return + + try { + const response = await fetch(`/api/articles/${id}`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error('Deactivate failed') + } + + toast({ + title: t('deactivated_title'), + description: article.name, + }) + router.push('/articles') + } catch { + toast({ + title: t('deactivate_failed_title'), + description: t('retry'), + variant: 'destructive', + }) + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!article) return null + + const Icon = articleTypeIcons[article.type] + + return ( +
+ {/* Header */} +
+
+ + + {t('back')} + +
+
+ +
+
+

{article.name}

+
+ {t(ARTICLE_TYPE_KEY[article.type])} + {article.article_number && ( + + {article.article_number} + + )} + + {article.active ? t('status_active') : t('status_inactive')} + +
+
+
+
+ +
+ + {article.active && ( + + )} +
+
+ + {/* Info cards */} +
+ {/* Pricing */} + + + {t('section_pricing')} + + +
+ {t('label_price')} + {formatCurrency(article.price_excl_vat)} +
+
+ {t('label_vat')} + {article.vat_rate} % +
+
+ {t('label_unit')} + {article.unit} +
+ {article.cost_price != null && ( +
+ {t('label_cost_price')} + {formatCurrency(article.cost_price)} +
+ )} +
+
+ + {/* Accounting */} + + + {t('section_accounting')} + + +
+ {t('label_revenue_account')} + + {article.revenue_account || t('revenue_account_auto')} + +
+ {article.type === 'tjanst' && article.housework_type && ( +
+ {t('label_housework')} + {article.housework_type} +
+ )} +
+
+ + {/* Details */} + + + {t('section_details')} + + + {article.name_en && ( +
+ {t('label_name_en')} + {article.name_en} +
+ )} + {article.ean && ( +
+ {t('label_ean')} + {article.ean} +
+ )} + {!article.name_en && !article.ean && ( +

{t('no_details')}

+ )} +
+
+
+ + {/* Notes */} + {article.notes && ( + + + {t('section_notes')} + + +

{article.notes}

+
+
+ )} + + + + {/* Edit dialog */} + + + + {t('edit_dialog_title')} + + + + +
+ ) +} diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx new file mode 100644 index 00000000..fad2a2f6 --- /dev/null +++ b/app/(dashboard)/articles/page.tsx @@ -0,0 +1,421 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback, Suspense } from 'react' +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 { 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' +import ArticleForm from '@/components/articles/ArticleForm' +import { EmptyState } from '@/components/ui/empty-state' +import { PageHeader } from '@/components/ui/page-header' +import { formatCurrency } from '@/lib/utils' +import Link from 'next/link' +import { useCompany } from '@/contexts/CompanyContext' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import type { Article, ArticleType, CreateArticleInput } from '@/types' + +const ARTICLE_TYPE_LABEL_KEYS: Record = { + vara: 'type_vara', + tjanst: 'type_tjanst', +} + +type SortColumn = 'name' | 'article_number' | 'type' | 'unit' | 'price_excl_vat' | 'vat_rate' +type SortDir = 'asc' | 'desc' + +const SORTABLE_COLUMNS: ReadonlyArray = [ + 'name', + 'article_number', + 'type', + 'unit', + 'price_excl_vat', + 'vat_rate', +] + +function compareStrings(a: string, b: string): number { + return a.localeCompare(b, 'sv', { sensitivity: 'base' }) +} + +function ArticlesPageInner() { + const { company } = useCompany() + const { canWrite } = useCanWrite() + const [articles, setArticles] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [searchTerm, setSearchTerm] = useState('') + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const { toast } = useToast() + const supabase = createClient() + const t = useTranslations('articles') + const errorLocale = useLocale() as ErrorLocale + + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const sortParam = searchParams.get('sort') + const dirParam = searchParams.get('dir') + const sortColumn: SortColumn = (SORTABLE_COLUMNS as ReadonlyArray).includes(sortParam ?? '') + ? (sortParam as SortColumn) + : 'name' + const sortDir: SortDir = dirParam === 'desc' ? 'desc' : 'asc' + + const updateSort = useCallback( + (column: SortColumn) => { + const params = new URLSearchParams(searchParams.toString()) + let nextDir: SortDir = 'asc' + if (column === sortColumn) { + nextDir = sortDir === 'asc' ? 'desc' : 'asc' + } + params.set('sort', column) + params.set('dir', nextDir) + router.replace(`${pathname}?${params.toString()}`, { scroll: false }) + }, + [searchParams, sortColumn, sortDir, router, pathname] + ) + + async function fetchArticles() { + if (!company) return + setIsLoading(true) + const { data, error } = await supabase + .from('articles') + .select('*') + .eq('company_id', company.id) + .eq('active', true) + .order('name', { ascending: true }) + + if (error) { + toast({ + title: t('load_failed_title'), + description: t('load_failed_description'), + variant: 'destructive', + }) + } else { + setArticles(data || []) + } + setIsLoading(false) + } + + useEffect(() => { + fetchArticles() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + async function handleCreateArticle(data: CreateArticleInput) { + setIsCreating(true) + + const response = await fetch('/api/articles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + + const result = await response.json() + + if (!response.ok) { + toast({ + title: t('create_failed_title'), + description: getErrorMessage(result, { context: 'article', locale: errorLocale }), + variant: 'destructive', + }) + } else { + toast({ + title: t('created_title'), + description: t('created_description', { name: data.name }), + }) + setArticles([...articles, result.data]) + setIsDialogOpen(false) + } + + setIsCreating(false) + } + + const filteredArticles = useMemo(() => { + const term = searchTerm.trim().toLowerCase() + if (!term) return articles + return articles.filter((a) => { + return ( + a.name.toLowerCase().includes(term) || + a.name_en?.toLowerCase().includes(term) || + a.article_number?.toLowerCase().includes(term) + ) + }) + }, [articles, searchTerm]) + + const sortedArticles = useMemo(() => { + const arr = [...filteredArticles] + arr.sort((a, b) => { + let cmp = 0 + switch (sortColumn) { + case 'price_excl_vat': + cmp = a.price_excl_vat - b.price_excl_vat + break + case 'vat_rate': + cmp = a.vat_rate - b.vat_rate + break + case 'name': + cmp = compareStrings(a.name || '', b.name || '') + break + case 'article_number': + cmp = compareStrings(a.article_number || '', b.article_number || '') + break + case 'type': + cmp = compareStrings(a.type || '', b.type || '') + break + case 'unit': + cmp = compareStrings(a.unit || '', b.unit || '') + break + } + return sortDir === 'asc' ? cmp : -cmp + }) + return arr + }, [filteredArticles, sortColumn, sortDir]) + + function SortableHeader({ + column, + label, + className, + }: { + column: SortColumn + label: string + className?: string + }) { + const isActive = sortColumn === column + const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown + return ( + + + + ) + } + + return ( +
+ + + + + + + {t('add_article')} + + + + + } + /> + + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Article list */} + {isLoading ? ( + <> + {/* Desktop skeleton */} + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + {/* Mobile skeleton */} +
+ {[1, 2, 3].map((i) => ( + + + + + + + + + + ))} +
+ + ) : sortedArticles.length === 0 ? ( + + + {searchTerm ? ( + + ) : ( + setIsDialogOpen(true) : undefined} + /> + )} + + + ) : ( + <> + {/* Desktop table */} + + + + + + + + + + + + {t('col_status')} + + + + {sortedArticles.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')} + + + + ))} + +
+
+
+ + {/* Mobile card list */} +
+ {sortedArticles.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 })} +
+
+
+ + ))} +
+ + )} +
+ ) +} + +export default function ArticlesPage() { + return ( + + + + ) +} diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 605256c0..9d27ed17 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -39,11 +39,17 @@ import { RUT_MAX, computeDeduction, } from '@/lib/invoices/rot-rut-rules' -import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType } from '@/types' +import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article } from '@/types' const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] +// Subset of Article fields the line picker needs to pre-fill a row. +type ArticleOption = Pick< + Article, + 'id' | 'article_number' | 'name' | 'unit' | 'price_excl_vat' | 'vat_rate' | 'revenue_account' +> + function RequiredMark() { return } @@ -67,6 +73,9 @@ export default function NewInvoicePage() { unit: z.string().min(1, t('validation_unit_required')), unit_price: z.number().min(0, t('validation_price_positive')), vat_rate: z.number().min(0).max(25), + // Article linkage (artikelregister). Optional — free-text lines omit them. + article_id: z.string().nullable().optional(), + revenue_account: z.string().nullable().optional(), // ROT/RUT-avdrag per line. Optional — null means "no deduction". deduction_type: z.enum(['rot', 'rut']).nullable().optional(), labor_hours: z.number().nonnegative().nullable().optional(), @@ -120,6 +129,9 @@ export default function NewInvoicePage() { const [vatRegistered, setVatRegistered] = useState(true) const [numberPreview, setNumberPreview] = useState(null) const [logoUrl, setLogoUrl] = useState(null) + // Artikelregister: active articles for the line picker + which line is mid quick-create. + const [articles, setArticles] = useState([]) + const [savingArticleIndex, setSavingArticleIndex] = useState(null) // True only when the user had zero invoices when this page loaded. The // post-create flow uses this to offer a one-shot "upload a logo?" prompt // — issue #520. Self-limits: once count > 0 it stays false. @@ -152,6 +164,8 @@ export default function NewInvoicePage() { unit: 'st', unit_price: 0, vat_rate: 25, + article_id: null, + revenue_account: null, deduction_type: null, labor_hours: null, work_type: null, @@ -194,8 +208,85 @@ export default function NewInvoicePage() { if (!company?.id) return fetchCustomers() fetchDefaultNotes() + fetchArticles() }, [company?.id]) + async function fetchArticles() { + if (!company?.id) return + const { data } = await supabase + .from('articles') + .select('id, article_number, name, unit, price_excl_vat, vat_rate, revenue_account') + .eq('company_id', company.id) + .eq('active', true) + .order('name') + setArticles((data ?? []) as ArticleOption[]) + } + + // Apply a chosen article's defaults onto a line. Selecting "none" detaches the + // article link (and its account override) but keeps the typed text/price so the + // row becomes an editable free-text line. + function applyArticle(index: number, articleId: string) { + if (articleId === 'none') { + setValue(`items.${index}.article_id`, null, { shouldDirty: true }) + setValue(`items.${index}.revenue_account`, null, { shouldDirty: true }) + return + } + const a = articles.find((x) => x.id === articleId) + if (!a) return + setValue(`items.${index}.article_id`, a.id, { shouldDirty: true }) + setValue(`items.${index}.description`, a.name, { shouldValidate: true, shouldDirty: true }) + if (a.unit) setValue(`items.${index}.unit`, a.unit, { shouldDirty: true }) + setValue(`items.${index}.unit_price`, Number(a.price_excl_vat) || 0, { shouldValidate: true, shouldDirty: true }) + // Only adopt the article's VAT rate when it's allowed for this customer + // (and the rate isn't locked, e.g. reverse charge / export). Otherwise keep + // the line's current rate so the API's per-customer VAT rule isn't violated. + if (!isRateLocked && availableRates.some((r) => r.rate === a.vat_rate)) { + setValue(`items.${index}.vat_rate`, a.vat_rate, { shouldValidate: true, shouldDirty: true }) + } + // The account override rides along regardless of rate; the engine ignores it + // for reverse-charge/export and validates it against the chart of accounts. + setValue(`items.${index}.revenue_account`, a.revenue_account ?? null, { shouldDirty: true }) + } + + // "Spara som artikel": persist the current free-text line into the register and + // back-fill the article_id so the row is now catalog-linked. + async function saveLineAsArticle(index: number) { + const item = watchItems[index] + if (!item?.description?.trim()) { + toast({ title: t('save_article_need_description'), variant: 'destructive' }) + return + } + setSavingArticleIndex(index) + try { + const response = await fetch('/api/articles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: item.description.trim(), + unit: item.unit || 'st', + price_excl_vat: Number(item.unit_price) || 0, + vat_rate: item.vat_rate ?? 25, + }), + }) + const result = await response.json() + if (!response.ok) { + throw new Error(getErrorMessage(result, { context: 'article', statusCode: response.status })) + } + const created = result.data as ArticleOption + setArticles((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name, 'sv'))) + setValue(`items.${index}.article_id`, created.id, { shouldDirty: true }) + toast({ title: t('article_saved_title'), description: created.name }) + } catch (error) { + toast({ + title: t('save_article_failed'), + description: getErrorMessage(error, { context: 'article' }), + variant: 'destructive', + }) + } finally { + setSavingArticleIndex(null) + } + } + async function fetchDefaultNotes() { if (!company?.id) return const { data } = await supabase @@ -864,6 +955,55 @@ export default function NewInvoicePage() { key={field.id} className="rounded-lg border bg-card p-4 space-y-3 relative md:rounded-none md:border-0 md:bg-transparent md:p-0 md:space-y-0 md:grid md:grid-cols-12 md:gap-4 md:items-start" > + {/* 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 */}
@@ -1106,6 +1246,8 @@ export default function NewInvoicePage() { unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25, + article_id: null, + revenue_account: null, deduction_type: null, labor_hours: null, work_type: null, diff --git a/app/api/articles/[id]/route.ts b/app/api/articles/[id]/route.ts new file mode 100644 index 00000000..494fdebf --- /dev/null +++ b/app/api/articles/[id]/route.ts @@ -0,0 +1,146 @@ +import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { UpdateArticleSchema } from '@/lib/api/schemas' +import { withRouteContext } from '@/lib/api/with-route-context' +import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { Article } from '@/types' + +ensureInitialized() + +export const GET = withRouteContext( + 'article.get', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + const opLog = log.child({ articleId: id }) + + const { data, error } = await supabase + .from('articles') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (error) { + if (error.code === 'PGRST116') { + return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId }) + } + opLog.error('article fetch failed', error) + return errorResponseFromCode('INTERNAL_ERROR', opLog, { + requestId, + details: { reason: error.message }, + }) + } + + return NextResponse.json({ data }) + }, +) + +export const PATCH = withRouteContext( + 'article.update', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ articleId: id }) + + const result = await validateBody(request, UpdateArticleSchema, { + log: opLog, + operation: 'article.update', + }) + if (!result.success) return result.response + const body = result.data + + if (body.revenue_account) { + const ok = await isValidRevenueAccount(supabase, companyId!, body.revenue_account) + if (!ok) { + return errorResponseFromCode('ARTICLE_REVENUE_ACCOUNT_INVALID', opLog, { requestId }) + } + } + + // Sparse update — only the fields the caller actually sent. + const updateData: Record = {} + for (const key of [ + 'name', 'name_en', 'type', 'unit', 'price_excl_vat', 'vat_rate', + 'revenue_account', 'cost_price', 'ean', 'housework_type', 'notes', + 'article_number', 'active', + ] as const) { + if (body[key] !== undefined) updateData[key] = body[key] + } + + const { data, error } = await supabase + .from('articles') + .update(updateData) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() + + if (error) { + if (error.code === 'PGRST116') { + return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId }) + } + if (error.code === '23505') { + return errorResponseFromCode('ARTICLE_DUPLICATE_NUMBER', opLog, { + requestId, + details: { articleNumber: body.article_number }, + }) + } + opLog.error('article update failed', error) + return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } + + await eventBus.emit({ + type: 'article.updated', + payload: { article: data as Article, companyId: companyId!, userId: user.id }, + }) + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) + +// DELETE soft-deactivates (active = false) rather than hard-deleting. Articles +// are master data referenced by historical invoice lines via a (frozen) copy; +// keeping the row preserves the register's audit trail and the article number. +// Re-activate by PATCHing { active: true }. +export const DELETE = withRouteContext( + 'article.delete', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ articleId: id }) + + const { data, error } = await supabase + .from('articles') + .update({ active: false }) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() + + if (error) { + if (error.code === 'PGRST116') { + return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId }) + } + opLog.error('article deactivate failed', error) + return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, { + requestId, + details: { reason: error.message }, + }) + } + + await eventBus.emit({ + type: 'article.updated', + payload: { article: data as Article, companyId: companyId!, userId: user.id }, + }) + + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/articles/__tests__/id.test.ts b/app/api/articles/__tests__/id.test.ts new file mode 100644 index 00000000..4718c800 --- /dev/null +++ b/app/api/articles/__tests__/id.test.ts @@ -0,0 +1,73 @@ +/** + * Tests for GET/PATCH/DELETE /api/articles/[id] (artikelregister). + * + * DELETE soft-deactivates (active = false) rather than hard-deleting, so the + * article and its number survive for history. PATCH is a sparse update. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase, createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { GET, PATCH, DELETE } from '../[id]/route' + +describe('GET/PATCH/DELETE /api/articles/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('GET returns 404 when the article is not found', async () => { + enqueue({ data: null, error: { code: 'PGRST116', message: 'not found' } }) + + const response = await GET(createMockRequest('/api/articles/a1'), createMockRouteParams({ id: 'a1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('ARTICLE_NOT_FOUND') + }) + + it('PATCH updates a field and returns the row', async () => { + enqueue({ data: { id: 'a1', name: 'Konsulttimme', price_excl_vat: 1500 } }) + + const request = createMockRequest('/api/articles/a1', { + method: 'PATCH', + body: { price_excl_vat: 1500 }, + }) + + const response = await PATCH(request, createMockRouteParams({ id: 'a1' })) + const { status, body } = await parseJsonResponse<{ data: { price_excl_vat: number } }>(response) + + expect(status).toBe(200) + expect(body.data.price_excl_vat).toBe(1500) + }) + + it('DELETE soft-deactivates and returns success', async () => { + enqueue({ data: { id: 'a1', active: false } }) + + const response = await DELETE(createMockRequest('/api/articles/a1', { method: 'DELETE' }), createMockRouteParams({ id: 'a1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) +}) diff --git a/app/api/articles/__tests__/route.test.ts b/app/api/articles/__tests__/route.test.ts new file mode 100644 index 00000000..50f576e9 --- /dev/null +++ b/app/api/articles/__tests__/route.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for GET/POST /api/articles (artikelregister). + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: list, validation (400), revenue-account guard (400), + * and the happy-path create with auto-numbering. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { GET, POST } from '../route' + +describe('GET/POST /api/articles', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('GET lists the company articles', async () => { + enqueue({ data: [{ id: 'a1', name: 'Konsulttimme' }, { id: 'a2', name: 'Licens' }] }) + + const response = await GET(createMockRequest('/api/articles'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(2) + }) + + it('POST rejects an invalid body (missing name) with 400', async () => { + const request = createMockRequest('/api/articles', { + method: 'POST', + body: { price_excl_vat: 100 }, + }) + + const response = await POST(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('POST rejects a revenue_account that is not an active class-3 account', async () => { + // chart_of_accounts lookup returns no row → override is invalid. + enqueue({ data: null }) + + const request = createMockRequest('/api/articles', { + method: 'POST', + body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3999' }, + }) + + const response = await POST(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID') + }) + + it('POST creates an article and auto-assigns a number', async () => { + // 1st DB hit: insert ... returning the row (article_number still null). + enqueue({ data: { id: 'a1', name: 'Konsulttimme', article_number: null, type: 'tjanst', vat_rate: 25 } }) + // 2nd DB hit: generate_article_number RPC returns the assigned number. + enqueue({ data: '7' }) + + const request = createMockRequest('/api/articles', { + method: 'POST', + body: { name: 'Konsulttimme', price_excl_vat: 1200, vat_rate: 25 }, + }) + + const response = await POST(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { id: string; article_number: string } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('a1') + expect(body.data.article_number).toBe('7') + }) +}) diff --git a/app/api/articles/route.ts b/app/api/articles/route.ts new file mode 100644 index 00000000..8b3047b7 --- /dev/null +++ b/app/api/articles/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { CreateArticleSchema } from '@/lib/api/schemas' +import { withRouteContext } from '@/lib/api/with-route-context' +import { ensureArticleNumber } from '@/lib/articles/ensure-article-number' +import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { Article } from '@/types' + +ensureInitialized() + +// GET /api/articles — list the active company's articles. `?include_inactive=1` +// returns soft-deactivated ones too (the register page can show an archive view). +export const GET = withRouteContext( + 'article.list', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const includeInactive = new URL(request.url).searchParams.get('include_inactive') === '1' + + let query = supabase + .from('articles') + .select('*') + .eq('company_id', companyId) + if (!includeInactive) query = query.eq('active', true) + + const { data, error } = await query.order('name', { ascending: true }) + + if (error) { + log.error('article list failed', error) + return errorResponse(error, log, { requestId }) + } + + return NextResponse.json({ data }) + }, +) + +export const POST = withRouteContext( + 'article.create', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const result = await validateBody(request, CreateArticleSchema, { + log, + operation: 'article.create', + }) + if (!result.success) return result.response + const body = result.data + + // Guard the optional revenue-account override against the chart of accounts. + if (body.revenue_account) { + const ok = await isValidRevenueAccount(supabase, companyId!, body.revenue_account) + if (!ok) { + return errorResponseFromCode('ARTICLE_REVENUE_ACCOUNT_INVALID', log, { requestId }) + } + } + + const { data, error } = await supabase + .from('articles') + .insert({ + user_id: user.id, + company_id: companyId, + name: body.name, + name_en: body.name_en ?? null, + type: body.type ?? 'tjanst', + unit: body.unit ?? 'st', + price_excl_vat: body.price_excl_vat, + vat_rate: body.vat_rate ?? 25, + revenue_account: body.revenue_account ?? null, + cost_price: body.cost_price ?? null, + ean: body.ean ?? null, + housework_type: body.housework_type ?? null, + notes: body.notes ?? null, + article_number: body.article_number ?? null, + }) + .select() + .single() + + if (error) { + if (error.code === '23505') { + return errorResponseFromCode('ARTICLE_DUPLICATE_NUMBER', log, { + requestId, + details: { articleNumber: body.article_number }, + }) + } + log.error('article insert failed', error) + return errorResponseFromCode('ARTICLE_CREATE_FAILED', log, { + requestId, + details: { reason: error.message }, + }) + } + + // Auto-number when the caller didn't supply one. Non-fatal: an unnumbered + // article is still usable and can be numbered later. + if (!data.article_number) { + try { + data.article_number = await ensureArticleNumber(supabase, companyId!, data.id) + } catch (err) { + log.warn('article number assignment failed', err as Error, { articleId: data.id }) + } + } + + await eventBus.emit({ + type: 'article.created', + payload: { article: data as Article, companyId: companyId!, userId: user.id }, + }) + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index f585701d..0ba612bc 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -145,6 +145,41 @@ export const POST = withRouteContext( } const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount + // Validate any per-line revenue-account override against the company's chart + // of accounts. Zod already constrains the shape to a 3xxx string; here we + // confirm each is a real, active class-3 account so a typo or a non-revenue + // account can never be booked. Never trust the client — same posture as the + // server-recomputed ROT/RUT amounts. + const overrideAccounts = Array.from( + new Set( + invoiceInput.items + .map((item) => item.revenue_account) + .filter((a): a is string => !!a), + ), + ) + if (overrideAccounts.length > 0) { + const { data: validAccounts, error: accountsError } = await supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId!) + .eq('account_class', 3) + .eq('is_active', true) + .in('account_number', overrideAccounts) + + if (accountsError) { + log.error('revenue account validation query failed', accountsError) + return errorResponse(accountsError, log, { requestId }) + } + const validSet = new Set((validAccounts ?? []).map((a) => a.account_number)) + const invalid = overrideAccounts.filter((a) => !validSet.has(a)) + if (invalid.length > 0) { + return errorResponseFromCode('INVOICE_CREATE_REVENUE_ACCOUNT_INVALID', log, { + requestId, + details: { invalidAccounts: invalid }, + }) + } + } + // ROT/RUT-avdrag: validate prerequisites and compute the per-item + // invoice-level deduction. Computed server-side (never trusted from // the client) so a tampered request can't expand the 1513 receivable. @@ -294,6 +329,11 @@ export const POST = withRouteContext( line_total: lineTotal, vat_rate: itemRate, vat_amount: itemVat, + // Article linkage. revenue_account is frozen-copied here so a later + // article edit never re-books this line; null falls through to the + // VAT-treatment-derived account in generatePerRateLines(). + article_id: item.article_id ?? null, + revenue_account: item.revenue_account ?? null, deduction_type: deductionType, deduction_amount: deductionAmount, labor_hours: documentType === 'invoice' ? (item.labor_hours ?? null) : null, @@ -469,7 +509,7 @@ async function createCreditNote( }) } - const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number }) => ({ + const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null }) => ({ invoice_id: creditNote.id, sort_order: item.sort_order, description: item.description, @@ -479,6 +519,12 @@ async function createCreditNote( line_total: -Math.abs(item.line_total), vat_rate: item.vat_rate ?? 0, vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0), + // Carry the original's per-line revenue-account override so the reversal + // hits the SAME account it originally credited (e.g. 3041, not the + // VAT-derived 3001) — otherwise the override account keeps a dangling + // balance. article_id is preserved for the usage history. + revenue_account: item.revenue_account ?? null, + article_id: item.article_id ?? null, })) const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts index f9b33c19..e14da941 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts @@ -126,7 +126,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const { data: invoice, error: fetchErr } = await ctx.supabase .from('invoices') .select( - `${INVOICE_MARK_SENT_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type, country), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`, + `${INVOICE_MARK_SENT_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type, country), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account)`, ) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index c350f8bf..33380c66 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -154,7 +154,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const { data: invoice, error: fetchErr } = await ctx.supabase .from('invoices') .select( - `${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`, + `${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account)`, ) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) diff --git a/components/articles/ArticleForm.tsx b/components/articles/ArticleForm.tsx new file mode 100644 index 00000000..a95665ff --- /dev/null +++ b/components/articles/ArticleForm.tsx @@ -0,0 +1,337 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { ChevronDown, Loader2, Lock } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import type { CreateArticleInput } from '@/types' + +// Unit list mirrors the invoice line editor (app/(dashboard)/invoices/new/page.tsx). +const UNITS = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] as const + +// Legal Swedish VAT rates as integer percent. Matches vatRatePercent in +// lib/api/schemas.ts (25 | 12 | 6 | 0). +const VAT_RATES = [25, 12, 6, 0] as const + +interface ArticleFormProps { + onSubmit: (data: CreateArticleInput) => Promise + isLoading: boolean + initialData?: Partial +} + +export default function ArticleForm({ + onSubmit, + isLoading, + initialData, +}: ArticleFormProps) { + const { canWrite } = useCanWrite() + const t = useTranslations('form_article') + // Open the advanced section by default when it already holds data, so an + // edit never hides a value the user previously set. + const [advancedOpen, setAdvancedOpen] = useState( + Boolean( + initialData?.revenue_account || + initialData?.cost_price != null || + initialData?.ean || + initialData?.housework_type || + initialData?.notes, + ), + ) + + // UI-local schema mirroring CreateArticleSchema (lib/api/schemas.ts). + const schema = useMemo( + () => + z.object({ + name: z.string().min(1, t('name_required')), + name_en: z.string().optional(), + type: z.enum(['vara', 'tjanst']), + unit: z.string().min(1), + price_excl_vat: z.number({ message: t('price_required') }).nonnegative(t('price_required')), + vat_rate: z.union([z.literal(25), z.literal(12), z.literal(6), z.literal(0)]), + revenue_account: z.string().optional(), + cost_price: z.number().nonnegative().optional(), + ean: z.string().optional(), + housework_type: z.string().optional(), + notes: z.string().optional(), + }), + [t], + ) + + type FormData = z.infer + + const { + register, + handleSubmit, + watch, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: initialData?.name || '', + name_en: initialData?.name_en || '', + type: initialData?.type || 'tjanst', + unit: initialData?.unit || 'st', + price_excl_vat: initialData?.price_excl_vat ?? 0, + vat_rate: (initialData?.vat_rate as FormData['vat_rate']) ?? 25, + revenue_account: initialData?.revenue_account || '', + cost_price: initialData?.cost_price ?? undefined, + ean: initialData?.ean || '', + housework_type: initialData?.housework_type || '', + notes: initialData?.notes || '', + }, + }) + + const type = watch('type') + + const onFormSubmit = (data: FormData) => { + onSubmit({ + name: data.name, + name_en: data.name_en || null, + type: data.type, + unit: data.unit, + price_excl_vat: data.price_excl_vat, + vat_rate: data.vat_rate, + revenue_account: data.revenue_account || null, + cost_price: data.cost_price ?? null, + ean: data.ean || null, + housework_type: type === 'tjanst' ? data.housework_type || null : null, + notes: data.notes || null, + }) + } + + return ( +
+ {/* Type */} +
+ + ( + + )} + /> +
+ + {/* Name */} +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ + {/* English name */} +
+ + +

{t('name_en_hint')}

+
+ + {/* Unit + price + VAT */} +
+
+ + ( + + )} + /> +
+
+ + + {errors.price_excl_vat && ( +

{errors.price_excl_vat.message}

+ )} +
+
+ + ( + + )} + /> +
+
+ + {/* Advanced (collapsible) */} +
+ + + {advancedOpen && ( +
+ {/* Revenue account */} +
+ + +

{t('revenue_account_hint')}

+
+ + {/* Cost price */} +
+ + (v === '' || v == null ? undefined : Number(v)), + })} + /> +

{t('cost_price_hint')}

+
+ + {/* EAN */} +
+ + +
+ + {/* Housework type (tjänst only) */} + {type === 'tjanst' && ( +
+ + ( + + )} + /> +

{t('housework_hint')}

+
+ )} + + {/* Notes */} +
+ +