|
|
@@ -7,7 +7,6 @@ import { useTranslations } from 'next-intl'
|
|
|
import { Input } from '@/components/ui/input'
|
|
|
import { Label } from '@/components/ui/label'
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
-import { Separator } from '@/components/ui/separator'
|
|
|
import { Switch } from '@/components/ui/switch'
|
|
|
import {
|
|
|
Select,
|
|
|
@@ -19,10 +18,9 @@ import {
|
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
|
import { toast } from 'sonner'
|
|
|
import { setSettings } from '@/features/settings/Actions/settingsActions'
|
|
|
-import { backfillCustomerNumbers } from '@/features/customers/Actions/customerActions'
|
|
|
import { freezeUnfrozenInvoices } from '@/features/invoices/Actions/legacyInvoiceActions'
|
|
|
import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
|
|
|
-import { FileText, Loader2, Lock, Save } from 'lucide-react'
|
|
|
+import { ChevronDown, ChevronUp, FileText, Hash, Loader2, Lock, Save } from 'lucide-react'
|
|
|
import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
|
|
|
import { cn } from '@/lib/utils'
|
|
|
import { useConfirm } from '@/components/confirm-dialog'
|
|
|
@@ -51,7 +49,6 @@ interface InvoiceSettingsProps {
|
|
|
settings: Record<string, string>
|
|
|
/** Fills the layout preview with this workshop's own letterhead. */
|
|
|
workshop?: { name?: string; address?: string; phone?: string; email?: string; slogan?: string }
|
|
|
- unnumberedCustomers?: number
|
|
|
/** Invoices that reached a customer before issuing existed, still unfrozen. */
|
|
|
unfrozenInvoices?: number
|
|
|
initialInvoiceLayout?: InvoiceLayoutConfig
|
|
|
@@ -61,16 +58,59 @@ interface InvoiceSettingsProps {
|
|
|
telegramEnabled?: boolean
|
|
|
}
|
|
|
|
|
|
+/** One labelled control with its one-line explanation beneath. */
|
|
|
+function Field({
|
|
|
+ id,
|
|
|
+ label,
|
|
|
+ hint,
|
|
|
+ children,
|
|
|
+}: {
|
|
|
+ id: string
|
|
|
+ label: string
|
|
|
+ hint?: React.ReactNode
|
|
|
+ children: React.ReactNode
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div className="space-y-1.5">
|
|
|
+ <Label htmlFor={id}>{label}</Label>
|
|
|
+ {children}
|
|
|
+ {hint && <p className="text-xs leading-relaxed text-muted-foreground">{hint}</p>}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/** A switch with its name and explanation on the left, the way the tax page does it. */
|
|
|
+function SwitchRow({
|
|
|
+ id,
|
|
|
+ label,
|
|
|
+ hint,
|
|
|
+ checked,
|
|
|
+ onCheckedChange,
|
|
|
+}: {
|
|
|
+ id: string
|
|
|
+ label: string
|
|
|
+ hint: string
|
|
|
+ checked: boolean
|
|
|
+ onCheckedChange: (checked: boolean) => void
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div className="flex items-start justify-between gap-4">
|
|
|
+ <div className="space-y-1">
|
|
|
+ <Label htmlFor={id}>{label}</Label>
|
|
|
+ <p className="text-xs leading-relaxed text-muted-foreground">{hint}</p>
|
|
|
+ </div>
|
|
|
+ <Switch id={id} checked={checked} onCheckedChange={onCheckedChange} className="mt-0.5" />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
export function InvoiceSettings({
|
|
|
settings,
|
|
|
- workshop,
|
|
|
- unnumberedCustomers = 0,
|
|
|
unfrozenInvoices = 0,
|
|
|
initialInvoiceLayout,
|
|
|
initialQuoteLayout,
|
|
|
customFields,
|
|
|
customFieldsEnabled,
|
|
|
- telegramEnabled = false,
|
|
|
}: InvoiceSettingsProps) {
|
|
|
const router = useRouter()
|
|
|
const searchParams = useSearchParams()
|
|
|
@@ -92,7 +132,6 @@ export function InvoiceSettings({
|
|
|
[router, searchParams]
|
|
|
)
|
|
|
|
|
|
- // General tab state
|
|
|
const [invoicePrefix, setInvoicePrefix] = useState(
|
|
|
settings[SETTING_KEYS.INVOICE_PREFIX] ?? '{year}-'
|
|
|
)
|
|
|
@@ -105,12 +144,6 @@ export function InvoiceSettings({
|
|
|
)
|
|
|
const [dueDays, setDueDays] = useState(settings[SETTING_KEYS.INVOICE_DUE_DAYS] || '14')
|
|
|
const [footerNote, setFooterNote] = useState(settings[SETTING_KEYS.INVOICE_FOOTER_NOTE] || '')
|
|
|
- const [defaultMarkupPercent, setDefaultMarkupPercent] = useState(
|
|
|
- settings[SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT] || '0'
|
|
|
- )
|
|
|
- const [markupAppliesToInventory, setMarkupAppliesToInventory] = useState(
|
|
|
- settings[SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY] === 'true'
|
|
|
- )
|
|
|
const [invoiceLockEnabled, setInvoiceLockEnabled] = useState(
|
|
|
settings[SETTING_KEYS.INVOICE_LOCK_ENABLED] === 'true'
|
|
|
)
|
|
|
@@ -124,6 +157,9 @@ export function InvoiceSettings({
|
|
|
settings[SETTING_KEYS.QUOTE_LOCK_TRIGGER] || 'accepted'
|
|
|
)
|
|
|
const [attachPdf, setAttachPdf] = useState(settings[SETTING_KEYS.EMAIL_ATTACH_PDF] !== 'false')
|
|
|
+ // The three paragraphs on what a lock freezes are worth reading once, not
|
|
|
+ // every time somebody comes to change a due date. Folded away by default.
|
|
|
+ const [lockDetailsOpen, setLockDetailsOpen] = useState(false)
|
|
|
|
|
|
const handleSaveGeneral = async () => {
|
|
|
setSaving(true)
|
|
|
@@ -134,8 +170,6 @@ export function InvoiceSettings({
|
|
|
[SETTING_KEYS.INVOICE_START_NUMBER]: invoiceStartNumber,
|
|
|
[SETTING_KEYS.INVOICE_DUE_DAYS]: dueDays,
|
|
|
[SETTING_KEYS.INVOICE_FOOTER_NOTE]: footerNote,
|
|
|
- [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
|
|
|
- [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory ? 'true' : 'false',
|
|
|
[SETTING_KEYS.INVOICE_LOCK_ENABLED]: invoiceLockEnabled ? 'true' : 'false',
|
|
|
[SETTING_KEYS.INVOICE_LOCK_TRIGGER]: invoiceLockTrigger,
|
|
|
[SETTING_KEYS.QUOTE_LOCK_ENABLED]: quoteLockEnabled ? 'true' : 'false',
|
|
|
@@ -148,8 +182,6 @@ export function InvoiceSettings({
|
|
|
}
|
|
|
|
|
|
const confirm = useConfirm()
|
|
|
- const [assigning, setAssigning] = useState(false)
|
|
|
- const [unnumbered, setUnnumbered] = useState(unnumberedCustomers)
|
|
|
|
|
|
// Invoices sent before this version could lock what they print. Locked in
|
|
|
// batches, one request each, so a workshop with thousands of them sees
|
|
|
@@ -187,24 +219,15 @@ export function InvoiceSettings({
|
|
|
setFreezing(null)
|
|
|
}
|
|
|
}
|
|
|
- const handleAssignCustomerNumbers = async () => {
|
|
|
- const ok = await confirm({
|
|
|
- title: t('invoice.assignCustomerNumbersConfirmTitle'),
|
|
|
- description: t('invoice.assignCustomerNumbersConfirmDescription', { count: unnumbered }),
|
|
|
- confirmLabel: t('invoice.assignCustomerNumbers'),
|
|
|
+
|
|
|
+ const year = String(new Date().getFullYear())
|
|
|
+ const numberHint = (prefix: string, preview: string) =>
|
|
|
+ t.rich('invoice.invoiceNumberFormatHint', {
|
|
|
+ code: (chunks) => <code className="rounded bg-muted px-1">{chunks}</code>,
|
|
|
+ bold: (chunks) => <span className="font-medium">{chunks}</span>,
|
|
|
+ year: '{year}',
|
|
|
+ preview: prefix.replace(/\{year\}/g, year) + preview,
|
|
|
})
|
|
|
- if (!ok) return
|
|
|
- setAssigning(true)
|
|
|
- const result = await backfillCustomerNumbers()
|
|
|
- setAssigning(false)
|
|
|
- if (result.success && result.data) {
|
|
|
- setUnnumbered(0)
|
|
|
- toast.success(t('invoice.customerNumbersAssigned', { count: result.data.assigned }))
|
|
|
- router.refresh()
|
|
|
- } else {
|
|
|
- toast.error(result.error || t('templates.failedSave'))
|
|
|
- }
|
|
|
- }
|
|
|
|
|
|
return (
|
|
|
<div className="space-y-6">
|
|
|
@@ -216,21 +239,20 @@ export function InvoiceSettings({
|
|
|
</p>
|
|
|
</div>
|
|
|
|
|
|
- {/* Tab Buttons */}
|
|
|
- <div className="flex gap-1 rounded-lg border bg-muted p-1">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- onClick={() => setTab('general')}
|
|
|
- className={cn(
|
|
|
- 'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors',
|
|
|
- tab === 'general'
|
|
|
- ? 'bg-background text-foreground shadow-sm'
|
|
|
- : 'text-muted-foreground hover:text-foreground'
|
|
|
- )}
|
|
|
- >
|
|
|
- {t('invoice.tabs.general')}
|
|
|
- </button>
|
|
|
- {customFieldsEnabled && (
|
|
|
+ {customFieldsEnabled && (
|
|
|
+ <div className="flex gap-1 rounded-lg border bg-muted p-1">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setTab('general')}
|
|
|
+ className={cn(
|
|
|
+ 'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors',
|
|
|
+ tab === 'general'
|
|
|
+ ? 'bg-background text-foreground shadow-sm'
|
|
|
+ : 'text-muted-foreground hover:text-foreground'
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ {t('invoice.tabs.general')}
|
|
|
+ </button>
|
|
|
<button
|
|
|
type="button"
|
|
|
onClick={() => setTab('customFields')}
|
|
|
@@ -243,68 +265,106 @@ export function InvoiceSettings({
|
|
|
>
|
|
|
{t('invoice.tabs.customFields')}
|
|
|
</button>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
{tab === 'general' ? (
|
|
|
<ReadOnlyWrapper>
|
|
|
- <AppCard icon={FileText} title={t('invoice.tabs.general')} contentClassName="space-y-6">
|
|
|
- <div className="space-y-3">
|
|
|
- <h3 className="text-sm font-semibold">{t('invoice.sectionInvoices')}</h3>
|
|
|
- <div className="grid gap-4 sm:grid-cols-2">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="invoicePrefix">{t('invoice.invoiceNumberFormat')}</Label>
|
|
|
- <Input
|
|
|
+ <div className="space-y-6">
|
|
|
+ <AppCard
|
|
|
+ icon={Hash}
|
|
|
+ title={t('invoice.numberingTitle')}
|
|
|
+ description={t('invoice.numberingDescription')}
|
|
|
+ >
|
|
|
+ <div className="grid gap-x-8 gap-y-5 md:grid-cols-2">
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="text-sm font-semibold">{t('invoice.sectionInvoices')}</h3>
|
|
|
+ <Field
|
|
|
id="invoicePrefix"
|
|
|
- placeholder="{year}-"
|
|
|
- value={invoicePrefix}
|
|
|
- onChange={(e) => setInvoicePrefix(e.target.value)}
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {t.rich('invoice.invoiceNumberFormatHint', {
|
|
|
- code: (chunks) => <code className="rounded bg-muted px-1">{chunks}</code>,
|
|
|
- bold: (chunks) => <span className="font-medium">{chunks}</span>,
|
|
|
- year: '{year}',
|
|
|
- preview:
|
|
|
- invoicePrefix.replace(/\{year\}/g, String(new Date().getFullYear())) +
|
|
|
- (invoiceStartNumber || '1001'),
|
|
|
- })}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="invoiceStartNumber">{t('invoice.nextInvoiceNumber')}</Label>
|
|
|
- <Input
|
|
|
+ label={t('invoice.invoiceNumberFormat')}
|
|
|
+ hint={numberHint(invoicePrefix, invoiceStartNumber || '1001')}
|
|
|
+ >
|
|
|
+ <Input
|
|
|
+ id="invoicePrefix"
|
|
|
+ placeholder="{year}-"
|
|
|
+ value={invoicePrefix}
|
|
|
+ onChange={(e) => setInvoicePrefix(e.target.value)}
|
|
|
+ />
|
|
|
+ </Field>
|
|
|
+ <Field
|
|
|
id="invoiceStartNumber"
|
|
|
- type="number"
|
|
|
- min="1"
|
|
|
- placeholder={t('invoice.nextInvoiceNumberPlaceholder')}
|
|
|
- value={invoiceStartNumber}
|
|
|
- onChange={(e) => setInvoiceStartNumber(e.target.value)}
|
|
|
- className="w-32"
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {t('invoice.nextInvoiceNumberHint', {
|
|
|
+ label={t('invoice.nextInvoiceNumber')}
|
|
|
+ hint={t('invoice.nextInvoiceNumberHint', {
|
|
|
example: invoicePrefix + (invoiceStartNumber || '...'),
|
|
|
})}
|
|
|
- </p>
|
|
|
+ >
|
|
|
+ <Input
|
|
|
+ id="invoiceStartNumber"
|
|
|
+ type="number"
|
|
|
+ min="1"
|
|
|
+ placeholder={t('invoice.nextInvoiceNumberPlaceholder')}
|
|
|
+ value={invoiceStartNumber}
|
|
|
+ onChange={(e) => setInvoiceStartNumber(e.target.value)}
|
|
|
+ className="w-32"
|
|
|
+ />
|
|
|
+ </Field>
|
|
|
+ <Field id="dueDays" label={t('invoice.dueDays')} hint={t('invoice.dueDaysHint')}>
|
|
|
+ <Input
|
|
|
+ id="dueDays"
|
|
|
+ type="number"
|
|
|
+ min="0"
|
|
|
+ placeholder="14"
|
|
|
+ value={dueDays}
|
|
|
+ onChange={(e) => setDueDays(e.target.value)}
|
|
|
+ className="w-24"
|
|
|
+ />
|
|
|
+ </Field>
|
|
|
</div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="dueDays">{t('invoice.dueDays')}</Label>
|
|
|
- <Input
|
|
|
- id="dueDays"
|
|
|
- type="number"
|
|
|
- min="0"
|
|
|
- placeholder="14"
|
|
|
- value={dueDays}
|
|
|
- onChange={(e) => setDueDays(e.target.value)}
|
|
|
- className="w-24"
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">{t('invoice.dueDaysHint')}</p>
|
|
|
+
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="text-sm font-semibold">{t('invoice.sectionQuotes')}</h3>
|
|
|
+ <Field
|
|
|
+ id="quotePrefix"
|
|
|
+ label={t('invoice.quoteNumberFormat')}
|
|
|
+ hint={numberHint(quotePrefix, '1001')}
|
|
|
+ >
|
|
|
+ <Input
|
|
|
+ id="quotePrefix"
|
|
|
+ placeholder="QT-"
|
|
|
+ value={quotePrefix}
|
|
|
+ onChange={(e) => setQuotePrefix(e.target.value)}
|
|
|
+ />
|
|
|
+ </Field>
|
|
|
+ <Field
|
|
|
+ id="quoteValidDays"
|
|
|
+ label={t('invoice.quoteValidDays')}
|
|
|
+ hint={t('invoice.quoteValidDaysHint')}
|
|
|
+ >
|
|
|
+ <Input
|
|
|
+ id="quoteValidDays"
|
|
|
+ type="number"
|
|
|
+ min="0"
|
|
|
+ placeholder="30"
|
|
|
+ value={quoteValidDays}
|
|
|
+ onChange={(e) => setQuoteValidDays(e.target.value)}
|
|
|
+ className="w-24"
|
|
|
+ />
|
|
|
+ </Field>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ </AppCard>
|
|
|
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="footerNote">{t('invoice.customFooter')}</Label>
|
|
|
+ <AppCard
|
|
|
+ icon={FileText}
|
|
|
+ title={t('invoice.documentsTitle')}
|
|
|
+ description={t('invoice.documentsDescription')}
|
|
|
+ contentClassName="space-y-5"
|
|
|
+ >
|
|
|
+ <Field
|
|
|
+ id="footerNote"
|
|
|
+ label={t('invoice.customFooter')}
|
|
|
+ hint={t('invoice.footerHint')}
|
|
|
+ >
|
|
|
<Textarea
|
|
|
id="footerNote"
|
|
|
placeholder={t('invoice.footerPlaceholder')}
|
|
|
@@ -312,108 +372,24 @@ export function InvoiceSettings({
|
|
|
value={footerNote}
|
|
|
onChange={(e) => setFooterNote(e.target.value)}
|
|
|
/>
|
|
|
- <p className="text-xs text-muted-foreground">{t('invoice.footerHint')}</p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <Separator />
|
|
|
-
|
|
|
- <div className="space-y-3">
|
|
|
- <h3 className="text-sm font-semibold">{t('invoice.sectionQuotes')}</h3>
|
|
|
- <div className="grid gap-4 sm:grid-cols-2">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="quotePrefix">{t('invoice.quoteNumberFormat')}</Label>
|
|
|
- <Input
|
|
|
- id="quotePrefix"
|
|
|
- placeholder="QT-"
|
|
|
- value={quotePrefix}
|
|
|
- onChange={(e) => setQuotePrefix(e.target.value)}
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {t.rich('invoice.quoteNumberFormatHint', {
|
|
|
- code: (chunks) => <code className="rounded bg-muted px-1">{chunks}</code>,
|
|
|
- bold: (chunks) => <span className="font-medium">{chunks}</span>,
|
|
|
- year: '{year}',
|
|
|
- preview:
|
|
|
- quotePrefix.replace(/\{year\}/g, String(new Date().getFullYear())) + '1001',
|
|
|
- })}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="quoteValidDays">{t('invoice.quoteValidDays')}</Label>
|
|
|
- <Input
|
|
|
- id="quoteValidDays"
|
|
|
- type="number"
|
|
|
- min="0"
|
|
|
- placeholder="30"
|
|
|
- value={quoteValidDays}
|
|
|
- onChange={(e) => setQuoteValidDays(e.target.value)}
|
|
|
- className="w-32"
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">{t('invoice.quoteValidDaysHint')}</p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <Separator />
|
|
|
-
|
|
|
- <div className="space-y-3">
|
|
|
- <h3 className="text-sm font-semibold">{t('invoice.sectionSending')}</h3>
|
|
|
- <Label
|
|
|
- htmlFor="attachPdf"
|
|
|
- className="flex items-center justify-between gap-3 font-normal"
|
|
|
- >
|
|
|
- <span className="text-sm">{t('invoice.attachPdfLabel')}</span>
|
|
|
- <Switch id="attachPdf" checked={attachPdf} onCheckedChange={setAttachPdf} />
|
|
|
- </Label>
|
|
|
- <p className="text-xs text-muted-foreground">{t('invoice.attachPdfHint')}</p>
|
|
|
- </div>
|
|
|
-
|
|
|
- <Separator />
|
|
|
-
|
|
|
- <div className="space-y-3">
|
|
|
- <h3 className="text-sm font-semibold">{t('invoice.sectionCustomers')}</h3>
|
|
|
- <div className="flex items-center justify-between gap-4">
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {unnumbered > 0
|
|
|
- ? t('invoice.assignCustomerNumbersHint', { count: unnumbered })
|
|
|
- : t('invoice.allCustomersNumbered')}
|
|
|
- </p>
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- variant="outline"
|
|
|
- size="sm"
|
|
|
- disabled={assigning || unnumbered === 0}
|
|
|
- onClick={handleAssignCustomerNumbers}
|
|
|
- >
|
|
|
- {assigning && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
|
|
- {t('invoice.assignCustomerNumbers')}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <Separator />
|
|
|
-
|
|
|
- <div className="space-y-3">
|
|
|
- <div>
|
|
|
- <h3 className="flex items-center gap-2 text-sm font-semibold">
|
|
|
- <Lock className="h-3.5 w-3.5" />
|
|
|
- {t('invoice.lockTitle')}
|
|
|
- </h3>
|
|
|
- <p className="text-xs text-muted-foreground">{t('invoice.lockDescription')}</p>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="rounded-md border border-dashed p-3">
|
|
|
- <p className="text-xs text-muted-foreground">{t('invoice.lockWhatItDoes')}</p>
|
|
|
- <p className="mt-1.5 text-xs text-muted-foreground">
|
|
|
- {t('invoice.lockWhatItAllows')}
|
|
|
- </p>
|
|
|
- <p className="mt-1.5 text-xs text-muted-foreground">
|
|
|
- {t('invoice.lockUnlockNote')}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
+ </Field>
|
|
|
+ <SwitchRow
|
|
|
+ id="attachPdf"
|
|
|
+ label={t('invoice.attachPdfLabel')}
|
|
|
+ hint={t('invoice.attachPdfHint')}
|
|
|
+ checked={attachPdf}
|
|
|
+ onCheckedChange={setAttachPdf}
|
|
|
+ />
|
|
|
+ </AppCard>
|
|
|
|
|
|
- <div className="grid gap-4 sm:grid-cols-2">
|
|
|
+ <AppCard
|
|
|
+ icon={Lock}
|
|
|
+ title={t('invoice.lockTitle')}
|
|
|
+ description={t('invoice.lockDescription')}
|
|
|
+ contentClassName="space-y-5"
|
|
|
+ footer={unfrozen === 0 ? t('invoice.freezeNone') : undefined}
|
|
|
+ >
|
|
|
+ <div className="grid gap-x-8 gap-y-5 md:grid-cols-2">
|
|
|
<div className="space-y-2">
|
|
|
<Label
|
|
|
htmlFor="invoiceLockEnabled"
|
|
|
@@ -439,7 +415,7 @@ export function InvoiceSettings({
|
|
|
<SelectItem value="paid">{t('invoice.lockTriggerPaid')}</SelectItem>
|
|
|
</SelectContent>
|
|
|
</Select>
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
+ <p className="text-xs leading-relaxed text-muted-foreground">
|
|
|
{invoiceLockTrigger === 'sent'
|
|
|
? t('invoice.lockTriggerSentHint')
|
|
|
: t('invoice.lockTriggerPaidHint')}
|
|
|
@@ -473,7 +449,7 @@ export function InvoiceSettings({
|
|
|
</SelectItem>
|
|
|
</SelectContent>
|
|
|
</Select>
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
+ <p className="text-xs leading-relaxed text-muted-foreground">
|
|
|
{quoteLockTrigger === 'sent'
|
|
|
? t('invoice.quoteLockTriggerSentHint')
|
|
|
: t('invoice.quoteLockTriggerAcceptedHint')}
|
|
|
@@ -481,15 +457,36 @@ export function InvoiceSettings({
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div className="rounded-md border p-3">
|
|
|
- <h4 className="text-sm font-medium">{t('invoice.freezeTitle')}</h4>
|
|
|
- <div className="mt-1.5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {unfrozen > 0
|
|
|
- ? t('invoice.freezeBody', { count: unfrozen })
|
|
|
- : t('invoice.freezeNone')}
|
|
|
- </p>
|
|
|
- {unfrozen > 0 && (
|
|
|
+ <div>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setLockDetailsOpen((open) => !open)}
|
|
|
+ aria-expanded={lockDetailsOpen}
|
|
|
+ className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
|
|
+ >
|
|
|
+ {lockDetailsOpen ? t('invoice.lockDetailsHide') : t('invoice.lockDetailsShow')}
|
|
|
+ {lockDetailsOpen ? (
|
|
|
+ <ChevronUp className="h-3.5 w-3.5" />
|
|
|
+ ) : (
|
|
|
+ <ChevronDown className="h-3.5 w-3.5" />
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ {lockDetailsOpen && (
|
|
|
+ <div className="mt-2 space-y-1.5 rounded-md border border-dashed p-3 text-xs leading-relaxed text-muted-foreground">
|
|
|
+ <p>{t('invoice.lockWhatItDoes')}</p>
|
|
|
+ <p>{t('invoice.lockWhatItAllows')}</p>
|
|
|
+ <p>{t('invoice.lockUnlockNote')}</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {unfrozen > 0 && (
|
|
|
+ <div className="rounded-md border p-3">
|
|
|
+ <h4 className="text-sm font-medium">{t('invoice.freezeTitle')}</h4>
|
|
|
+ <div className="mt-1.5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
|
+ <p className="text-xs leading-relaxed text-muted-foreground">
|
|
|
+ {t('invoice.freezeBody', { count: unfrozen })}
|
|
|
+ </p>
|
|
|
<Button
|
|
|
type="button"
|
|
|
variant="outline"
|
|
|
@@ -506,58 +503,12 @@ export function InvoiceSettings({
|
|
|
})
|
|
|
: t('invoice.freezeButton')}
|
|
|
</Button>
|
|
|
- )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <Separator />
|
|
|
-
|
|
|
- <div className="space-y-3">
|
|
|
- <div>
|
|
|
- <h3 className="text-sm font-semibold">{t('invoice.partsMarkupTitle')}</h3>
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {t('invoice.partsMarkupDescription')}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- <div className="grid gap-4 sm:grid-cols-2">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="defaultMarkupPercent">{t('invoice.defaultMarkupPercent')}</Label>
|
|
|
- <Input
|
|
|
- id="defaultMarkupPercent"
|
|
|
- type="number"
|
|
|
- min="0"
|
|
|
- step="0.1"
|
|
|
- placeholder="0"
|
|
|
- value={defaultMarkupPercent}
|
|
|
- onChange={(e) => setDefaultMarkupPercent(e.target.value)}
|
|
|
- className="w-32"
|
|
|
- />
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {t('invoice.defaultMarkupPercentHint')}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label
|
|
|
- htmlFor="markupAppliesToInventory"
|
|
|
- className="flex items-center justify-between gap-3"
|
|
|
- >
|
|
|
- <span>{t('invoice.markupAppliesToInventory')}</span>
|
|
|
- <Switch
|
|
|
- id="markupAppliesToInventory"
|
|
|
- checked={markupAppliesToInventory}
|
|
|
- onCheckedChange={setMarkupAppliesToInventory}
|
|
|
- />
|
|
|
- </Label>
|
|
|
- <p className="text-xs text-muted-foreground">
|
|
|
- {t('invoice.markupAppliesToInventoryHint')}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ )}
|
|
|
+ </AppCard>
|
|
|
|
|
|
<SaveButton>
|
|
|
- <Separator />
|
|
|
<div className="flex items-center gap-3">
|
|
|
<Button onClick={handleSaveGeneral} disabled={saving}>
|
|
|
{saving ? (
|
|
|
@@ -569,7 +520,7 @@ export function InvoiceSettings({
|
|
|
</Button>
|
|
|
</div>
|
|
|
</SaveButton>
|
|
|
- </AppCard>
|
|
|
+ </div>
|
|
|
</ReadOnlyWrapper>
|
|
|
) : (
|
|
|
<CustomFieldsManager
|