feat(invoices,year-end): four byrå-feedback fixes (validation feedback, moms gate, klarmarkera, article search) (#1641)
* fix(invoices): surface validation errors instead of a silent dead submit button A missing unit (or any other Zod failure) blocked both Granska & skapa and Spara som utkast with zero feedback: handleSubmit had no onInvalid callback, the buttons stayed enabled, and the unit field rendered no inline error. Reported by a byra user whose client could not save any invoice. - onInvalid handler on all three submit paths: destructive toast plus scroll to the first inline error - inline error text under the unit select and quantity input (the only line fields that had none) - same treatment in NewRecurringScheduleDialog, including inline errors on its item rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): stop defaulting 25 % moms for icke momsregistrerade companies The registration form hard-coded vat_rate 0.25 on the initial line, added rows, AI prefill fallback and konto defaults, regardless of company_settings.vat_registered. A non-VAT-registered business that missed the prefilled rate booked ingaende moms (2641) it has no right to deduct (ML 8 kap. 3 \u00a7). The customer-invoice side already gates on the same flag; the supplier side ignored it. - form: read vat_registered from /api/settings; when false, all moms controls (rate cells, per-line moms, totals rows) are hidden and every line is forced to 0 %, including late AI prefills - reverse charge keeps its rate controls: self-assessment is a separate obligation from deduction - route: 400 SI_CREATE_INVALID_INPUT when a non-registered company posts a line with vat_rate/vat_amount > 0 (API/MCP defense in depth), and an omitted vat_rate now defaults to 0 instead of 25 % for those companies - tests: guard rejection, reverse-charge pass-through, 0-default; existing POST tests updated for the new settings lookup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(year-end): klarmarkera imported years already closed in a previous system SIE-imported historical fiscal years land with is_closed = false and no closing entry, so the year-end page lists every migrated year as pending bokslut even though the bokslut was done in the old software. There was no sanctioned way to mark them done: closePeriod hard-requires locked_at and closing_entry_id. - migration: fiscal_periods.closed_externally boolean (audit clarity: distinguishes a year-end run here from a close done elsewhere) - markPeriodClosedExternally(): closes + locks without a closing entry; refuses already-closed periods, periods with their own closing entry, periods that have not ended, and periods with unbooked bank transactions (same stranding guard as lockPeriod); writes the immutable audit_log entry - POST /api/bookkeeping/fiscal-periods/[id]/close-external (requireWrite) - year-end page: one attn line on the preflight step with a confirm dialog describing the outcome; the marked year drops out of the eligible list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): searchable article picker on invoice lines The article field was a plain Radix Select whose only matching is label-prefix typeahead: for numbered articles that means number-only lookup, and typing "skruv" found nothing. Byra feedback: name search would help a lot for users with real article catalogs. New ArticleCombobox (input-trigger dropdown, same pattern as AccountCombobox): free-text search over name + article number, diacritics-folded via foldText, keyboard navigation, pinned "Egen rad" free-text option, browse-all on focus like the Select it replaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log klarmarkera pg-test decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address skeptic and compliance-review findings on PR #1641 - ArticleCombobox: keyboard focus no longer auto-opens the list, opening highlights the committed selection, typing highlights the first match, and re-selecting the current value is a no-op. Previously Tab+Enter silently detached the article and wiped its revenue-account override. - Supplier invoice prefill for icke momsregistrerade: the zeroing effect now grosses the net amount up by the extracted rate before forcing 0 %, so the booked cost and 2440 keep the full att-betala amount instead of understating both by the moms. - markPeriodClosedExternally: only migrated periods qualify (must contain SIE-imported verifikat or no verifikat at all); the update carries an is_closed=false predicate so a concurrent normal close cannot be overwritten; confirm dialog now names the reporting consequences. - Route comment: honest scope (this route only; v1/inbox/MCP sweep is a follow-up) and current-law citation (13 kap. ML 2023:200). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: use roundOre for the icke-momsregistrerad gross-up (ratchet guard) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
caa0c3b41d
commit
dfb34a01d9
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { foldText } from '@/lib/bookkeeping/account-search'
|
||||
|
||||
export interface ArticleComboboxItem {
|
||||
id: string
|
||||
article_number: string | null
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ArticleComboboxProps {
|
||||
/** Selected article id, or null for a free-text line. */
|
||||
value: string | null
|
||||
/** Already sorted by the caller (sortArticles). */
|
||||
articles: ArticleComboboxItem[]
|
||||
/** Receives the picked article id, or 'none' for the free-text option. */
|
||||
onChange: (value: string) => void
|
||||
/** Label for the pinned free-text option ("Egen rad"). */
|
||||
freeTextLabel: string
|
||||
/** Trigger placeholder when nothing is selected. */
|
||||
placeholder: string
|
||||
/** Empty-state text when the search matches nothing. */
|
||||
emptyLabel: string
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
function articleLabel(a: ArticleComboboxItem): string {
|
||||
return a.article_number ? `${a.article_number}: ${a.name}` : a.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable article picker for invoice lines. Replaces the plain Select whose
|
||||
* only matching was Radix's label-prefix typeahead: for numbered articles that
|
||||
* meant number-only lookup, and typing "skruv" found nothing. Free-text search
|
||||
* here matches both name and article number, diacritics-folded (foldText), on
|
||||
* the already-loaded article list. Same input-trigger dropdown pattern as
|
||||
* AccountCombobox.
|
||||
*/
|
||||
export default function ArticleCombobox({
|
||||
value,
|
||||
articles,
|
||||
onChange,
|
||||
freeTextLabel,
|
||||
placeholder,
|
||||
emptyLabel,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
}: ArticleComboboxProps) {
|
||||
const selected = value ? articles.find((a) => a.id === value) ?? null : null
|
||||
// Free-text lines display the "Egen rad" label, matching the Select this
|
||||
// replaces (which pinned value 'none' and never showed a placeholder).
|
||||
const selectedLabel = selected ? articleLabel(selected) : freeTextLabel
|
||||
|
||||
const [search, setSearch] = useState(selectedLabel)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
// Typing narrows the list; a fresh focus shows everything so the field also
|
||||
// works as a browse dropdown, exactly like the Select it replaces.
|
||||
const [hasTyped, setHasTyped] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// True while a pointer interaction is what is about to focus the field.
|
||||
// Keyboard focus (Tab) must NOT auto-open: with the list open a bare Enter
|
||||
// would select the highlighted row, and the old Select treated Tab+Enter as
|
||||
// a no-op. Pointer focus keeps the click-to-browse behavior.
|
||||
const pointerDownRef = useRef(false)
|
||||
|
||||
// Sync external value changes (applyArticle, draft restore) into the field.
|
||||
useEffect(() => {
|
||||
setSearch(selectedLabel)
|
||||
// selectedLabel is derived from value + articles; both belong here.
|
||||
}, [selectedLabel])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = foldText(search.trim())
|
||||
// A committed selection sits in the field as its full label; treating it
|
||||
// as a filter would show exactly one row. Browse instead.
|
||||
if (!q || !hasTyped) return articles
|
||||
return articles.filter((a) =>
|
||||
foldText(`${a.article_number ?? ''} ${a.name}`).includes(q),
|
||||
)
|
||||
}, [articles, search, hasTyped])
|
||||
|
||||
// Options list: the free-text "Egen rad" choice stays pinned on top.
|
||||
type Option = { key: string; label: string; muted: boolean }
|
||||
const options = useMemo<Option[]>(
|
||||
() => [
|
||||
{ key: 'none', label: freeTextLabel, muted: true },
|
||||
...filtered.map((a) => ({ key: a.id, label: articleLabel(a), muted: false })),
|
||||
],
|
||||
[filtered, freeTextLabel],
|
||||
)
|
||||
|
||||
// While typing, highlight the first actual match (index 1: index 0 is the
|
||||
// pinned "Egen rad"), so type-and-Enter picks the searched article instead
|
||||
// of silently detaching the line to free text.
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(hasTyped && filtered.length > 0 ? 1 : 0)
|
||||
}, [options.length, search, hasTyped, filtered.length])
|
||||
|
||||
// Opening on a committed selection starts the highlight ON that selection,
|
||||
// like the Select this replaces, so Enter re-confirms instead of switching.
|
||||
const openList = useCallback(() => {
|
||||
const currentKey = value ?? 'none'
|
||||
const idx = options.findIndex((o) => o.key === currentKey)
|
||||
setHighlightedIndex(idx >= 0 ? idx : 0)
|
||||
setIsOpen(true)
|
||||
}, [options, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !listRef.current) return
|
||||
listRef.current
|
||||
.querySelector('[data-highlighted="true"]')
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
}, [highlightedIndex, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent | TouchEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('touchstart', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('touchstart', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const select = useCallback(
|
||||
(option: Option) => {
|
||||
// Re-selecting the committed value is a no-op close: applyArticle
|
||||
// re-applies the article's description/price/unit, which would clobber
|
||||
// per-line edits, and re-selecting "Egen rad" on a free-text line would
|
||||
// needlessly null the article link. The old Radix Select behaved the
|
||||
// same (onValueChange only fires on an actual change).
|
||||
if (option.key !== (value ?? 'none')) {
|
||||
onChange(option.key)
|
||||
}
|
||||
setSearch(option.label)
|
||||
setHasTyped(false)
|
||||
setIsOpen(false)
|
||||
},
|
||||
[onChange, value],
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openList()
|
||||
}
|
||||
return
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => Math.min(prev + 1, options.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (options[highlightedIndex]) select(options[highlightedIndex])
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsOpen(false)
|
||||
// Delay so a dropdown mousedown wins, then snap the field back to the
|
||||
// committed selection: a half-typed search must not linger as a label.
|
||||
setTimeout(() => {
|
||||
setSearch(selectedLabel)
|
||||
setHasTyped(false)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setHasTyped(true)
|
||||
if (!isOpen) setIsOpen(true)
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
pointerDownRef.current = true
|
||||
}}
|
||||
onClick={() => {
|
||||
// Clicking an already-focused field reopens the list (onFocus will
|
||||
// not fire again in that case).
|
||||
if (!isOpen) openList()
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
setHasTyped(false)
|
||||
if (pointerDownRef.current) openList()
|
||||
pointerDownRef.current = false
|
||||
// Typing should replace the committed label, not append to it.
|
||||
e.currentTarget.select()
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 top-full left-0 mt-1 w-full min-w-[16rem] max-h-[300px] overflow-y-auto rounded-lg border border-input bg-card shadow-md"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
data-highlighted={index === highlightedIndex}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer ${
|
||||
index === highlightedIndex ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
||||
} ${option.muted ? 'text-muted-foreground' : ''}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
select(option)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@/components/invoices/line-vat-rates'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { sortArticles } from '@/lib/articles/sort'
|
||||
import ArticleCombobox from '@/components/invoices/ArticleCombobox'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Copy } from 'lucide-react'
|
||||
@@ -1243,6 +1244,24 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
}
|
||||
}
|
||||
|
||||
// A failed Zod validation is otherwise invisible: handleSubmit never reaches
|
||||
// onSubmit, the buttons stay enabled and look normal, and the only signal is
|
||||
// inline error text the user may have scrolled past. Toast + scroll so a
|
||||
// blocked "Granska & skapa" / "Spara som utkast" never reads as a dead button.
|
||||
function onInvalidSubmit(_errors: unknown, event?: React.BaseSyntheticEvent) {
|
||||
toast({
|
||||
title: t('validation_toast_title'),
|
||||
description: t('validation_toast_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
const root = (event?.target as HTMLElement | null)?.closest('form')
|
||||
// The inline error paragraphs render on the next React commit; scroll after.
|
||||
setTimeout(() => {
|
||||
const firstError = (root ?? document).querySelector('p.text-destructive')
|
||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// "Spara som utkast": save an unnumbered draft (save_as_draft) without the
|
||||
// review dialog. The invoice gets no F-number and fires no invoice.created
|
||||
// until the user opens it and clicks "Granska & skapa" (finalize). Same
|
||||
@@ -1539,7 +1558,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={bare ? 'space-y-6' : 'space-y-6 pb-28 md:pb-0'}>
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className={bare ? 'space-y-6' : 'space-y-6 pb-28 md:pb-0'}>
|
||||
<div className="grid gap-6 lg:grid-cols-3 lg:items-start">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@@ -1777,22 +1796,15 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
name={`items.${index}.article_id`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value ?? 'none'}
|
||||
onValueChange={(v) => applyArticle(index, v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('article_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('article_free_text')}</SelectItem>
|
||||
{articles.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.article_number ? `${a.article_number}: ${a.name}` : a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ArticleCombobox
|
||||
value={field.value ?? null}
|
||||
articles={articles}
|
||||
onChange={(v) => applyArticle(index, v)}
|
||||
freeTextLabel={t('article_free_text')}
|
||||
placeholder={t('article_placeholder')}
|
||||
emptyLabel={t('article_search_empty')}
|
||||
ariaLabel={t('article_label')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1843,6 +1855,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
className="text-right tabular-nums"
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.items?.[index]?.quantity && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.items[index].quantity?.message}
|
||||
</p>
|
||||
)}
|
||||
</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">{t('unit_label')}</Label>
|
||||
@@ -1864,6 +1881,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.items?.[index]?.unit && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.items[index].unit?.message}
|
||||
</p>
|
||||
)}
|
||||
</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">{t('unit_price_label')}</Label>
|
||||
@@ -2544,7 +2566,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
size="lg"
|
||||
disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : t('save_as_draft_tooltip')}
|
||||
onClick={handleSubmit(saveDraftData)}
|
||||
onClick={handleSubmit(saveDraftData, onInvalidSubmit)}
|
||||
>
|
||||
{isSavingDraft ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t('save_as_draft')}
|
||||
@@ -2572,7 +2594,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite}
|
||||
onClick={handleSubmit(saveDraftData)}
|
||||
onClick={handleSubmit(saveDraftData, onInvalidSubmit)}
|
||||
>
|
||||
{isSavingDraft ? <Loader2 className="h-4 w-4 animate-spin" /> : t('save_as_draft_short')}
|
||||
</Button>
|
||||
|
||||
@@ -219,6 +219,21 @@ function NewRecurringScheduleForm({
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror InvoiceEditor: a failed validation must never look like a dead
|
||||
// button. Toast, then scroll the first inline error into view once rendered.
|
||||
function onInvalidSubmit(_errors: unknown, event?: React.BaseSyntheticEvent) {
|
||||
toast({
|
||||
title: t('validation_toast_title'),
|
||||
description: t('validation_toast_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
const root = (event?.target as HTMLElement | null)?.closest('form')
|
||||
setTimeout(() => {
|
||||
const firstError = (root ?? document).querySelector('p.text-destructive')
|
||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const items = watch('items')
|
||||
const watchCurrency = watch('currency')
|
||||
// Automatic sending requires a customer email; without one the cron would
|
||||
@@ -246,7 +261,7 @@ function NewRecurringScheduleForm({
|
||||
const subtotal = Math.round(subtotalRaw * 100) / 100
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('schedule_card_title')}</CardTitle>
|
||||
@@ -459,6 +474,11 @@ function NewRecurringScheduleForm({
|
||||
placeholder={t('description_placeholder')}
|
||||
{...register(`items.${index}.description`)}
|
||||
/>
|
||||
{errors.items?.[index]?.description && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.items[index].description?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<Input
|
||||
@@ -468,6 +488,11 @@ function NewRecurringScheduleForm({
|
||||
className="tabular-nums"
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.items?.[index]?.quantity && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.items[index].quantity?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 sm:col-span-1">
|
||||
<Controller
|
||||
@@ -488,6 +513,11 @@ function NewRecurringScheduleForm({
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.items?.[index]?.unit && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.items[index].unit?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-4 sm:col-span-3">
|
||||
<Input
|
||||
|
||||
Reference in New Issue
Block a user