Explorar o código

Add quote number format setting (#220)

* Add quote number format setting

The quote prefix (workshop.quotePrefix, default QT-) existed but had no
settings UI. It is now editable next to the invoice number format in
Settings -> Invoice & Quotes, supports the {year} token, and respects
an explicitly empty value.

* Prefill quote valid-until from a new quote validity setting

New quotes get validUntil defaulted to creation date plus
workshop.quoteValidDays (30 when unset, 0 disables). The setting is
editable in Settings -> Invoice & Quotes next to the quote number
format, with labels in all 12 languages.

* Group invoice and quote settings into explicit sections

The general tab mixed invoice and quote fields in one grid. It now has
an Invoices section (number format, next number, due days, footer note)
and a Quotes section (number format, validity days), matching the
existing Parts & Markup heading style. Headings in all 12 languages.
Bernt Christian Egeland hai 1 mes
pai
achega
43fe2802af

+ 6 - 0
messages/de/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Rechnungsnummernformat",
     "invoiceNumberFormat": "Rechnungsnummernformat",
     "invoiceNumberFormatHint": "Verwenden Sie <code>{year}</code> fur das aktuelle Jahr. Vorschau: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Verwenden Sie <code>{year}</code> fur das aktuelle Jahr. Vorschau: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Angebotsnummernformat",
+    "quoteNumberFormatHint": "Verwenden Sie <code>{year}</code> fur das aktuelle Jahr. Vorschau: <bold>{preview}</bold>",
+    "quoteValidDays": "Angebotsgültigkeit (Tage)",
+    "quoteValidDaysHint": "Neue Angebote erhalten ein Gültig-bis-Datum so viele Tage im Voraus. 0 deaktiviert dies.",
+    "sectionInvoices": "Rechnungen",
+    "sectionQuotes": "Angebote",
     "nextInvoiceNumber": "Nachste Rechnungsnummer",
     "nextInvoiceNumber": "Nachste Rechnungsnummer",
     "nextInvoiceNumberPlaceholder": "z.B. 94",
     "nextInvoiceNumberPlaceholder": "z.B. 94",
     "nextInvoiceNumberHint": "Die nachste Rechnung verwendet diese Nummer (z.B. {example})",
     "nextInvoiceNumberHint": "Die nachste Rechnung verwendet diese Nummer (z.B. {example})",

+ 6 - 0
messages/en/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Invoice Number Format",
     "invoiceNumberFormat": "Invoice Number Format",
     "invoiceNumberFormatHint": "Use <code>{year}</code> for the current year. Preview: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Use <code>{year}</code> for the current year. Preview: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Quote Number Format",
+    "quoteNumberFormatHint": "Use <code>{year}</code> for the current year. Preview: <bold>{preview}</bold>",
+    "quoteValidDays": "Quote Validity (Days)",
+    "quoteValidDaysHint": "New quotes get a valid until date this many days ahead. Set 0 to disable.",
+    "sectionInvoices": "Invoices",
+    "sectionQuotes": "Quotes",
     "nextInvoiceNumber": "Next Invoice Number",
     "nextInvoiceNumber": "Next Invoice Number",
     "nextInvoiceNumberPlaceholder": "e.g. 94",
     "nextInvoiceNumberPlaceholder": "e.g. 94",
     "nextInvoiceNumberHint": "Next invoice will use this number (e.g. {example})",
     "nextInvoiceNumberHint": "Next invoice will use this number (e.g. {example})",

+ 6 - 0
messages/es/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Formato de numero de factura",
     "invoiceNumberFormat": "Formato de numero de factura",
     "invoiceNumberFormatHint": "Use <code>{year}</code> para el ano actual. Vista previa: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Use <code>{year}</code> para el ano actual. Vista previa: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Formato de numero de presupuesto",
+    "quoteNumberFormatHint": "Use <code>{year}</code> para el ano actual. Vista previa: <bold>{preview}</bold>",
+    "quoteValidDays": "Validez del presupuesto (días)",
+    "quoteValidDaysHint": "Los nuevos presupuestos reciben una fecha de validez con esta cantidad de días. 0 lo desactiva.",
+    "sectionInvoices": "Facturas",
+    "sectionQuotes": "Presupuestos",
     "nextInvoiceNumber": "Siguiente numero de factura",
     "nextInvoiceNumber": "Siguiente numero de factura",
     "nextInvoiceNumberPlaceholder": "Ej. 94",
     "nextInvoiceNumberPlaceholder": "Ej. 94",
     "nextInvoiceNumberHint": "La siguiente factura usara este numero (ej. {example})",
     "nextInvoiceNumberHint": "La siguiente factura usara este numero (ej. {example})",

+ 6 - 0
messages/fr/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Format du numero de facture",
     "invoiceNumberFormat": "Format du numero de facture",
     "invoiceNumberFormatHint": "Utilisez <code>{year}</code> pour l'annee en cours. Apercu : <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Utilisez <code>{year}</code> pour l'annee en cours. Apercu : <bold>{preview}</bold>",
+    "quoteNumberFormat": "Format du numero de devis",
+    "quoteNumberFormatHint": "Utilisez <code>{year}</code> pour l'annee en cours. Apercu : <bold>{preview}</bold>",
+    "quoteValidDays": "Validité du devis (jours)",
+    "quoteValidDaysHint": "Les nouveaux devis reçoivent une date de validité de ce nombre de jours. 0 désactive cette option.",
+    "sectionInvoices": "Factures",
+    "sectionQuotes": "Devis",
     "nextInvoiceNumber": "Prochain numero de facture",
     "nextInvoiceNumber": "Prochain numero de facture",
     "nextInvoiceNumberPlaceholder": "Ex. 94",
     "nextInvoiceNumberPlaceholder": "Ex. 94",
     "nextInvoiceNumberHint": "La prochaine facture utilisera ce numero (ex. {example})",
     "nextInvoiceNumberHint": "La prochaine facture utilisera ce numero (ex. {example})",

+ 6 - 0
messages/it/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Formato numero fattura",
     "invoiceNumberFormat": "Formato numero fattura",
     "invoiceNumberFormatHint": "Usa <code>{year}</code> per l'anno corrente. Anteprima: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Usa <code>{year}</code> per l'anno corrente. Anteprima: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Formato numero preventivo",
+    "quoteNumberFormatHint": "Usa <code>{year}</code> per l'anno corrente. Anteprima: <bold>{preview}</bold>",
+    "quoteValidDays": "Validità del preventivo (giorni)",
+    "quoteValidDaysHint": "I nuovi preventivi ricevono una data di validità di questo numero di giorni. 0 disattiva la funzione.",
+    "sectionInvoices": "Fatture",
+    "sectionQuotes": "Preventivi",
     "nextInvoiceNumber": "Prossimo numero fattura",
     "nextInvoiceNumber": "Prossimo numero fattura",
     "nextInvoiceNumberPlaceholder": "es. 94",
     "nextInvoiceNumberPlaceholder": "es. 94",
     "nextInvoiceNumberHint": "La prossima fattura usera questo numero (es. {example})",
     "nextInvoiceNumberHint": "La prossima fattura usera questo numero (es. {example})",

+ 6 - 0
messages/lt/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Sąskaitos numerio formatas",
     "invoiceNumberFormat": "Sąskaitos numerio formatas",
     "invoiceNumberFormatHint": "Naudokite <code>{year}</code> einamiesiems metams. Peržiūra: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Naudokite <code>{year}</code> einamiesiems metams. Peržiūra: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Pasiūlymo numerio formatas",
+    "quoteNumberFormatHint": "Naudokite <code>{year}</code> einamiesiems metams. Peržiūra: <bold>{preview}</bold>",
+    "quoteValidDays": "Pasiūlymo galiojimas (dienos)",
+    "quoteValidDaysHint": "Nauji pasiūlymai gauna galiojimo datą po tiek dienų. 0 išjungia.",
+    "sectionInvoices": "Sąskaitos faktūros",
+    "sectionQuotes": "Pasiūlymai",
     "nextInvoiceNumber": "Kitas sąskaitos numeris",
     "nextInvoiceNumber": "Kitas sąskaitos numeris",
     "nextInvoiceNumberPlaceholder": "pvz., 94",
     "nextInvoiceNumberPlaceholder": "pvz., 94",
     "nextInvoiceNumberHint": "Kita sąskaita naudos šį numerį (pvz., {example})",
     "nextInvoiceNumberHint": "Kita sąskaita naudos šį numerį (pvz., {example})",

+ 6 - 0
messages/nb/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Fakturanummerformat",
     "invoiceNumberFormat": "Fakturanummerformat",
     "invoiceNumberFormatHint": "Bruk <code>{year}</code> for innevarende ar. Forhandsvisning: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Bruk <code>{year}</code> for innevarende ar. Forhandsvisning: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Tilbudsnummerformat",
+    "quoteNumberFormatHint": "Bruk <code>{year}</code> for innevarende ar. Forhandsvisning: <bold>{preview}</bold>",
+    "quoteValidDays": "Tilbudets gyldighet (dager)",
+    "quoteValidDaysHint": "Nye tilbud får en gyldig til-dato så mange dager frem. 0 deaktiverer dette.",
+    "sectionInvoices": "Fakturaer",
+    "sectionQuotes": "Tilbud",
     "nextInvoiceNumber": "Neste fakturanummer",
     "nextInvoiceNumber": "Neste fakturanummer",
     "nextInvoiceNumberPlaceholder": "F.eks. 94",
     "nextInvoiceNumberPlaceholder": "F.eks. 94",
     "nextInvoiceNumberHint": "Neste faktura bruker dette nummeret (f.eks. {example})",
     "nextInvoiceNumberHint": "Neste faktura bruker dette nummeret (f.eks. {example})",

+ 6 - 0
messages/nl/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Factuurnummerformaat",
     "invoiceNumberFormat": "Factuurnummerformaat",
     "invoiceNumberFormatHint": "Gebruik <code>{year}</code> voor het huidige jaar. Voorbeeld: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Gebruik <code>{year}</code> voor het huidige jaar. Voorbeeld: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Offertenummerformaat",
+    "quoteNumberFormatHint": "Gebruik <code>{year}</code> voor het huidige jaar. Voorbeeld: <bold>{preview}</bold>",
+    "quoteValidDays": "Geldigheid offerte (dagen)",
+    "quoteValidDaysHint": "Nieuwe offertes krijgen een geldig-tot-datum zoveel dagen vooruit. 0 schakelt dit uit.",
+    "sectionInvoices": "Facturen",
+    "sectionQuotes": "Offertes",
     "nextInvoiceNumber": "Volgend factuurnummer",
     "nextInvoiceNumber": "Volgend factuurnummer",
     "nextInvoiceNumberPlaceholder": "Bijv. 94",
     "nextInvoiceNumberPlaceholder": "Bijv. 94",
     "nextInvoiceNumberHint": "De volgende factuur gebruikt dit nummer (bijv. {example})",
     "nextInvoiceNumberHint": "De volgende factuur gebruikt dit nummer (bijv. {example})",

+ 6 - 0
messages/pl/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Format numeru faktury",
     "invoiceNumberFormat": "Format numeru faktury",
     "invoiceNumberFormatHint": "Uzyj <code>{year}</code> dla biezacego roku. Podglad: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Uzyj <code>{year}</code> dla biezacego roku. Podglad: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Format numeru wyceny",
+    "quoteNumberFormatHint": "Uzyj <code>{year}</code> dla biezacego roku. Podglad: <bold>{preview}</bold>",
+    "quoteValidDays": "Ważność wyceny (dni)",
+    "quoteValidDaysHint": "Nowe wyceny otrzymują datę ważności o tyle dni do przodu. 0 wyłącza tę opcję.",
+    "sectionInvoices": "Faktury",
+    "sectionQuotes": "Wyceny",
     "nextInvoiceNumber": "Nastepny numer faktury",
     "nextInvoiceNumber": "Nastepny numer faktury",
     "nextInvoiceNumberPlaceholder": "Np. 94",
     "nextInvoiceNumberPlaceholder": "Np. 94",
     "nextInvoiceNumberHint": "Nastepna faktura uzyje tego numeru (np. {example})",
     "nextInvoiceNumberHint": "Nastepna faktura uzyje tego numeru (np. {example})",

+ 6 - 0
messages/pt-BR/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Formato do numero da fatura",
     "invoiceNumberFormat": "Formato do numero da fatura",
     "invoiceNumberFormatHint": "Use <code>{year}</code> para o ano atual. Visualizacao: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Use <code>{year}</code> para o ano atual. Visualizacao: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Formato do numero do orçamento",
+    "quoteNumberFormatHint": "Use <code>{year}</code> para o ano atual. Visualizacao: <bold>{preview}</bold>",
+    "quoteValidDays": "Validade do orçamento (dias)",
+    "quoteValidDaysHint": "Novos orçamentos recebem uma data de validade com esse número de dias. 0 desativa.",
+    "sectionInvoices": "Faturas",
+    "sectionQuotes": "Orçamentos",
     "nextInvoiceNumber": "Proximo numero da fatura",
     "nextInvoiceNumber": "Proximo numero da fatura",
     "nextInvoiceNumberPlaceholder": "Ex. 94",
     "nextInvoiceNumberPlaceholder": "Ex. 94",
     "nextInvoiceNumberHint": "A proxima fatura usara este numero (ex. {example})",
     "nextInvoiceNumberHint": "A proxima fatura usara este numero (ex. {example})",

+ 6 - 0
messages/ru/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Формат номера счёта",
     "invoiceNumberFormat": "Формат номера счёта",
     "invoiceNumberFormatHint": "Используйте <code>{year}</code> для текущего года. Предпросмотр: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Используйте <code>{year}</code> для текущего года. Предпросмотр: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Формат номера предложения",
+    "quoteNumberFormatHint": "Используйте <code>{year}</code> для текущего года. Предпросмотр: <bold>{preview}</bold>",
+    "quoteValidDays": "Срок действия предложения (дни)",
+    "quoteValidDaysHint": "Новые предложения получают дату действия через указанное число дней. 0 отключает.",
+    "sectionInvoices": "Счета",
+    "sectionQuotes": "Предложения",
     "nextInvoiceNumber": "Следующий номер счёта",
     "nextInvoiceNumber": "Следующий номер счёта",
     "nextInvoiceNumberPlaceholder": "напр. 94",
     "nextInvoiceNumberPlaceholder": "напр. 94",
     "nextInvoiceNumberHint": "Следующий счёт будет использовать этот номер (напр. {example})",
     "nextInvoiceNumberHint": "Следующий счёт будет использовать этот номер (напр. {example})",

+ 6 - 0
messages/tr/settings.json

@@ -381,6 +381,12 @@
     },
     },
     "invoiceNumberFormat": "Fatura Numara Formatı",
     "invoiceNumberFormat": "Fatura Numara Formatı",
     "invoiceNumberFormatHint": "Geçerli yıl için <code>{year}</code> kullanın. Önizleme: <bold>{preview}</bold>",
     "invoiceNumberFormatHint": "Geçerli yıl için <code>{year}</code> kullanın. Önizleme: <bold>{preview}</bold>",
