Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TRANSLATIONS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Translation Status

Last updated: 2026-10-07 09:57:47
Last updated: 2026-10-09 09:28:47

Reference locale (en): **346 keys**

Expand Down
8 changes: 2 additions & 6 deletions frontend/src/lib/components/InvoiceEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Plus, GripVertical } from "lucide-svelte";
import { goto } from "$app/navigation";
import { generateId } from "$lib/utils/id";
import ProductCombobox from "$lib/components/ProductCombobox.svelte";

let { data, invoice = null, formId = "invoice-editor-form" } = $props();
let initInvoice = untrack(() => invoice);
Expand Down Expand Up @@ -363,12 +364,7 @@
</button>

{#if products.length > 0}
<select class="select select-bordered w-44 max-w-xs shrink-0" bind:value={item.productId} onchange={(e) => applyProductSelection(item, (e.currentTarget as HTMLSelectElement).value)}>
<option value="">{t("Select product")}</option>
{#each products as p (p.id)}
<option value={p.id}>{p.name}{p.sku ? ` (${p.sku})` : ""}</option>
{/each}
</select>
<ProductCombobox id="product-{item.id}" class="w-44 max-w-xs shrink-0" {products} value={item.productId} onchange={(productId: string) => applyProductSelection(item, productId)} />
{/if}

<input class="input input-bordered w-full min-w-0" bind:value={item.description} placeholder={t("Description")} required />
Expand Down
169 changes: 169 additions & 0 deletions frontend/src/lib/components/ProductCombobox.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<script lang="ts">
import { getContext, tick } from "svelte";
import { ChevronsUpDown, X } from "lucide-svelte";

let { products = [], value = "", id = "product-combobox", class: className = "", onchange = (_productId: string) => {} } = $props();
let t = getContext("i18n") as (key: string) => string;

let input: HTMLInputElement | undefined = $state();
let listEl: HTMLUListElement | undefined = $state();
let open = $state(false);
let query = $state("");
let activeIndex = $state(0);

let selected = $derived(products.find((p: any) => p.id === value) || null);

function label(p: any) {
return p.sku ? `${p.name} (${p.sku})` : p.name;
}

// Matches every whitespace-separated term against name, SKU, description and
// category, so "web hour" finds "Web development" billed per hour.
let results = $derived.by(() => {
const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
if (!terms.length) return products;
return products.filter((p: any) => {
const haystack = [p.name, p.sku, p.description, p.category, p.unit]
.filter((v) => v !== undefined && v !== null)
.join(" ")
.toLowerCase();
return terms.every((term) => haystack.includes(term));
});
});

// Show the selected product whenever the user isn't actively searching.
$effect(() => {
if (!open) query = selected ? label(selected) : "";
});

function openList() {
if (open) return;
open = true;
query = "";
activeIndex = Math.max(
0,
results.findIndex((p: any) => p.id === value),
);
scrollActiveIntoView();
}

function close() {
open = false;
}

function select(product: any) {
// Re-picking the current product must not overwrite manual edits to the line.
if (product.id !== value) onchange(product.id);
close();
}

function clear() {
onchange("");
query = "";
input?.focus();
}

async function scrollActiveIntoView() {
await tick();
listEl?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)?.scrollIntoView({ block: "nearest" });
}

function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!open) return openList();
activeIndex = Math.min(activeIndex + 1, results.length - 1);
scrollActiveIntoView();
break;
case "ArrowUp":
e.preventDefault();
if (!open) return openList();
activeIndex = Math.max(activeIndex - 1, 0);
scrollActiveIntoView();
break;
case "Enter":
if (open) {
e.preventDefault();
if (results[activeIndex]) select(results[activeIndex]);
}
break;
case "Escape":
if (open) {
e.preventDefault();
close();
}
break;
case "Tab":
close();
break;
}
}
</script>

