refactor(ui): form primitive polish + invoice page grid alignment (#475)
* refactor(ui): form primitive polish + invoice page grid alignment
Three things that were dropped from the previous design-refresh squash
and one new fix on invoices/[id].
Form primitives — tighter focus rings, match Button transition cadence:
- Input/Textarea/Select trigger: transition-colors duration-200 → 150
- Focus ring: border-primary/{50,60} + ring-primary/{20,35} →
border-primary + ring-primary/20 (full-opacity border with a
faint glow ring; cleaner than the opacity-stacked version)
- SelectContent: border-border/60 → border-border, shadow-md →
halved --shadow-md token
- SelectSeparator: bg-border/50 → bg-border
invoices/new — single grid with explicit row placement so the
second-row cards (Anteckningar / Summering) align across columns
regardless of how tall the first-row cards end up:
- Flatten the two column wrappers into one grid
- Add lg:auto-rows-min lg:items-start
- Each card pinned with lg:col-{start,span} + lg:row-start
- Fakturarader now spans col 1-3 (full width) on lg+
invoices/[id] — same alignment fix on the detail view:
- Flatten the column wrappers into one grid
- Kund (col 1-2) + Fakturarader (col 1-2) + Anteckningar (col 1-2,
conditional) pinned to rows 1-3
- All right-side cards (Detaljer + conditional status cards) stay
grouped in a single wrapper at lg:col-start-3 lg:row-span-3, so
they stack naturally without disrupting the left-column rows
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(ui): address PR review
- invoices/[id]: add lg:auto-rows-min to match invoices/new and prevent
unintended row expansion from any future auto-placed items
- Clean up stale indentation in both invoice pages — Card children
were 2 spaces too deep after the wrapper divs were removed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c8461397c8
commit
dd28a379c9
@@ -468,178 +468,175 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3 lg:auto-rows-min lg:items-start">
|
||||||
{/* Main content */}
|
{/* Customer info */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Card className="lg:col-span-2 lg:row-start-1">
|
||||||
{/* Customer info */}
|
<CardHeader>
|
||||||
<Card>
|
<CardTitle>Kund</CardTitle>
|
||||||
<CardHeader>
|
</CardHeader>
|
||||||
<CardTitle>Kund</CardTitle>
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="space-y-2">
|
||||||
<CardContent>
|
<p className="font-medium text-lg">{customer.name}</p>
|
||||||
<div className="space-y-2">
|
{customer.org_number && (
|
||||||
<p className="font-medium text-lg">{customer.name}</p>
|
<p className="text-muted-foreground">Org.nr: {customer.org_number}</p>
|
||||||
{customer.org_number && (
|
)}
|
||||||
<p className="text-muted-foreground">Org.nr: {customer.org_number}</p>
|
{customer.vat_number && (
|
||||||
|
<p className="text-muted-foreground">VAT: {customer.vat_number}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground">
|
||||||
|
{customer.email && (
|
||||||
|
<span>{customer.email}</span>
|
||||||
)}
|
)}
|
||||||
{customer.vat_number && (
|
{customer.phone && (
|
||||||
<p className="text-muted-foreground">VAT: {customer.vat_number}</p>
|
<span>{customer.phone}</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground">
|
</div>
|
||||||
{customer.email && (
|
{(customer.address_line1 || customer.city) && (
|
||||||
<span>{customer.email}</span>
|
<div className="text-sm text-muted-foreground pt-1">
|
||||||
)}
|
{customer.address_line1 && <p>{customer.address_line1}</p>}
|
||||||
{customer.phone && (
|
{customer.address_line2 && <p>{customer.address_line2}</p>}
|
||||||
<span>{customer.phone}</span>
|
<p>
|
||||||
)}
|
{customer.postal_code} {customer.city}
|
||||||
|
{customer.country !== 'SE' && `, ${customer.country}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{(customer.address_line1 || customer.city) && (
|
)}
|
||||||
<div className="text-sm text-muted-foreground pt-1">
|
</div>
|
||||||
{customer.address_line1 && <p>{customer.address_line1}</p>}
|
</CardContent>
|
||||||
{customer.address_line2 && <p>{customer.address_line2}</p>}
|
</Card>
|
||||||
<p>
|
|
||||||
{customer.postal_code} {customer.city}
|
{/* Invoice items */}
|
||||||
{customer.country !== 'SE' && `, ${customer.country}`}
|
<Card className="lg:col-span-2 lg:row-start-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Fakturarader</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header — desktop */}
|
||||||
|
<div className="hidden sm:grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
|
||||||
|
<div className="col-span-5">Beskrivning</div>
|
||||||
|
<div className="col-span-2 text-right">Antal</div>
|
||||||
|
<div className="col-span-1 text-center">Enhet</div>
|
||||||
|
<div className="col-span-2 text-right">à-pris</div>
|
||||||
|
<div className="col-span-2 text-right">Summa</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items — desktop */}
|
||||||
|
<div className="hidden sm:block space-y-4">
|
||||||
|
{invoice.items.map((item) => (
|
||||||
|
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
|
||||||
|
<div className="col-span-5">{item.description}</div>
|
||||||
|
<div className="col-span-2 text-right">{item.quantity}</div>
|
||||||
|
<div className="col-span-1 text-center">{item.unit}</div>
|
||||||
|
<div className="col-span-2 text-right">
|
||||||
|
{formatCurrency(item.unit_price, invoice.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-right font-medium">
|
||||||
|
{formatCurrency(item.line_total, invoice.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items — mobile cards */}
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{invoice.items.map((item) => (
|
||||||
|
<div key={item.id} className="border rounded-lg p-3 text-sm space-y-1.5">
|
||||||
|
<p className="font-medium">{item.description}</p>
|
||||||
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
|
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-right font-medium">
|
||||||
|
{formatCurrency(item.line_total, invoice.currency)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Invoice items */}
|
<Separator />
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
{/* Totals */}
|
||||||
<CardTitle>Fakturarader</CardTitle>
|
<div className="space-y-2">
|
||||||
</CardHeader>
|
<div className="flex justify-between">
|
||||||
<CardContent>
|
<span className="text-muted-foreground">Delsumma</span>
|
||||||
<div className="space-y-4">
|
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
||||||
{/* Header — desktop */}
|
|
||||||
<div className="hidden sm:grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
|
|
||||||
<div className="col-span-5">Beskrivning</div>
|
|
||||||
<div className="col-span-2 text-right">Antal</div>
|
|
||||||
<div className="col-span-1 text-center">Enhet</div>
|
|
||||||
<div className="col-span-2 text-right">à-pris</div>
|
|
||||||
<div className="col-span-2 text-right">Summa</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const vatByRate = new Map<number, number>()
|
||||||
|
for (const item of invoice.items) {
|
||||||
|
const rate = item.vat_rate ?? 0
|
||||||
|
const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100
|
||||||
|
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
||||||
|
}
|
||||||
|
const entries = Array.from(vatByRate.entries())
|
||||||
|
.filter(([, vat]) => vat > 0)
|
||||||
|
.sort(([a], [b]) => b - a)
|
||||||
|
|
||||||
{/* Items — desktop */}
|
if (entries.length === 0) {
|
||||||
<div className="hidden sm:block space-y-4">
|
|
||||||
{invoice.items.map((item) => (
|
|
||||||
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
|
|
||||||
<div className="col-span-5">{item.description}</div>
|
|
||||||
<div className="col-span-2 text-right">{item.quantity}</div>
|
|
||||||
<div className="col-span-1 text-center">{item.unit}</div>
|
|
||||||
<div className="col-span-2 text-right">
|
|
||||||
{formatCurrency(item.unit_price, invoice.currency)}
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 text-right font-medium">
|
|
||||||
{formatCurrency(item.line_total, invoice.currency)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Items — mobile cards */}
|
|
||||||
<div className="sm:hidden space-y-2">
|
|
||||||
{invoice.items.map((item) => (
|
|
||||||
<div key={item.id} className="border rounded-lg p-3 text-sm space-y-1.5">
|
|
||||||
<p className="font-medium">{item.description}</p>
|
|
||||||
<div className="flex items-center justify-between text-muted-foreground">
|
|
||||||
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-right font-medium">
|
|
||||||
{formatCurrency(item.line_total, invoice.currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Delsumma</span>
|
|
||||||
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const vatByRate = new Map<number, number>()
|
|
||||||
for (const item of invoice.items) {
|
|
||||||
const rate = item.vat_rate ?? 0
|
|
||||||
const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100
|
|
||||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
|
||||||
}
|
|
||||||
const entries = Array.from(vatByRate.entries())
|
|
||||||
.filter(([, vat]) => vat > 0)
|
|
||||||
.sort(([a], [b]) => b - a)
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Moms</span>
|
|
||||||
<span>{formatCurrency(0, invoice.currency)}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries.map(([rate, vat]) => (
|
|
||||||
<div key={rate} className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Moms {rate}%</span>
|
|
||||||
<span>{formatCurrency(vat, invoice.currency)}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
})()}
|
|
||||||
<Separator />
|
|
||||||
{(() => {
|
|
||||||
const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding })
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex justify-between">
|
||||||
{rounding.applies && (
|
<span className="text-muted-foreground">Moms</span>
|
||||||
<div className="flex justify-between text-sm text-muted-foreground">
|
<span>{formatCurrency(0, invoice.currency)}</span>
|
||||||
<span>Öresavrundning</span>
|
</div>
|
||||||
<span>{formatCurrency(rounding.roundingDelta, 'SEK')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between font-bold text-lg">
|
|
||||||
<span>Totalt</span>
|
|
||||||
<span>{formatCurrency(rounding.displayed, invoice.currency)}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
})()}
|
}
|
||||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
|
||||||
<div className="flex justify-between text-sm text-muted-foreground">
|
|
||||||
<span>I SEK (kurs {invoice.exchange_rate})</span>
|
|
||||||
<span>{formatCurrency(invoice.total_sek)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Notes */}
|
return entries.map(([rate, vat]) => (
|
||||||
{(invoice.notes || invoice.reverse_charge_text) && (
|
<div key={rate} className="flex justify-between">
|
||||||
<Card>
|
<span className="text-muted-foreground">Moms {rate}%</span>
|
||||||
<CardHeader>
|
<span>{formatCurrency(vat, invoice.currency)}</span>
|
||||||
<CardTitle>Anteckningar</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
))
|
||||||
<CardContent className="space-y-4">
|
})()}
|
||||||
{invoice.reverse_charge_text && (
|
<Separator />
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
{(() => {
|
||||||
<p className="text-sm font-medium">Omvänd skattskyldighet</p>
|
const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding })
|
||||||
<p className="text-sm text-muted-foreground">{invoice.reverse_charge_text}</p>
|
return (
|
||||||
|
<>
|
||||||
|
{rounding.applies && (
|
||||||
|
<div className="flex justify-between text-sm text-muted-foreground">
|
||||||
|
<span>Öresavrundning</span>
|
||||||
|
<span>{formatCurrency(rounding.roundingDelta, 'SEK')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between font-bold text-lg">
|
||||||
|
<span>Totalt</span>
|
||||||
|
<span>{formatCurrency(rounding.displayed, invoice.currency)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||||
|
<div className="flex justify-between text-sm text-muted-foreground">
|
||||||
|
<span>I SEK (kurs {invoice.exchange_rate})</span>
|
||||||
|
<span>{formatCurrency(invoice.total_sek)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{invoice.notes && <p className="text-sm">{invoice.notes}</p>}
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{(invoice.notes || invoice.reverse_charge_text) && (
|
||||||
|
<Card className="lg:col-span-2 lg:row-start-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Anteckningar</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{invoice.reverse_charge_text && (
|
||||||
|
<div className="p-3 bg-muted rounded-lg">
|
||||||
|
<p className="text-sm font-medium">Omvänd skattskyldighet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{invoice.reverse_charge_text}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.notes && <p className="text-sm">{invoice.notes}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="lg:col-start-3 lg:row-start-1 lg:row-span-3 space-y-6">
|
||||||
{/* Invoice details */}
|
{/* Invoice details */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -473,157 +473,117 @@ export default function NewInvoicePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pb-28 md:pb-0">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pb-28 md:pb-0">
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3 lg:auto-rows-min lg:items-start">
|
||||||
{/* Main content */}
|
{/* Customer selection */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Card className="lg:col-span-2 lg:row-start-1">
|
||||||
{/* Customer selection */}
|
<CardHeader>
|
||||||
<Card>
|
<CardTitle>Kund<RequiredMark /></CardTitle>
|
||||||
<CardHeader>
|
<CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription>
|
||||||
<CardTitle>Kund<RequiredMark /></CardTitle>
|
</CardHeader>
|
||||||
<CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription>
|
<CardContent>
|
||||||
</CardHeader>
|
<Controller
|
||||||
<CardContent>
|
name="customer_id"
|
||||||
<Controller
|
control={control}
|
||||||
name="customer_id"
|
render={({ field }) => (
|
||||||
control={control}
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
render={({ field }) => (
|
<SelectTrigger>
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<SelectValue placeholder="Välj kund" />
|
||||||
<SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="Välj kund" />
|
<SelectContent>
|
||||||
</SelectTrigger>
|
{customers.map((customer) => (
|
||||||
<SelectContent>
|
<SelectItem key={customer.id} value={customer.id}>
|
||||||
{customers.map((customer) => (
|
{customer.name}
|
||||||
<SelectItem key={customer.id} value={customer.id}>
|
</SelectItem>
|
||||||
{customer.name}
|
))}
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
))}
|
</Select>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => setIsCreateCustomerOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Skapa kund
|
|
||||||
</Button>
|
|
||||||
{errors.customer_id && (
|
|
||||||
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() => setIsCreateCustomerOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Skapa kund
|
||||||
|
</Button>
|
||||||
|
{errors.customer_id && (
|
||||||
|
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Invoice items */}
|
{/* Invoice items */}
|
||||||
<Card>
|
<Card className="lg:col-span-3 lg:row-start-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Fakturarader</CardTitle>
|
<CardTitle>Fakturarader</CardTitle>
|
||||||
<CardDescription>Lägg till produkter eller tjänster</CardDescription>
|
<CardDescription>Lägg till produkter eller tjänster</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{fields.map((field, index) => {
|
{fields.map((field, index) => {
|
||||||
const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0)
|
const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0)
|
||||||
const lineVat = Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100
|
const lineVat = Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{/* Description + mobile delete button */}
|
{/* Description + mobile delete button */}
|
||||||
<div className="flex items-start gap-2 md:contents">
|
<div className="flex items-start gap-2 md:contents">
|
||||||
<div className="flex-1 space-y-1 md:col-span-3 md:space-y-2">
|
<div className="flex-1 space-y-1 md:col-span-3 md:space-y-2">
|
||||||
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Beskrivning</Label>
|
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Beskrivning</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="T.ex. Instagram-kampanj"
|
placeholder="T.ex. Instagram-kampanj"
|
||||||
{...register(`items.${index}.description`)}
|
{...register(`items.${index}.description`)}
|
||||||
/>
|
/>
|
||||||
{errors.items?.[index]?.description && (
|
{errors.items?.[index]?.description && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
{errors.items[index].description?.message}
|
{errors.items[index].description?.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden"
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
disabled={fields.length === 1}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
disabled={fields.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Antal, Enhet, à-pris */}
|
{/* Antal, Enhet, à-pris */}
|
||||||
<div className="grid grid-cols-3 gap-2 md:contents">
|
<div className="grid grid-cols-3 gap-2 md:contents">
|
||||||
<div className="space-y-1 md:col-span-2 md:space-y-2">
|
|
||||||
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Antal</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
inputMode="decimal"
|
|
||||||
className="text-right tabular-nums"
|
|
||||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 md:col-span-2 md:space-y-2">
|
|
||||||
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Enhet</Label>
|
|
||||||
<Controller
|
|
||||||
name={`items.${index}.unit`}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{units.map((unit) => (
|
|
||||||
<SelectItem key={unit} value={unit}>
|
|
||||||
{unit}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 md:col-span-2 md:space-y-2">
|
|
||||||
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">à-pris</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
inputMode="decimal"
|
|
||||||
className="text-right tabular-nums"
|
|
||||||
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Moms */}
|
|
||||||
<div className="space-y-1 md:col-span-2 md:space-y-2">
|
<div className="space-y-1 md:col-span-2 md:space-y-2">
|
||||||
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Moms</Label>
|
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Antal</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 md:col-span-2 md:space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Enhet</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name={`items.${index}.vat_rate`}
|
name={`items.${index}.unit`}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
value={String(field.value ?? 25)}
|
|
||||||
onValueChange={(v) => field.onChange(Number(v))}
|
|
||||||
disabled={isRateLocked}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableRates.map((opt) => (
|
{units.map((unit) => (
|
||||||
<SelectItem key={opt.rate} value={String(opt.rate)}>
|
<SelectItem key={unit} value={unit}>
|
||||||
{opt.label}
|
{unit}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -631,214 +591,248 @@ export default function NewInvoicePage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1 md:col-span-2 md:space-y-2">
|
||||||
{/* Desktop delete button */}
|
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">à-pris</Label>
|
||||||
<div className="hidden md:flex md:col-span-1 md:items-end">
|
<Input
|
||||||
<Button
|
type="number"
|
||||||
type="button"
|
step="any"
|
||||||
variant="ghost"
|
inputMode="decimal"
|
||||||
size="icon"
|
className="text-right tabular-nums"
|
||||||
onClick={() => remove(index)}
|
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
||||||
disabled={fields.length === 1}
|
/>
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile summary row */}
|
|
||||||
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
|
|
||||||
<span className="text-muted-foreground">Rad {index + 1}</span>
|
|
||||||
<span className="font-medium tabular-nums">{formatCurrency(lineTotal + lineVat, watchCurrency)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Button
|
{/* Moms */}
|
||||||
type="button"
|
<div className="space-y-1 md:col-span-2 md:space-y-2">
|
||||||
variant="outline"
|
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Moms</Label>
|
||||||
className="w-full md:w-auto"
|
<Controller
|
||||||
onClick={() =>
|
name={`items.${index}.vat_rate`}
|
||||||
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 })
|
control={control}
|
||||||
}
|
render={({ field }) => (
|
||||||
>
|
<Select
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
value={String(field.value ?? 25)}
|
||||||
Lägg till rad
|
onValueChange={(v) => field.onChange(Number(v))}
|
||||||
</Button>
|
disabled={isRateLocked}
|
||||||
</div>
|
>
|
||||||
</CardContent>
|
<SelectTrigger>
|
||||||
</Card>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableRates.map((opt) => (
|
||||||
|
<SelectItem key={opt.rate} value={String(opt.rate)}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Desktop delete button */}
|
||||||
<Card>
|
<div className="hidden md:flex md:col-span-1 md:items-end">
|
||||||
<CardHeader>
|
<Button
|
||||||
<CardTitle>Anteckningar</CardTitle>
|
type="button"
|
||||||
<CardDescription>Valfritt meddelande på fakturan</CardDescription>
|
variant="ghost"
|
||||||
</CardHeader>
|
size="icon"
|
||||||
<CardContent>
|
onClick={() => remove(index)}
|
||||||
<Textarea
|
disabled={fields.length === 1}
|
||||||
placeholder="T.ex. betalningsvillkor eller tack för samarbetet..."
|
>
|
||||||
{...register('notes')}
|
<Trash2 className="h-4 w-4" />
|
||||||
/>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Mobile summary row */}
|
||||||
<div className="space-y-6">
|
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
|
||||||
{/* Invoice details */}
|
<span className="text-muted-foreground">Rad {index + 1}</span>
|
||||||
<Card>
|
<span className="font-medium tabular-nums">{formatCurrency(lineTotal + lineVat, watchCurrency)}</span>
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle>Fakturadetaljer</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Dokumenttyp</Label>
|
|
||||||
<Controller
|
|
||||||
name="document_type"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="invoice">Faktura</SelectItem>
|
|
||||||
<SelectItem value="proforma">Proformafaktura</SelectItem>
|
|
||||||
<SelectItem value="delivery_note">Följesedel</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Valuta</Label>
|
|
||||||
<Controller
|
|
||||||
name="currency"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{currencies.map((currency) => (
|
|
||||||
<SelectItem key={currency} value={currency}>
|
|
||||||
{currency}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Fakturadatum<RequiredMark /></Label>
|
|
||||||
<Input type="date" {...register('invoice_date')} aria-required="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Förfallodatum<RequiredMark /></Label>
|
|
||||||
<Input type="date" {...register('due_date')} aria-required="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{watchDocumentType === 'invoice' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Leveransdatum</Label>
|
|
||||||
<Input type="date" {...register('delivery_date')} placeholder="Om det skiljer sig från fakturadatum" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Er referens</Label>
|
|
||||||
<Controller
|
|
||||||
name="your_reference"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<TagInput
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="Kontaktperson hos kund"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Vår referens</Label>
|
|
||||||
<Controller
|
|
||||||
name="our_reference"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<TagInput
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="Ditt namn"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Summering</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Delsumma</span>
|
|
||||||
<span>{formatCurrency(subtotal, watchCurrency)}</span>
|
|
||||||
</div>
|
|
||||||
{Array.from(vatByRate.entries())
|
|
||||||
.sort(([a], [b]) => b - a)
|
|
||||||
.map(([rate, group]) => (
|
|
||||||
<div key={rate}>
|
|
||||||
{vatByRate.size > 1 && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Netto {rate}%</span>
|
|
||||||
<span>{formatCurrency(group.base, watchCurrency)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{group.vat > 0 && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Moms {rate}%</span>
|
|
||||||
<span>{formatCurrency(group.vat, watchCurrency)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
{vatByRate.size === 0 && (
|
})}
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Moms</span>
|
|
||||||
<span>{formatCurrency(0, watchCurrency)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between font-bold text-lg">
|
|
||||||
<span>Totalt</span>
|
|
||||||
<span>{formatCurrency(total, watchCurrency)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions — desktop/tablet only */}
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="submit"
|
variant="outline"
|
||||||
className="w-full hidden md:block"
|
className="w-full md:w-auto"
|
||||||
size="lg"
|
onClick={() =>
|
||||||
disabled={isSubmitting || !canWrite}
|
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 })
|
||||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
}
|
||||||
>
|
>
|
||||||
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Granska & skapa
|
Lägg till rad
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Card className="lg:col-span-2 lg:row-start-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Anteckningar</CardTitle>
|
||||||
|
<CardDescription>Valfritt meddelande på fakturan</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
placeholder="T.ex. betalningsvillkor eller tack för samarbetet..."
|
||||||
|
{...register('notes')}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invoice details */}
|
||||||
|
<Card className="lg:col-start-3 lg:row-start-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Fakturadetaljer</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Dokumenttyp</Label>
|
||||||
|
<Controller
|
||||||
|
name="document_type"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="invoice">Faktura</SelectItem>
|
||||||
|
<SelectItem value="proforma">Proformafaktura</SelectItem>
|
||||||
|
<SelectItem value="delivery_note">Följesedel</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Valuta</Label>
|
||||||
|
<Controller
|
||||||
|
name="currency"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{currencies.map((currency) => (
|
||||||
|
<SelectItem key={currency} value={currency}>
|
||||||
|
{currency}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Fakturadatum<RequiredMark /></Label>
|
||||||
|
<Input type="date" {...register('invoice_date')} aria-required="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Förfallodatum<RequiredMark /></Label>
|
||||||
|
<Input type="date" {...register('due_date')} aria-required="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{watchDocumentType === 'invoice' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Leveransdatum</Label>
|
||||||
|
<Input type="date" {...register('delivery_date')} placeholder="Om det skiljer sig från fakturadatum" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Er referens</Label>
|
||||||
|
<Controller
|
||||||
|
name="your_reference"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TagInput
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="Kontaktperson hos kund"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Vår referens</Label>
|
||||||
|
<Controller
|
||||||
|
name="our_reference"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TagInput
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="Ditt namn"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Card className="lg:col-start-3 lg:row-start-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Summering</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Delsumma</span>
|
||||||
|
<span>{formatCurrency(subtotal, watchCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
{Array.from(vatByRate.entries())
|
||||||
|
.sort(([a], [b]) => b - a)
|
||||||
|
.map(([rate, group]) => (
|
||||||
|
<div key={rate}>
|
||||||
|
{vatByRate.size > 1 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Netto {rate}%</span>
|
||||||
|
<span>{formatCurrency(group.base, watchCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.vat > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Moms {rate}%</span>
|
||||||
|
<span>{formatCurrency(group.vat, watchCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{vatByRate.size === 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Moms</span>
|
||||||
|
<span>{formatCurrency(0, watchCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between font-bold text-lg">
|
||||||
|
<span>Totalt</span>
|
||||||
|
<span>{formatCurrency(total, watchCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions — desktop/tablet only */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full hidden md:block lg:col-start-3 lg:row-start-4"
|
||||||
|
size="lg"
|
||||||
|
disabled={isSubmitting || !canWrite}
|
||||||
|
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||||
|
>
|
||||||
|
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
|
||||||
|
Granska & skapa
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile sticky total bar */}
|
{/* Mobile sticky total bar */}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-1 focus-visible:ring-primary/35 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-200 placeholder:text-muted-foreground/60 focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/35 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-150 placeholder:text-muted-foreground/60 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -74,7 +74,7 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border/60 bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-[var(--shadow-md)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
@@ -138,7 +138,7 @@ const SelectSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[100px] w-full rounded-lg border border-input bg-card px-4 py-3 text-sm transition-colors duration-200 placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary/50 focus-visible:ring-1 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
|
"flex min-h-[100px] w-full rounded-lg border border-input bg-card px-4 py-3 text-sm transition-colors duration-150 placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
Reference in New Issue
Block a user