Files
accounted/components/deadlines/DeadlineForm.tsx
T
MattssonandClaude Opus 4.6 0dd1f5ebc1 feat: multi-tenant company refactor (GNU-19) (#153)
* feat: multi-tenant company refactor (GNU-19)

Introduce companies table, company_members, and user_preferences to
support multiple companies per user. All data scoping changes from
user_id to company_id across the entire codebase.

Key changes:
- Database migration: new tables, company_id on 40+ tables, backfill,
  RLS rewrite from user_id to company-member-based, updated RPCs
- Types: Company, CompanyMember, CompanyRole, UserPreferences types;
  company_id added to all entity interfaces; companyId on all events
- Engine: all 7 core functions take companyId; storno, period, year-end
  services updated; 16 report generators updated
- Middleware: company context resolution (cookie → prefs → first company)
- API routes: ~120 routes updated with requireCompanyId()
- Frontend: CompanyProvider context, layout/dashboard/onboarding updated
- Extensions: context factory, 9 extensions, all lib files updated
- Tests: 1880 tests passing, all helpers updated with company_id defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add database migrations for multi-tenant company and team system (GNU-19)

Adds company_invitations, company creation RPC, team_members, account
deletion RPC, and teams table refactor migrations. Updates base
multi-tenant migration with cascading FKs and onboarding_step column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team types and update core infrastructure for multi-tenancy (GNU-19)

Adds TeamRole, MemberSource, and Team types. Refactors Supabase service
client to be stateless, updates middleware for team-aware routing, extends
CompanyContext with team/role fields, and updates extension service types
to accept companyId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through business logic functions (GNU-19)

Replaces user_id scoping with company_id across all lib modules:
bookkeeping, documents, transactions, invoices, reconciliation, tax,
deadlines, and import. Updates corresponding tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through API routes and extensions (GNU-19)

Updates all existing API routes to extract and pass companyId. Updates
enable-banking and arcim-migration extensions for company-scoped
transaction ingestion and sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add company and team management API routes (GNU-19)

Adds CRUD endpoints for company members, company invitations, team
members, and team invitations. Includes invite token utilities, email
templates, and company switch server action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team/company UI components, pages, and dashboard updates (GNU-19)

Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company
members and team management panels. Updates dashboard layout for
team-aware routing, onboarding for multi-step role choice, and auth
callback for team invite acceptance. Ignores supabase/.branches/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in import page (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move appUrl declaration to outer scope in invite route (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for second company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in extension components (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update tests to use companyId instead of userId and improve type handling

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:41:52 +02:00

313 lines
9.9 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'
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 [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}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Skapa'}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}