<div class="relative {className}">
<div class="input input-bordered flex w-full items-center gap-2">
<input
bind:this={input}
{id}
type="text"
class="min-w-0 grow"
role="combobox"
aria-label={t("Product")}
aria-expanded={open}
aria-controls="{id}-listbox"
aria-autocomplete="list"
aria-activedescendant={open && results[activeIndex] ? `${id}-option-${activeIndex}` : undefined}
autocomplete="off"
placeholder={selected ? label(selected) : t("Select product")}
bind:value={query}
onfocus={openList}
onclick={openList}
onblur={close}
oninput={() => {
open = true;
activeIndex = 0;
}}
onkeydown={handleKeydown}
/>
{#if value}
<button type="button" class="opacity-50 hover:opacity-100" tabindex="-1" aria-label={t("Clear")} onmousedown={(e) => e.preventDefault()} onclick={clear}>
<X size={16} />
</button>
{:else}
<ChevronsUpDown size={16} class="shrink-0 opacity-50" />
{/if}
</div>

{#if open}
<!-- min-w keeps the list readable even though the trigger sits in a narrow table column -->
<ul
bind:this={listEl}
id="{id}-listbox"
role="listbox"
class="menu bg-base-100 rounded-box border-base-300 absolute z-20 mt-1 max-h-72 w-full min-w-72 flex-nowrap overflow-y-auto border p-1 shadow-lg"
>
{#each results as p, i (p.id)}
<li role="option" id="{id}-option-{i}" data-index={i} aria-selected={p.id === value}>
<!-- mousedown is prevented so the input keeps focus and doesn't close the list before the click lands -->
<button
type="button"
tabindex="-1"
class="flex flex-col items-start gap-0"
class:menu-active={i === activeIndex}
onmousedown={(e) => e.preventDefault()}
onmouseenter={() => (activeIndex = i)}
onclick={() => select(p)}
>
<span class:font-semibold={p.id === value}>{p.name}</span>
<span class="text-xs opacity-60">
{[p.sku, `${Number(p.unitPrice ?? p.unit_price ?? 0).toFixed(2)}${p.unit ? ` / ${p.unit}` : ""}`].filter(Boolean).join(" · ")}
</span>
</button>
</li>
{:else}
<li class="px-3 py-2 text-sm opacity-60">{t("No products found")}</li>
{/each}
</ul>
{/if}
</div>
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"Category Name": "Kategoriename",
"City": "Stadt",
"City + Postal Code": "Stadt + Postleitzahl",
"Clear": "Leeren",
"Clear All": "Alle abwählen",
"Company": "Unternehmen",
"Company Address": "Unternehmensadresse",
Expand Down Expand Up @@ -181,6 +182,7 @@
"No description": "Keine Beschreibung",
"No invoices found": "Keine Rechnungen gefunden",
"No invoices match this filter": "Keine Rechnungen entsprechen diesem Filter",
"No products found": "Keine Produkte gefunden",
"No products yet.": "Noch keine Produkte.",
"No tax definitions yet.": "Noch keine Steuerdefinitionen.",
"No templates found.": "Keine Vorlagen gefunden.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"Category Name": "Category Name",
"City": "City",
"City + Postal Code": "City + Postal Code",
"Clear": "Clear",
"Clear All": "Clear All",
"Company": "Company",
"Company Address": "Company Address",
Expand Down Expand Up @@ -181,6 +182,7 @@
"No description": "No description",
"No invoices found": "No invoices found",
"No invoices match this filter": "No invoices match this filter",
"No products found": "No products found",
"No products yet.": "No products yet.",
"No tax definitions yet.": "No tax definitions yet.",
"No templates found.": "No templates found.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/locales/es-ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"Category Name": "Nombre de la categoría",
"City": "Ciudad",
"City + Postal Code": "Ciudad + código postal",
"Clear": "Borrar",
"Clear All": "Limpiar todo",
"Company": "Empresa",
"Company Address": "Dirección de la empresa",
Expand Down Expand Up @@ -181,6 +182,7 @@
"No description": "Sin descripción",
"No invoices found": "No se encontraron facturas",
"No invoices match this filter": "Ninguna factura coincide con este filtro",
"No products found": "No se encontraron productos",
"No products yet.": "Aún no hay productos registrados.",
"No tax definitions yet.": "Aún no hay impuestos definidos.",
"No templates found.": "No se encontraron plantillas.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/locales/es-co.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"Category Name": "Nombre de la categoría",
"City": "Ciudad",
"City + Postal Code": "Ciudad + código postal",
"Clear": "Borrar",
"Clear All": "Limpiar todo",
"Company": "Empresa",
"Company Address": "Dirección de la empresa",
Expand Down Expand Up @@ -181,6 +182,7 @@
"No description": "Sin descripción",
"No invoices found": "No se encontraron facturas",
"No invoices match this filter": "Ninguna factura coincide con este filtro",
"No products found": "No se encontraron productos",
"No products yet.": "Aún no hay productos registrados.",
"No tax definitions yet.": "Aún no hay impuestos definidos.",
"No templates found.": "No se encontraron plantillas.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"Category Name": "Categorienaam",
"City": "Plaats",
"City + Postal Code": "Plaats + postcode",
"Clear": "Wissen",
"Clear All": "Alles wissen",
"Company": "Bedrijf",
"Company Address": "Adres",
Expand Down Expand Up @@ -181,6 +182,7 @@
"No description": "Geen omschrijving",
"No invoices found": "Geen facturen gevonden",
"No invoices match this filter": "Geen facturen voldoen aan dit filter",
"No products found": "Geen producten gevonden",
"No products yet.": "Nog geen producten.",
"No tax definitions yet.": "Nog geen belastingdefinities.",
"No templates found.": "Geen templates gevonden.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/locales/pt-br.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"Category Name": "Nome da categoria",
"City": "Cidade",
"City + Postal Code": "Cidade + CEP",
"Clear": "Limpar",
"Clear All": "Limpar tudo",
"Company": "Empresa",
"Company Address": "Endereço da Empresa",
Expand Down Expand Up @@ -181,6 +182,7 @@
"No description": "Sem descrição",
"No invoices found": "Nenhuma fatura encontrada",
"No invoices match this filter": "Nenhuma fatura corresponde a este filtro",
"No products found": "Nenhum produto encontrado",
"No products yet.": "Nenhum produto ainda.",
"No tax definitions yet.": "Nenhuma definição de imposto ainda.",
"No templates found.": "Nenhum modelo encontrado.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/i18n/locales/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"Category Name": "Kategori Adı",
"City": "Şehir",
"City + Postal Code": "Şehir + posta kodu",
"Clear": "Temizle",
"Clear All": "Tümünü Temizle",
"Company": "Şirket",
"Company Address": "Şirket Adresi",
Expand Down Expand Up @@ -181,6 +182,7 @@
"No description": "Açıklama yok",
"No invoices found": "Fatura bulunamadı",
"No invoices match this filter": "Bu filtreyle eşleşen fatura yok",
"No products found": "Ürün bulunamadı",
"No products yet.": "Henüz ürün yok.",
"No tax definitions yet.": "Henüz vergi tanımı yok.",
"No templates found.": "Şablon bulunamadı.",
Expand Down
Loading