+    "quoteNumberFormat": "Teklif Numara Formatı",
+    "quoteNumberFormatHint": "Geçerli yıl için <code>{year}</code> kullanın. Önizleme: <bold>{preview}</bold>",
+    "quoteValidDays": "Teklif Geçerliliği (Gün)",
+    "quoteValidDaysHint": "Yeni teklifler bu kadar gün ileri bir geçerlilik tarihi alır. 0 devre dışı bırakır.",
+    "sectionInvoices": "Faturalar",
+    "sectionQuotes": "Teklifler",
     "nextInvoiceNumber": "Sonraki Fatura Numarası",
     "nextInvoiceNumber": "Sonraki Fatura Numarası",
     "nextInvoiceNumberPlaceholder": "ör. 94",
     "nextInvoiceNumberPlaceholder": "ör. 94",
     "nextInvoiceNumberHint": "Sonraki fatura bu numarayı kullanacak (ör. {example})",
     "nextInvoiceNumberHint": "Sonraki fatura bu numarayı kullanacak (ör. {example})",

+ 64 - 0
src/__tests__/features/quotes/create-quote.test.ts

@@ -164,6 +164,70 @@ describe("createQuote — quote number generation", () => {
       })
       })
     );
     );
   });
   });
+
+  it("defaults validUntil from workshop.quoteValidDays when not provided", async () => {
+    setupAuth();
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.quoteValidDays", value: "14" } as any,
+    ]);
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "q-4" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        quote: { create: mockCreate },
+        quotePart: { createMany: vi.fn() },
+        quoteLabor: { createMany: vi.fn() },
+      })
+    );
+
+    await createQuote({
+      title: "Test",
+      status: "draft",
+      subtotal: 0,
+      taxRate: 0,
+      taxAmount: 0,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 0,
+    });
+
+    const validUntil = mockCreate.mock.calls[0][0].data.validUntil as Date;
+    const expected = new Date();
+    expected.setDate(expected.getDate() + 14);
+    expect(validUntil).toBeInstanceOf(Date);
+    expect(validUntil.toDateString()).toBe(expected.toDateString());
+  });
+
+  it("does not default validUntil when quoteValidDays is 0", async () => {
+    setupAuth();
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.quoteValidDays", value: "0" } as any,
+    ]);
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "q-5" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        quote: { create: mockCreate },
+        quotePart: { createMany: vi.fn() },
+        quoteLabor: { createMany: vi.fn() },
+      })
+    );
+
+    await createQuote({
+      title: "Test",
+      status: "draft",
+      subtotal: 0,
+      taxRate: 0,
+      taxAmount: 0,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 0,
+    });
+
+    expect(mockCreate.mock.calls[0][0].data.validUntil).toBeUndefined();
+  });
 });
 });
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------

