Explorar el 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 hace 1 mes
padre
commit
43fe2802af

+ 6 - 0
messages/de/settings.json

@@ -381,6 +381,12 @@
     },
     "invoiceNumberFormat": "Rechnungsnummernformat",
     "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",
     "nextInvoiceNumberPlaceholder": "z.B. 94",
     "nextInvoiceNumberHint": "Die nachste Rechnung verwendet diese Nummer (z.B. {example})",

+ 6 - 0
messages/en/settings.json

@@ -381,6 +381,12 @@
     },
     "invoiceNumberFormat": "Invoice Number Format",
     "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",
     "nextInvoiceNumberPlaceholder": "e.g. 94",
     "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",
     "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",
     "nextInvoiceNumberPlaceholder": "Ej. 94",
     "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",
     "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",
     "nextInvoiceNumberPlaceholder": "Ex. 94",
     "nextInvoiceNumberHint": "La prochaine facture utilisera ce numero (ex. {example})",

+ 6 - 0
messages/it/settings.json

@@ -381,6 +381,12 @@
     },
     "invoiceNumberFormat": "Formato numero fattura",
     "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",
     "nextInvoiceNumberPlaceholder": "es. 94",
     "nextInvoiceNumberHint": "La prossima fattura usera questo numero (es. {example})",

+ 6 - 0
messages/lt/settings.json

@@ -381,6 +381,12 @@
     },
     "invoiceNumberFormat": "Sąskaitos numerio formatas",
     "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",
     "nextInvoiceNumberPlaceholder": "pvz., 94",
     "nextInvoiceNumberHint": "Kita sąskaita naudos šį numerį (pvz., {example})",

+ 6 - 0
messages/nb/settings.json

@@ -381,6 +381,12 @@
     },
     "invoiceNumberFormat": "Fakturanummerformat",
     "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",
     "nextInvoiceNumberPlaceholder": "F.eks. 94",
     "nextInvoiceNumberHint": "Neste faktura bruker dette nummeret (f.eks. {example})",

+ 6 - 0
messages/nl/settings.json

@@ -381,6 +381,12 @@
     },
     "invoiceNumberFormat": "Factuurnummerformaat",
     "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",
     "nextInvoiceNumberPlaceholder": "Bijv. 94",
     "nextInvoiceNumberHint": "De volgende factuur gebruikt dit nummer (bijv. {example})",

+ 6 - 0
messages/pl/settings.json

@@ -381,6 +381,12 @@
     },
     "invoiceNumberFormat": "Format numeru faktury",
     "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",
     "nextInvoiceNumberPlaceholder": "Np. 94",
     "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",
     "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",
     "nextInvoiceNumberPlaceholder": "Ex. 94",
     "nextInvoiceNumberHint": "A proxima fatura usara este numero (ex. {example})",

+ 6 - 0
messages/ru/settings.json

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

+ 6 - 0
messages/tr/settings.json

@@ -381,6 +381,12 @@
     },
     "invoiceNumberFormat": "Fatura Numara Formatı",
     "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ı",
     "nextInvoiceNumberPlaceholder": "ör. 94",
     "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(
     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 [footerNote, setFooterNote] = useState(settings[SETTING_KEYS.INVOICE_FOOTER_NOTE] || '')
   const [defaultMarkupPercent, setDefaultMarkupPercent] = useState(
@@ -125,6 +129,8 @@ export function InvoiceSettings({
     setSaving(true)
     await setSettings({
       [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_DUE_DAYS]: dueDays,
       [SETTING_KEYS.INVOICE_FOOTER_NOTE]: footerNote,
@@ -215,7 +221,9 @@ export function InvoiceSettings({
               <CardTitle className="text-lg">{t('invoice.tabs.general')}</CardTitle>
             </CardHeader>
             <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">
                   <Label htmlFor="invoicePrefix">{t('invoice.invoiceNumberFormat')}</Label>
                   <Input
@@ -265,20 +273,60 @@ export function InvoiceSettings({
                   />
                   <p className="text-xs text-muted-foreground">{t('invoice.dueDaysHint')}</p>
                 </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>
 
               <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>
 
               <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 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: {
   page?: number;
   pageSize?: number;
@@ -128,6 +140,7 @@ export async function createQuote(input: unknown) {
         key: {
           in: [
             "workshop.quotePrefix",
+            "workshop.quoteValidDays",
             "workshop.defaultTaxRate",
             "workshop.taxEnabled",
             "workshop.taxInclusive",
@@ -137,7 +150,7 @@ export async function createQuote(input: unknown) {
     });
     const settingsMap: Record<string, string> = {};
     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.
     // All current call sites send taxRate: 0 at creation, so 0 means "unset".
@@ -183,7 +196,9 @@ export async function createQuote(input: unknown) {
           organizationId,
           taxRate: quoteData.taxRate > 0 ? quoteData.taxRate : defaultTaxRate,
           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,
         },
       });