Files
accounted/components/deadlines/DeadlineForm.tsx
T
Mattsson a1a816b4a5 Delete features (#218)
* Implement company and account deletion features

- Add event types for company and account deletion to CoreEvent.
- Enhance Supabase middleware to handle company context resolution and cookie management for archived companies.
- Create API routes for deleting accounts and companies, including necessary validations and event emissions.
- Implement tests for account and company deletion endpoints to ensure proper functionality and error handling.
- Add retention notice component to inform users about bookkeeping data retention during destructive actions.
- Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws.

* feat: enhance account deletion process and update user notifications

* Add service client for onboarding completion check and update escape hatch visibility

* Enhance invite flow and email handling for company members

* Refactor company context and RLS policies for active company isolation

- Update `switchCompany` to remove unnecessary revalidation as client handles navigation.
- Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships.
- Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility.
- Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership.
- Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization.
- Implement `CompanyTabSync` component for real-time active company enforcement across tabs.
- Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`.

* feat: implement viewer role enforcement for write permissions

- Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company.
- Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions.
- Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers.
- Created tests to verify the behavior of the viewer role and write permissions.
- Added database migration to enforce read-only access for viewers at the database level.
2026-04-11 17:06:32 +02:00

321 lines
10 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
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 { Deadline, DeadlineType, DeadlinePriority } from '@/types'
import { formatDateISO, DEADLINE_TYPE_LABELS, PRIORITY_LABELS } from '@/lib/calendar/utils'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { Lock } from 'lucide-react'
interface DeadlineFormProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => Promise<void>
onDelete?: (deadline: Partial<Deadline>) => void
initialData?: Partial<Deadline>
initialDate?: Date | null
customers: { id: string; name: string }[]
}
export function DeadlineForm({
open,
onOpenChange,
onSubmit,
onDelete,
initialData,
initialDate,
customers,
}: DeadlineFormProps) {
const { canWrite } = useCanWrite()
const [confirmDelete, setConfirmDelete] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState({
title: '',
due_date: '',
due_time: '',
deadline_type: 'other' as DeadlineType,
priority: 'normal' as DeadlinePriority,
customer_id: '',
notes: '',
})
// Reset form when dialog opens with new data
useEffect(() => {
if (open) {
setConfirmDelete(false)
if (initialData) {
setFormData({
title: initialData.title || '',
due_date: initialData.due_date || formatDateISO(new Date()),
due_time: initialData.due_time || '',
deadline_type: initialData.deadline_type || 'other',
priority: initialData.priority || 'normal',
customer_id: initialData.customer_id || '',
notes: initialData.notes || '',
})
} else if (initialDate) {
setFormData({
title: '',
due_date: formatDateISO(initialDate),
due_time: '',
deadline_type: 'other',
priority: 'normal',
customer_id: '',
notes: '',
})
} else {
setFormData({
title: '',
due_date: formatDateISO(new Date()),
due_time: '',
deadline_type: 'other',
priority: 'normal',
customer_id: '',
notes: '',
})
}
}
}, [open, initialData, initialDate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await onSubmit({
title: formData.title,
due_date: formData.due_date,
due_time: formData.due_time || null,
deadline_type: formData.deadline_type,
priority: formData.priority,
customer_id: formData.customer_id || null,
notes: formData.notes || null,
is_completed: initialData?.is_completed || false,
completed_at: initialData?.completed_at || null,
is_auto_generated: false,
// New tax deadline fields with defaults for user-created deadlines
tax_deadline_type: null,
tax_period: null,
source: 'user',
reminder_offsets: [14, 7, 1, 0],
status: 'upcoming',
status_changed_at: new Date().toISOString(),
linked_report_type: null,
linked_report_period: null,
})
} finally {
setIsLoading(false)
}
}
const updateField = <K extends keyof typeof formData>(
field: K,
value: (typeof formData)[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera deadline' : 'Ny deadline'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Titel *</Label>
<Input
id="title"
placeholder="t.ex. Leverera video till Företag AB"
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
required
/>
</div>
{/* Date and Time */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="due_date">Datum *</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => updateField('due_date', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="due_time">Tid (valfritt)</Label>
<Input
id="due_time"
type="time"
value={formData.due_time}
onChange={(e) => updateField('due_time', e.target.value)}
/>
</div>
</div>
{/* Type and Priority */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<div className="space-y-2">
<Label>Typ *</Label>
<Select
value={formData.deadline_type}
onValueChange={(v) => { if (v) updateField('deadline_type', v as DeadlineType) }}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DEADLINE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Prioritet</Label>
<Select
value={formData.priority}
onValueChange={(v) => { if (v) updateField('priority', v as DeadlinePriority) }}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Customer */}
{customers.length > 0 && (
<div className="space-y-2">
<Label>Kund (valfritt)</Label>
<Select
value={formData.customer_id || 'none'}
onValueChange={(v) => { if (v) updateField('customer_id', v === 'none' ? '' : v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj kund..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen kund</SelectItem>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar (valfritt)</Label>
<Textarea
id="notes"
placeholder="Lägg till anteckningar..."
value={formData.notes}
onChange={(e) => updateField('notes', e.target.value)}
rows={3}
/>
</div>
<DialogFooter className="flex-row justify-between sm:justify-between gap-2">
{/* Delete (only when editing an existing deadline) */}
{initialData?.id && onDelete ? (
<div className="flex items-center gap-2">
{confirmDelete ? (
<>
<span className="text-sm text-muted-foreground mr-1">Ta bort?</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConfirmDelete(false)}
>
Avbryt
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
onDelete(initialData)
onOpenChange(false)
}}
>
Ta bort
</Button>
</>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setConfirmDelete(true)}
>
Ta bort
</Button>
)}
</div>
) : (
<div />
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Avbryt
</Button>
<Button
type="submit"
disabled={isLoading || !formData.title || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Skapa'}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}