diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 84e14764..88562f4d 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -411,6 +411,7 @@ export default function NewInvoicePage() { your_reference: pendingData.your_reference, our_reference: pendingData.our_reference, notes: pendingData.notes, + invoice_number: numberPreview, }), }) diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index dc10fc46..924d32cc 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -15,7 +15,7 @@ import { formatCurrency, formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' import { invoiceNumberDisplay } from '@/lib/invoices/display' import { getDisplayTotal } from '@/lib/invoices/rounding' -import { Plus, Search, Receipt, Lock } from 'lucide-react' +import { Plus, Search, Receipt, Lock, Repeat } from 'lucide-react' import { EmptyInvoices, EmptyState } from '@/components/ui/empty-state' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' @@ -138,22 +138,30 @@ export default function InvoicesPage() { - - ) : ( - - ) + {canWrite ? ( + + + + ) : ( + + )} + } /> diff --git a/app/(dashboard)/invoices/recurring/new/page.tsx b/app/(dashboard)/invoices/recurring/new/page.tsx new file mode 100644 index 00000000..20f687d8 --- /dev/null +++ b/app/(dashboard)/invoices/recurring/new/page.tsx @@ -0,0 +1,383 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/client' +import { useForm, useFieldArray, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +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 { PageHeader } from '@/components/ui/page-header' +import { useToast } from '@/components/ui/use-toast' +import { useCompany } from '@/contexts/CompanyContext' +import { ArrowLeft, Plus, Trash2 } from 'lucide-react' +import type { Customer, Currency } from '@/types' +import { formatCurrency } from '@/lib/utils' + +const itemSchema = z.object({ + description: z.string().min(1, 'Beskrivning krävs'), + quantity: z.number().min(0.01, 'Minst 0.01'), + unit: z.string().min(1, 'Enhet krävs'), + unit_price: z.number(), + vat_rate: z + .union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)]) + .nullable() + .optional(), +}) + +const schema = z.object({ + customer_id: z.string().uuid('Välj en kund'), + name: z.string().min(1, 'Namn krävs'), + day_of_month: z.number().int().min(1).max(31), + payment_terms_days: z.number().int().min(0).max(90), + currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']), + auto_send: z.boolean(), + your_reference: z.string().optional(), + our_reference: z.string().optional(), + notes: z.string().optional(), + items: z.array(itemSchema).min(1, 'Minst en rad krävs'), +}) + +type FormData = z.infer + +const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] +const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] + +export default function NewRecurringSchedulePage() { + const router = useRouter() + const { toast } = useToast() + const { company } = useCompany() + const supabase = createClient() + const [customers, setCustomers] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { + register, + control, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + customer_id: '', + name: '', + day_of_month: 15, + payment_terms_days: 30, + currency: 'SEK', + auto_send: false, + items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }], + }, + }) + + const { fields, append, remove } = useFieldArray({ control, name: 'items' }) + + useEffect(() => { + if (!company) return + supabase + .from('customers') + .select('*') + .eq('company_id', company.id) + .order('name') + .then(({ data }) => setCustomers(data ?? [])) + }, [company]) + + async function onSubmit(data: FormData) { + setIsSubmitting(true) + try { + const res = await fetch('/api/invoices/recurring', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || 'Kunde inte skapa schema') + } + toast({ title: 'Schema skapat' }) + router.push('/invoices/recurring') + } catch (err) { + toast({ + title: 'Kunde inte skapa schema', + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + const items = watch('items') + const watchCurrency = watch('currency') + const subtotalRaw = items.reduce( + (sum, it) => sum + (it.quantity || 0) * (it.unit_price || 0), + 0, + ) + // Round to öre using the project monetary rule, then format. + const subtotal = Math.round(subtotalRaw * 100) / 100 + + return ( +
+ + + Tillbaka + + + + +
+ + + Schema + + +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + ( + + )} + /> + {errors.customer_id && ( +

{errors.customer_id.message}

+ )} +
+ +
+
+ + +

+ 29-31 körs sista dagen i kortare månader. +

+
+
+ + +
+
+ + ( + + )} + /> +
+
+ +
+
+ ( + field.onChange(e.target.checked)} + className="mt-1 h-4 w-4" + /> + )} + /> +
+ +

+ När markerad: fakturan skickas med e-post till kunden direkt vid + skapande. Annars skapas den som utkast för manuell granskning. +

+
+
+
+
+
+ + + + Rader + + + {fields.map((field, index) => ( +
+
+ +
+
+ +
+
+ ( + + )} + /> +
+
+ +
+
+ +
+
+ ))} + +
+ Delsumma exkl. moms: {formatCurrency(subtotal, watchCurrency)} +
+
+
+ + + + Övrigt + + +
+
+ + +
+
+ + +
+
+
+ +