'use client' import { useState, useEffect, useMemo } from 'react' import { useTranslations } from 'next-intl' 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 { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' 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 { useToast } from '@/components/ui/use-toast' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' import { Plus, Trash2 } from 'lucide-react' import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types' import { formatCurrency } from '@/lib/utils' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] interface Props { open: boolean onOpenChange: (open: boolean) => void /** Fired after a successful create or edit. Hosts close the dialog and refresh their list. */ onSaved: () => void /** When provided, the dialog edits this schedule (PATCH) instead of creating a new one. */ schedule?: RecurringInvoiceSchedule } /** * "Nytt schema" / "Redigera schema" as a modal: mirrors NewInvoiceDialog now * that regular invoice creation opens in one. Passing `schedule` switches it to * edit mode (prefilled form, PATCH on save). */ export default function NewRecurringScheduleDialog({ open, onOpenChange, onSaved, schedule }: Props) { const t = useTranslations('invoice_recurring_new') return ( e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > {schedule ? t('edit_title') : t('title')} onOpenChange(false)} /> ) } // Inner component so form state resets whenever the dialog reopens (Radix // unmounts DialogContent children on close). This is also what makes edit mode // work: each open re-mounts with the current schedule's values as defaults. function NewRecurringScheduleForm({ schedule, onSaved, onCancel, }: { schedule?: RecurringInvoiceSchedule onSaved: () => void onCancel: () => void }) { const { toast } = useToast() const { company } = useCompany() const supabase = createClient() const t = useTranslations('invoice_recurring_new') const [customers, setCustomers] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) const schema = useMemo(() => { const itemSchema = z.object({ description: z.string().min(1, t('validation_description_required')), quantity: z.number().min(0.01, t('validation_quantity_min')), unit: z.string().min(1, t('validation_unit_required')), unit_price: z.number(), vat_rate: z .union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)]) .nullable() .optional(), }) return z.object({ customer_id: z.string().uuid(t('validation_customer_required')), name: z.string().min(1, t('validation_name_required')), day_of_month: z.number().int().min(1).max(31), send_hour: z.number().int().min(0).max(23), 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, t('validation_min_one_row')), }) }, [t]) type FormData = z.infer const { register, control, handleSubmit, watch, setValue, formState: { errors }, } = useForm({ resolver: zodResolver(schema), defaultValues: schedule ? { customer_id: schedule.customer_id, name: schedule.name, day_of_month: schedule.day_of_month, send_hour: schedule.send_hour ?? 8, payment_terms_days: schedule.payment_terms_days, currency: schedule.currency, auto_send: schedule.auto_send, your_reference: schedule.your_reference ?? undefined, our_reference: schedule.our_reference ?? undefined, notes: schedule.notes ?? undefined, items: schedule.items && schedule.items.length > 0 ? [...schedule.items] .sort((a, b) => a.sort_order - b.sort_order) .map((it) => ({ description: it.description, quantity: it.quantity, unit: it.unit, unit_price: it.unit_price, vat_rate: (it.vat_rate as 0 | 6 | 12 | 25 | null) ?? null, })) : [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }], } : { customer_id: '', name: '', day_of_month: 15, send_hour: 8, 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( schedule ? `/api/invoices/recurring/${schedule.id}` : '/api/invoices/recurring', { method: schedule ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }, ) if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string | { message?: string } } // errorResponse() returns the nested envelope { error: { code, message } }, // so reading body.error directly would stringify to "[object Object]". const message = typeof body.error === 'string' ? body.error : body.error?.message throw new Error(message || t('create_failed_fallback')) } toast({ title: schedule ? t('updated_title') : t('created_title') }) onSaved() } catch (err) { toast({ title: schedule ? t('update_failed_title') : t('create_failed_title'), description: err instanceof Error ? getUserErrorMessage(err) : undefined, variant: 'destructive', }) } finally { setIsSubmitting(false) } } const items = watch('items') const watchCurrency = watch('currency') // Automatic sending requires a customer email; without one the cron would // just produce a monthly draft + warning. Block it at the source. const watchCustomerId = watch('customer_id') const selectedCustomer = customers.find((c) => c.id === watchCustomerId) const customerMissingEmail = !!selectedCustomer && !selectedCustomer.email const hasEmailSend = useCapability(CAPABILITY.email_send) const autoSendBlocked = customerMissingEmail || !hasEmailSend // The onValueChange guard on the customer select only fires on a manual // change. In edit mode a schedule can load with auto_send=true against a // customer who has since lost their email (customers load async, after the // form's defaultValues). Force auto_send off whenever the effective customer // has no email (or email sending isn't entitled) so a disabled-but-checked // box can't PATCH auto_send=true. useEffect(() => { if (autoSendBlocked) setValue('auto_send', false) }, [autoSendBlocked, setValue]) 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 ( {t('schedule_card_title')} {t('name_label')} {errors.name && ( {errors.name.message} )} {t('customer_label')} ( { field.onChange(v) // Switching to a customer without email while auto-send is // checked would create an unsendable schedule. const c = customers.find((x) => x.id === v) if (!c?.email) setValue('auto_send', false) }} > {customers.map((c) => ( {c.name} ))} )} /> {errors.customer_id && ( {errors.customer_id.message} )} {t('day_label')} {t('day_hint')} {t('send_hour_label')} ( field.onChange(Number(v))} > {Array.from({ length: 24 }, (_, h) => h).map((h) => ( {`${String(h).padStart(2, '0')}:00`} ))} )} /> {t('send_hour_hint')} {t('payment_terms_label')} {t('currency_label')} ( {currencies.map((c) => ( {c} ))} )} /> ( field.onChange(e.target.checked)} disabled={autoSendBlocked} className="mt-1 h-4 w-4" /> )} /> {t('auto_send_label')} {t('auto_send_description')} {customerMissingEmail && ( {t('auto_send_missing_email')} )} {!hasEmailSend && ( {t('auto_send_requires_subscription')} )} {t('items_card_title')} {fields.map((field, index) => ( ( {units.map((u) => ( {u} ))} )} /> fields.length > 1 && remove(index)} aria-label={t('remove_row')} > ))} append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }) } > {t('add_row')} {t('subtotal_ex_vat', { amount: formatCurrency(subtotal, watchCurrency) })} {t('other_card_title')} {t('your_reference_label')} {t('our_reference_label')} {t('notes_label')} {t('cancel')} {schedule ? isSubmitting ? t('saving') : t('save_changes') : isSubmitting ? t('creating') : t('create_schedule')} ) }
{errors.name.message}
{errors.customer_id.message}
{t('day_hint')}
{t('send_hour_hint')}
{t('auto_send_description')}
{t('auto_send_missing_email')}