+ 59 - 11
src/app/(authenticated)/settings/invoice/invoice-settings.tsx

@@ -86,6 +86,10 @@ export function InvoiceSettings({
   const [invoiceStartNumber, setInvoiceStartNumber] = useState(
   const [invoiceStartNumber, setInvoiceStartNumber] = useState(
     settings[SETTING_KEYS.INVOICE_START_NUMBER] || ''
     settings[SETTING_KEYS.INVOICE_START_NUMBER] || ''
   )
   )
+  const [quotePrefix, setQuotePrefix] = useState(settings[SETTING_KEYS.QUOTE_PREFIX] ?? 'QT-')
+  const [quoteValidDays, setQuoteValidDays] = useState(
+    settings[SETTING_KEYS.QUOTE_VALID_DAYS] ?? '30'
+  )
   const [dueDays, setDueDays] = useState(settings[SETTING_KEYS.INVOICE_DUE_DAYS] || '14')
   const [dueDays, setDueDays] = useState(settings[SETTING_KEYS.INVOICE_DUE_DAYS] || '14')
   const [footerNote, setFooterNote] = useState(settings[SETTING_KEYS.INVOICE_FOOTER_NOTE] || '')
   const [footerNote, setFooterNote] = useState(settings[SETTING_KEYS.INVOICE_FOOTER_NOTE] || '')
   const [defaultMarkupPercent, setDefaultMarkupPercent] = useState(
   const [defaultMarkupPercent, setDefaultMarkupPercent] = useState(
@@ -125,6 +129,8 @@ export function InvoiceSettings({
     setSaving(true)
     setSaving(true)
     await setSettings({
     await setSettings({
       [SETTING_KEYS.INVOICE_PREFIX]: invoicePrefix,
       [SETTING_KEYS.INVOICE_PREFIX]: invoicePrefix,
+      [SETTING_KEYS.QUOTE_PREFIX]: quotePrefix,
+      [SETTING_KEYS.QUOTE_VALID_DAYS]: quoteValidDays,
       [SETTING_KEYS.INVOICE_START_NUMBER]: invoiceStartNumber,
       [SETTING_KEYS.INVOICE_START_NUMBER]: invoiceStartNumber,
       [SETTING_KEYS.INVOICE_DUE_DAYS]: dueDays,
       [SETTING_KEYS.INVOICE_DUE_DAYS]: dueDays,
       [SETTING_KEYS.INVOICE_FOOTER_NOTE]: footerNote,
       [SETTING_KEYS.INVOICE_FOOTER_NOTE]: footerNote,
@@ -215,7 +221,9 @@ export function InvoiceSettings({
               <CardTitle className="text-lg">{t('invoice.tabs.general')}</CardTitle>
               <CardTitle className="text-lg">{t('invoice.tabs.general')}</CardTitle>
             </CardHeader>
             </CardHeader>
             <CardContent className="space-y-6">
             <CardContent className="space-y-6">
-              <div className="grid gap-4 sm:grid-cols-2">
+              <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">
                 <div className="space-y-2">
                   <Label htmlFor="invoicePrefix">{t('invoice.invoiceNumberFormat')}</Label>
                   <Label htmlFor="invoicePrefix">{t('invoice.invoiceNumberFormat')}</Label>
                   <Input
                   <Input
@@ -265,20 +273,60 @@ export function InvoiceSettings({
                   />
                   />
                   <p className="text-xs text-muted-foreground">{t('invoice.dueDaysHint')}</p>
                   <p className="text-xs text-muted-foreground">{t('invoice.dueDaysHint')}</p>
                 </div>
                 </div>
+                </div>
+
+                <div className="space-y-2">
+                  <Label htmlFor="footerNote">{t('invoice.customFooter')}</Label>
+                  <Textarea
+                    id="footerNote"
+                    placeholder={t('invoice.footerPlaceholder')}
+                    rows={2}
+                    value={footerNote}
+                    onChange={(e) => setFooterNote(e.target.value)}
+                  />
+                  <p className="text-xs text-muted-foreground">{t('invoice.footerHint')}</p>
+                </div>
               </div>
               </div>
 
 
               <Separator />
               <Separator />
 
 
-              <div className="space-y-2">
-                <Label htmlFor="footerNote">{t('invoice.customFooter')}</Label>
-                <Textarea
-                  id="footerNote"
-                  placeholder={t('invoice.footerPlaceholder')}
-                  rows={2}
-                  value={footerNote}
-                  onChange={(e) => setFooterNote(e.target.value)}
-                />
-                <p className="text-xs text-muted-foreground">{t('invoice.footerHint')}</p>
+              <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>
               </div>
 
 
               <Separator />
               <Separator />

+ 17 - 2
src/features/quotes/Actions/quoteActions.ts

@@ -11,6 +11,18 @@ import { reconcileInventoryForParts } from "@/features/inventory/Lib/reconcileSt
 import { copyFile, mkdir } from "fs/promises";
 import { copyFile, mkdir } from "fs/promises";
 import path from "path";
 import path from "path";
 
 
+/**
+ * Default valid-until for new quotes: today plus workshop.quoteValidDays
+ * (30 when unset). An explicit 0 or negative disables the prefill.
+ */
+function defaultValidUntil(validDaysSetting: string | undefined): Date | undefined {
+  const days = validDaysSetting === undefined ? 30 : Number.parseInt(validDaysSetting, 10);
+  if (!Number.isFinite(days) || days <= 0) return undefined;
+  const d = new Date();
+  d.setDate(d.getDate() + days);
+  return d;
+}
+
 export async function getQuotesPaginated(params: {
 export async function getQuotesPaginated(params: {
   page?: number;
   page?: number;
   pageSize?: number;
   pageSize?: number;
@@ -128,6 +140,7 @@ export async function createQuote(input: unknown) {
         key: {
         key: {
           in: [
           in: [
             "workshop.quotePrefix",
             "workshop.quotePrefix",
+            "workshop.quoteValidDays",
             "workshop.defaultTaxRate",
             "workshop.defaultTaxRate",
             "workshop.taxEnabled",
             "workshop.taxEnabled",
             "workshop.taxInclusive",
             "workshop.taxInclusive",
@@ -137,7 +150,7 @@ export async function createQuote(input: unknown) {
     });
     });
     const settingsMap: Record<string, string> = {};
     const settingsMap: Record<string, string> = {};
     for (const s of settings) settingsMap[s.key] = s.value;
     for (const s of settings) settingsMap[s.key] = s.value;
-    const prefix = settingsMap["workshop.quotePrefix"] || "QT-";
+    const prefix = resolveInvoicePrefix(settingsMap["workshop.quotePrefix"] ?? "QT-");
 
 
     // Apply default tax rate from settings when the caller hasn't set one.
     // Apply default tax rate from settings when the caller hasn't set one.
     // All current call sites send taxRate: 0 at creation, so 0 means "unset".
     // All current call sites send taxRate: 0 at creation, so 0 means "unset".
@@ -183,7 +196,9 @@ export async function createQuote(input: unknown) {
           organizationId,
           organizationId,
           taxRate: quoteData.taxRate > 0 ? quoteData.taxRate : defaultTaxRate,
           taxRate: quoteData.taxRate > 0 ? quoteData.taxRate : defaultTaxRate,
           taxInclusive,
           taxInclusive,
-          validUntil: quoteData.validUntil ? new Date(quoteData.validUntil) : undefined,
+          validUntil: quoteData.validUntil
+            ? new Date(quoteData.validUntil)
+            : defaultValidUntil(settingsMap["workshop.quoteValidDays"]),
           discountType: quoteData.discountType === "none" ? null : quoteData.discountType,
           discountType: quoteData.discountType === "none" ? null : quoteData.discountType,
         },
         },
       });
       });