feat: enhance forms with decimal formatting and validation

- Updated CustomersPage.vue to use decimalString for standard discount percent.
- Enhanced IncomingInvoicesPage.vue to format item quantities, unit prices, and tax rates using decimalString.
- Improved ItemsPage.vue with new supplier price management and decimal formatting for prices.
- Modified OrganizationSetupPage.vue to use a dropdown for default tax rates and ensure numeric input for payment days.
- Updated OutgoingInvoicesPage.vue to apply decimal formatting for customer discounts and item details.
- Enhanced PriceImportsPage.vue to include additional fields in the import format.
- Improved PriceRulesPage.vue to use decimal input for markup percentages.
- Updated QuotesPage.vue to apply decimal formatting for customer discounts and item details.
- Enhanced SuppliersPage.vue to use decimal input for standard discount percent.
- Added a new SQL migration to set default unit for items to 'Stck'.
- Introduced format.ts for centralized decimal and currency formatting utilities.
This commit is contained in:
Torsten Schulz (local)
2026-06-03 09:25:10 +02:00
parent 0e539710c0
commit d5b6f39177
22 changed files with 1420 additions and 183 deletions

View File

@@ -35,13 +35,13 @@ async function apiRequest<T>(method: string, path: string, body?: unknown): Prom
});
const text = await response.text();
const data = text ? JSON.parse(text) : {};
const data = text ? parseResponseBody(text) : {};
if (!response.ok) {
return { ok: false, message: data.message ?? `HTTP ${response.status}` };
return { ok: false, message: responseMessage(data, text, response.status) };
}
return { ok: true, data };
return { ok: true, data: data as T };
} catch (error) {
return {
ok: false,
@@ -49,3 +49,20 @@ async function apiRequest<T>(method: string, path: string, body?: unknown): Prom
};
}
}
function parseResponseBody(text: string): Record<string, unknown> {
try {
const parsed = JSON.parse(text);
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : { message: String(parsed) };
} catch {
return { message: text };
}
}
function responseMessage(data: Record<string, unknown>, text: string, status: number): string {
for (const key of ["message", "detail"]) {
const value = data[key];
if (typeof value === "string" && value.trim()) return value;
}
return text || `HTTP ${status}`;
}

View File

@@ -12,10 +12,11 @@ const props = defineProps<{
const emit = defineEmits<{
"update:modelValue": [value: string | null];
change: [];
change: [value: string | null];
}>();
const query = ref("");
const open = ref(false);
const selected = computed(() => props.options.find((option) => option.id === props.modelValue));
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
@@ -33,7 +34,15 @@ const filteredOptions = computed(() => {
function select(value: string | null) {
emit("update:modelValue", value);
emit("change");
emit("change", value);
query.value = "";
open.value = false;
}
function closeSoon() {
window.setTimeout(() => {
open.value = false;
}, 120);
}
</script>
@@ -44,11 +53,14 @@ function select(value: string | null) {
type="search"
:placeholder="placeholder ?? 'Nach Nummer oder Name suchen'"
:required="required && !modelValue"
@focus="open = true"
@input="open = true"
@blur="closeSoon"
/>
<div v-if="selected" class="search-select-current">
Ausgewählt: <strong>{{ selected.number ? `${selected.number} - ${selected.name}` : selected.name }}</strong>
</div>
<div class="search-select-options">
<div v-if="open" class="search-select-options">
<button
v-if="allowEmpty"
type="button"

View File

@@ -0,0 +1,62 @@
const decimalFormatter = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 4
});
const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 4
});
export const unitOptions = ["Stck", "kg", "g", "L", "mg", "ml", "qm", "m", "cm", "mm"];
export const taxRateOptions = ["0", "7", "19"];
export function formatDecimal(value: string | number | null | undefined): string {
const number = toNumber(value);
return number === null ? "" : decimalFormatter.format(number);
}
export function formatEuro(value: string | number | null | undefined): string {
const number = toNumber(value);
return number === null ? "-" : currencyFormatter.format(number);
}
export function normalizeDecimal(value: string | number | null | undefined): string | null {
if (value === null || value === undefined || value === "") return null;
const text = String(value).trim();
if (!text) return null;
const compact = text.replace(/\s/g, "");
if (compact.includes(",")) return compact.replace(/\./g, "").replace(",", ".");
const dotCount = (compact.match(/\./g) ?? []).length;
if (dotCount > 1) return compact.replace(/\./g, "");
if (/^-?\d{1,3}\.\d{3}$/.test(compact)) return compact.replace(".", "");
return compact;
}
export function formatDecimalInput<T extends Record<string, unknown>>(target: T, key: keyof T) {
const normalized = normalizeDecimal(target[key] as string | number | null | undefined);
target[key] = (normalized === null ? null : formatDecimal(normalized)) as T[keyof T];
}
export function decimalString(value: string | number | null | undefined, fallback = "0"): string {
return normalizeDecimal(value) ?? fallback;
}
export function normalizeTaxRate(value: string | number | null | undefined, fallback = "19"): string {
const normalized = normalizeDecimal(value);
if (normalized === null) return fallback;
const number = Number(normalized);
if (!Number.isFinite(number)) return normalized;
const compact = String(number);
return taxRateOptions.includes(compact) ? compact : normalized;
}
function toNumber(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === "") return null;
const normalized = normalizeDecimal(value);
if (normalized === null) return null;
const number = Number(normalized);
return Number.isFinite(number) ? number : null;
}

View File

@@ -422,10 +422,58 @@ code {
border-radius: 8px;
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(120px, 1fr));
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
padding: 12px;
}
.quote-line:has(.position-summary) {
align-items: center;
gap: 10px;
}
.position-table-header {
align-items: center;
color: #65757b;
display: grid;
font-size: 12px;
font-weight: 700;
gap: 10px;
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
padding: 0 12px;
text-transform: uppercase;
}
.position-table-header.compact {
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
}
.position-summary {
align-items: center;
display: grid;
gap: 10px;
grid-column: span 5;
grid-template-columns: subgrid;
}
.position-summary.compact {
grid-column: span 5;
}
.position-summary span {
color: #435258;
font-size: 14px;
}
.position-summary strong {
overflow-wrap: anywhere;
}
.position-actions {
display: flex;
gap: 10px;
grid-column: 1 / -1;
}
.form-panel {
max-width: 720px;
}
@@ -566,22 +614,31 @@ select[multiple] {
.search-select {
display: grid;
gap: 8px;
gap: 6px;
position: relative;
}
.search-select-current {
color: #435258;
font-size: 13px;
line-height: 1.25;
}
.search-select-options {
background: #ffffff;
border: 1px solid #dbe3e6;
border-radius: 6px;
box-shadow: 0 14px 28px rgba(15, 37, 45, 0.16);
display: grid;
gap: 4px;
left: 0;
max-height: 220px;
overflow: auto;
padding: 6px;
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 30;
}
.search-option {

View File

@@ -52,11 +52,27 @@ export type Item = {
id: string;
item_number: string;
name: string;
manufacturer_code?: string | null;
unit: string;
tax_rate: string;
default_purchase_price?: string | null;
default_sales_price?: string | null;
status: string;
supplier_prices: ItemSupplierPrice[];
};
export type ItemSupplierPrice = {
id?: string;
supplier_id: string;
supplier_number?: string;
supplier_name?: string;
external_item_number: string;
purchase_price: string;
currency: string;
is_preferred: boolean;
valid_from?: string | null;
valid_until?: string | null;
source?: string;
};
export type ItemPriceHistory = {
@@ -108,7 +124,9 @@ export type NumberRange = {
export type QuoteItem = {
id?: string;
line_number?: number;
item_id: string;
line_kind?: "item" | "activity";
item_id?: string | null;
activity_id?: string | null;
description: string;
quantity: string;
unit_price: string;
@@ -171,10 +189,14 @@ export type PriceListImportRow = {
row_number: number;
item_number: string;
name: string;
manufacturer_code?: string | null;
unit: string;
tax_rate: string;
purchase_price?: string | null;
sales_price?: string | null;
supplier_number?: string | null;
supplier_item_number?: string | null;
currency?: string | null;
action: string;
error?: string | null;
};

View File

@@ -6,7 +6,7 @@ import PageHeader from "../components/PageHeader.vue";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Activity } from "../types";
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "task", title: "", body: "", status: "open", priority: "normal", due_at: null as string | null });
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "task", title: "", body: "", status: "active", priority: "normal", due_at: null as string | null });
const records = ref<Activity[]>([]); const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
const filteredRecords = computed(() =>
records.value.filter((record) => matchesObjectSearch(record.activity_number, record.title, search.value))
@@ -15,7 +15,7 @@ async function load() { const r = await apiGet<Activity[]>("/api/v1/activities")
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.activity_number = await reserveNextNumber("activities"); }
function select(record: Activity) { selectedId.value = record.id; Object.assign(form, record); }
async function save() { const r = selectedId.value ? await apiPut<Activity>(`/api/v1/activities/${selectedId.value}`, form) : await apiPost<Activity>("/api/v1/activities", form); status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await load(); } }
async function cancel() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität storniert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) await load(); }
async function deactivate() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität deaktiviert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { form.status = "inactive"; await load(); } }
onMounted(load); watch(() => liveUpdateState.revision, load);
</script>
<template>
@@ -31,9 +31,9 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
<label class="field"><span>Aktivitätsnummer</span><div class="readonly-value">{{ form.activity_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Typ</span><select v-model="form.activity_type"><option value="task">Aufgabe</option><option value="follow_up">Wiedervorlage</option><option value="phone_note">Telefonnotiz</option><option value="internal_note">Interne Notiz</option></select></label>
<label class="field"><span>Titel</span><input v-model="form.title" required /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="open">Offen</option><option value="in_progress">In Bearbeitung</option><option value="done">Erledigt</option><option value="cancelled">Storniert</option></select></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Nicht aktiv</option></select></label>
<label class="field"><span>Priorität</span><select v-model="form.priority"><option value="low">Niedrig</option><option value="normal">Normal</option><option value="high">Hoch</option><option value="critical">Kritisch</option></select></label>
<label class="field full-width"><span>Beschreibung</span><textarea v-model="form.body" rows="5" /></label>
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="cancel">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import { onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm } from "../types";
@@ -53,9 +54,13 @@ function select(term: CashDiscountTerm) {
}
async function save() {
const payload = {
...form,
discount_percent: decimalString(form.discount_percent)
};
const result = selectedId.value
? await apiPut<CashDiscountTerm>(`/api/v1/cash-discount-terms/${selectedId.value}`, form)
: await apiPost<CashDiscountTerm>("/api/v1/cash-discount-terms", form);
? await apiPut<CashDiscountTerm>(`/api/v1/cash-discount-terms/${selectedId.value}`, payload)
: await apiPost<CashDiscountTerm>("/api/v1/cash-discount-terms", payload);
status.value = result.ok ? "Skonto-Regel gespeichert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); }
@@ -74,7 +79,7 @@ watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Skonto" description="Zahlungsbedingungen für Kunden, Lieferanten und Belege." />
<PageHeader title="Skonto-Regeln" description="Zahlungsbedingungen für Kunden, Lieferanten und Belege." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
@@ -90,7 +95,7 @@ watch(() => liveUpdateState.revision, load);
<div class="form-grid">
<label class="field"><span>Code</span><input v-model="form.code" required /></label>
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
<label class="field"><span>Skonto %</span><input v-model="form.discount_percent" type="number" min="0" max="100" step="0.01" required /></label>
<label class="field"><span>Skonto %</span><input v-model="form.discount_percent" inputmode="decimal" required /></label>
<label class="field"><span>Skontofrist Tage</span><input v-model="form.discount_days" type="number" min="0" required /></label>
<label class="field"><span>Nettoziel Tage</span><input v-model="form.net_days" type="number" min="0" /></label>
<label class="field"><span>Gültig ab</span><input v-model="form.valid_from" type="date" /></label>

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm, Customer } from "../types";
@@ -73,9 +74,13 @@ function selectCustomer(customer: Customer) {
async function save() {
pending.value = true;
const payload = {
...form,
standard_discount_percent: decimalString(form.standard_discount_percent)
};
const result = selectedId.value
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, form)
: await apiPost<Customer>("/api/v1/customers", form);
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, payload)
: await apiPost<Customer>("/api/v1/customers", payload);
pending.value = false;
if (!result.ok) {
status.value = result.message;
@@ -145,7 +150,7 @@ watch(
<option value="blocked">Gesperrt</option>
</select>
</label>
<label class="field"><span>Standardrabatt %</span><input v-model="form.standard_discount_percent" type="number" min="0" max="100" step="0.01" required /></label>
<label class="field"><span>Standardrabatt %</span><input v-model="form.standard_discount_percent" inputmode="decimal" required /></label>
<label class="field">
<span>Skonto-Regel</span>
<select v-model="form.cash_discount_term_id">

View File

@@ -4,6 +4,7 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { IncomingInvoice, IncomingInvoiceItem, Item, Supplier } from "../types";
@@ -12,6 +13,7 @@ const emptyItem = (): IncomingInvoiceItem => ({ item_id: null, description: "",
const emptyForm = () => ({ invoice_number: "", supplier_id: "", status: "received", cash_discount_term_id: null as string | null, invoice_date: null as string | null, due_at: null as string | null, items: [emptyItem()] });
const invoices = ref<IncomingInvoice[]>([]); const suppliers = ref<Supplier[]>([]); const items = ref<Item[]>([]);
const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
const editingLineIndex = ref(0);
const supplierName = (supplierId: string) => suppliers.value.find((supplier) => supplier.id === supplierId)?.name ?? supplierId;
const filteredInvoices = computed(() => {
const needle = search.value.trim().toLowerCase();
@@ -21,11 +23,34 @@ const filteredInvoices = computed(() => {
);
});
async function load() { const [ir, sr, itemr] = await Promise.all([apiGet<IncomingInvoice[]>("/api/v1/incoming-invoices"), apiGet<Supplier[]>("/api/v1/suppliers"), apiGet<Item[]>("/api/v1/items")]); if (ir.ok) invoices.value = ir.data; else { status.value = ir.message; kind.value = "error"; } if (sr.ok) suppliers.value = sr.data.filter((supplier) => supplier.status === "active"); if (itemr.ok) items.value = itemr.data.filter((item) => item.status === "active"); }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.invoice_number = (await reserveNextNumber("incoming_invoices")) ?? ""; }
function select(invoice: IncomingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item })) }); }
function addLine() { form.items.push(emptyItem()); }
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); }
async function save() { const result = selectedId.value ? await apiPut<IncomingInvoice>(`/api/v1/incoming-invoices/${selectedId.value}`, form) : await apiPost<IncomingInvoice>("/api/v1/incoming-invoices", form); status.value = result.ok ? "Eingangsrechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); } }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; form.invoice_number = (await reserveNextNumber("incoming_invoices")) ?? ""; }
function select(invoice: IncomingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item, tax_rate: normalizeTaxRate(item.tax_rate) })) }); editingLineIndex.value = 0; }
function addLine() { form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1); }
function itemName(itemId: string | null | undefined) {
if (!itemId) return "Kein Artikel gewählt";
const item = items.value.find((record) => record.id === itemId);
return item ? `${item.item_number} - ${item.name}` : itemId;
}
function lineTotal(line: IncomingInvoiceItem) {
const quantity = Number(decimalString(line.quantity, "0"));
const unitPrice = Number(decimalString(line.unit_price, "0"));
const total = quantity * unitPrice;
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
}
async function save() {
const payload = {
...form,
items: form.items.map((item) => ({
...item,
quantity: decimalString(item.quantity, "1"),
unit_price: decimalString(item.unit_price),
tax_rate: normalizeTaxRate(item.tax_rate)
}))
};
const result = selectedId.value ? await apiPut<IncomingInvoice>(`/api/v1/incoming-invoices/${selectedId.value}`, payload) : await apiPost<IncomingInvoice>("/api/v1/incoming-invoices", payload);
status.value = result.ok ? "Eingangsrechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function cancelInvoice() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/incoming-invoices/${selectedId.value}`); status.value = result.ok ? "Eingangsrechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
onMounted(load); watch(() => liveUpdateState.revision, load);
</script>
@@ -33,6 +58,6 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
<PageHeader title="Eingangsrechnungen" description="Lieferantenrechnungen mit Skonto-Bezug und Positionen." />
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Eingangsrechnungen</h2><button type="button" @click="createNew">Neu</button></div><label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Lieferant oder Status" /></label><button v-for="invoice in filteredInvoices" :key="invoice.id" type="button" class="list-row" :class="{ selected: selectedId === invoice.id }" @click="select(invoice)"><strong>{{ invoice.invoice_number }}</strong><span>{{ supplierName(invoice.supplier_id) }}</span><small>{{ invoice.status }}</small></button><p v-if="invoices.length === 0" class="empty">Keine Eingangsrechnungen vorhanden.</p><p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p></section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"><label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label><label class="field"><span>Lieferant</span><SearchSelect v-model="form.supplier_id" :options="suppliers.map((supplier) => ({ id: supplier.id, number: supplier.supplier_number, name: supplier.name }))" placeholder="Lieferanten-Nr. oder Name suchen" required /></label><label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="received">Erhalten</option><option value="approved">Freigegeben</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label><label class="field"><span>Datum</span><input v-model="form.invoice_date" type="date" /></label><label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label></div>
<div class="sub-panel"><div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div><div v-for="(line, index) in form.items" :key="index" class="quote-line"><label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" allow-empty empty-label="Kein Artikel" /></label><label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" /></label><label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label></div></div>
<div class="sub-panel"><div class="section-title"><h2>Positionen</h2></div><div class="position-table-header compact"><span>Position</span><span>Menge</span><span>Einzelpreis</span><span>Gesamtpreis</span><span>Steuer</span><span>Aktion</span></div><div v-for="(line, index) in form.items" :key="index" class="quote-line"><template v-if="editingLineIndex !== index"><div class="position-summary compact"><strong>{{ itemName(line.item_id) }}</strong><span>{{ line.quantity }}</span><span>{{ line.unit_price }}</span><span>{{ lineTotal(line) }}</span><span>{{ line.tax_rate }} %</span></div><button type="button" class="secondary" @click="editingLineIndex = index">Bearbeiten</button></template><template v-else><label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" allow-empty empty-label="Kein Artikel" /></label><label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" /></label><label class="field"><span>Steuer %</span><select v-model="line.tax_rate"><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label><label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label><div class="position-actions"><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><button type="button" @click="addLine">Position hinzufügen</button></div></template></div></div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section></div>
</template>

View File

@@ -3,33 +3,89 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { formatDecimal, formatDecimalInput, formatEuro, normalizeDecimal, normalizeTaxRate, taxRateOptions, unitOptions } from "../format";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Item, ItemPriceHistory } from "../types";
import type { Item, ItemPriceHistory, ItemSupplierPrice, Supplier } from "../types";
const emptyForm = () => ({ item_number: "", name: "", unit: "Stk", tax_rate: "19", default_purchase_price: null as string | null, default_sales_price: null as string | null, status: "active" });
const emptySupplierPrice = (): ItemSupplierPrice => ({ supplier_id: "", external_item_number: "", purchase_price: "0,00", currency: "EUR", is_preferred: false, valid_from: null, valid_until: null });
const emptyForm = () => ({ item_number: "", name: "", manufacturer_code: null as string | null, unit: "Stck", tax_rate: "19", default_purchase_price: null as string | null, default_sales_price: null as string | null, status: "active", supplier_prices: [] as ItemSupplierPrice[] });
const items = ref<Item[]>([]);
const suppliers = ref<Supplier[]>([]);
const priceHistory = ref<ItemPriceHistory[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
const search = ref("");
const sortMode = ref<"name_desc" | "number_desc">("name_desc");
const filteredItems = computed(() =>
items.value.filter((item) => matchesObjectSearch(item.item_number, item.name, search.value))
items.value
.filter((item) => matchesObjectSearch(item.item_number, item.name, search.value))
.sort((left, right) => {
if (sortMode.value === "number_desc") {
return right.item_number.localeCompare(left.item_number, "de", { numeric: true, sensitivity: "base" });
}
return right.name.localeCompare(left.name, "de", { numeric: true, sensitivity: "base" });
})
);
async function load() { const r = await apiGet<Item[]>("/api/v1/items"); if (r.ok) items.value = r.data; else { status.value = r.message; kind.value = "error"; } }
async function load() {
const [itemResult, supplierResult] = await Promise.all([apiGet<Item[]>("/api/v1/items"), apiGet<Supplier[]>("/api/v1/suppliers")]);
if (itemResult.ok) items.value = itemResult.data; else { status.value = itemResult.message; kind.value = "error"; }
if (supplierResult.ok) suppliers.value = supplierResult.data.filter((supplier) => supplier.status === "active");
}
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.item_number = (await reserveNextNumber("items")) ?? ""; priceHistory.value = []; }
async function select(item: Item) { selectedId.value = item.id; Object.assign(form, item); await loadPriceHistory(item.id); }
async function select(item: Item) {
selectedId.value = item.id;
Object.assign(form, displayItem(item));
await loadPriceHistory(item.id);
}
async function loadPriceHistory(itemId: string) {
const result = await apiGet<ItemPriceHistory[]>(`/api/v1/items/${itemId}/prices`);
if (result.ok) priceHistory.value = result.data;
}
async function save() {
const r = selectedId.value ? await apiPut<Item>(`/api/v1/items/${selectedId.value}`, form) : await apiPost<Item>("/api/v1/items", form);
status.value = r.ok ? "Artikel gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await Promise.all([load(), loadPriceHistory(r.data.id)]); }
const payload = apiItemPayload();
const r = selectedId.value ? await apiPut<Item>(`/api/v1/items/${selectedId.value}`, payload) : await apiPost<Item>("/api/v1/items", payload);
status.value = r.ok ? "Artikel gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; Object.assign(form, displayItem(r.data)); await Promise.all([load(), loadPriceHistory(r.data.id)]); }
}
async function deactivate() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/items/${selectedId.value}`); status.value = r.ok ? "Artikel deaktiviert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) await load(); }
function addSupplierPrice() { form.supplier_prices.push(emptySupplierPrice()); }
function removeSupplierPrice(index: number) { form.supplier_prices.splice(index, 1); }
function displayItem(item: Item) {
return {
...item,
tax_rate: normalizeTaxRate(item.tax_rate),
default_purchase_price: formatDecimal(item.default_purchase_price),
default_sales_price: formatDecimal(item.default_sales_price),
supplier_prices: item.supplier_prices.map((price) => ({ ...price, purchase_price: formatDecimal(price.purchase_price) }))
};
}
function apiItemPayload() {
return {
...form,
tax_rate: normalizeTaxRate(form.tax_rate),
default_purchase_price: normalizeDecimal(form.default_purchase_price),
default_sales_price: normalizeDecimal(form.default_sales_price),
supplier_prices: form.supplier_prices.map((price) => ({
...price,
purchase_price: normalizeDecimal(price.purchase_price) ?? "0",
currency: price.currency.trim().toUpperCase()
}))
};
}
function selectedUnitValue() {
return unitOptions.includes(form.unit) ? form.unit : "__custom";
}
function selectUnit(event: Event) {
const value = (event.target as HTMLSelectElement).value;
if (value === "__custom") {
if (unitOptions.includes(form.unit)) form.unit = "";
} else {
form.unit = value;
}
}
onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(); if (selectedId.value) await loadPriceHistory(selectedId.value); });
</script>
<template>
@@ -38,6 +94,7 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
<section class="panel list-panel">
<div class="section-title"><h2>Artikel</h2><button type="button" @click="createNew">Neu</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Artikelnummer oder Bezeichnung" /></label>
<label class="field list-search"><span>Sortierung</span><select v-model="sortMode"><option value="name_desc">Artikelname absteigend</option><option value="number_desc">Artikelnummer absteigend</option></select></label>
<button v-for="item in filteredItems" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)"><strong>{{ item.item_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small></button>
<p v-if="items.length === 0" class="empty">Keine Artikel vorhanden.</p>
<p v-else-if="filteredItems.length === 0" class="empty">Keine Treffer.</p>
@@ -45,19 +102,34 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
<section class="panel detail-panel">
<form @submit.prevent="save"><div class="form-grid">
<label class="field"><span>Artikelnummer</span><div class="readonly-value">{{ form.item_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Hersteller-Code</span><input v-model="form.manufacturer_code" /></label>
<label class="field"><span>Bezeichnung</span><input v-model="form.name" required /></label>
<label class="field"><span>Einheit</span><input v-model="form.unit" required /></label>
<label class="field"><span>Steuersatz %</span><input v-model="form.tax_rate" type="number" min="0" step="0.01" required /></label>
<label class="field"><span>Einkaufspreis</span><input v-model="form.default_purchase_price" type="number" min="0" step="0.01" /></label>
<label class="field"><span>Verkaufspreis</span><input v-model="form.default_sales_price" type="number" min="0" step="0.01" /></label>
<label class="field"><span>Einheit</span><select :value="selectedUnitValue()" required @change="selectUnit"><option v-for="unit in unitOptions" :key="unit" :value="unit">{{ unit }}</option><option value="__custom">Eigene Einheit</option></select></label>
<label v-if="selectedUnitValue() === '__custom'" class="field"><span>Eigene Einheit</span><input v-model="form.unit" required /></label>
<label class="field"><span>Steuersatz %</span><select v-model="form.tax_rate" required><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
<label class="field"><span>Einkaufspreis</span><input v-model="form.default_purchase_price" inputmode="decimal" @blur="formatDecimalInput(form, 'default_purchase_price')" /></label>
<label class="field"><span>Verkaufspreis</span><input v-model="form.default_sales_price" inputmode="decimal" @blur="formatDecimalInput(form, 'default_sales_price')" /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form>
</div>
<div class="sub-panel">
<div class="section-title"><h2>Lieferantenpreise</h2><button type="button" @click="addSupplierPrice">Lieferant</button></div>
<div v-for="(price, index) in form.supplier_prices" :key="index" class="quote-line">
<label class="field full-width"><span>Lieferant</span><SearchSelect v-model="price.supplier_id" :options="suppliers.map((supplier) => ({ id: supplier.id, number: supplier.supplier_number, name: supplier.name }))" placeholder="Lieferanten-Nr. oder Name suchen" required /></label>
<label class="field"><span>Externe Artikelnr.</span><input v-model="price.external_item_number" required /></label>
<label class="field"><span>Einkaufspreis</span><input v-model="price.purchase_price" inputmode="decimal" required @blur="formatDecimalInput(price, 'purchase_price')" /></label>
<label class="field"><span>Währung</span><input v-model="price.currency" maxlength="3" required /></label>
<label class="check-row"><input v-model="price.is_preferred" type="checkbox" /><span>Bevorzugt</span></label>
<button type="button" class="secondary" @click="removeSupplierPrice(index)">Entfernen</button>
</div>
<p v-if="form.supplier_prices.length === 0" class="empty">Keine Lieferantenpreise vorhanden.</p>
</div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form>
<div v-if="selectedId" class="sub-panel">
<h2>Preishistorie</h2>
<div v-for="entry in priceHistory" :key="entry.id" class="data-row">
<strong>{{ new Date(entry.valid_from).toLocaleString("de-DE") }}</strong>
<span>EK {{ entry.purchase_price ?? "-" }}</span>
<span>VK {{ entry.sales_price ?? "-" }}</span>
<span>EK {{ formatEuro(entry.purchase_price) }}</span>
<span>VK {{ formatEuro(entry.sales_price) }}</span>
<small>{{ entry.source }}</small>
</div>
<p v-if="priceHistory.length === 0" class="empty">Noch keine Preisänderung vorhanden.</p>

View File

@@ -3,6 +3,7 @@ import { onMounted, reactive, ref, watch } from "vue";
import { apiGet, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { decimalString, taxRateOptions } from "../format";
import { liveUpdateState } from "../realtime";
type OrganizationSetupForm = {
@@ -73,7 +74,11 @@ async function submit() {
status.value = "Sende Anfrage...";
statusKind.value = "info";
const result = await apiPut("/api/v1/organizations/current/setup", form);
const result = await apiPut("/api/v1/organizations/current/setup", {
...form,
default_tax_rate: decimalString(form.default_tax_rate, "19"),
default_payment_days: String(form.default_payment_days)
});
pending.value = false;
status.value = result.ok ? "Gespeichert." : result.message;
statusKind.value = result.ok ? "success" : "error";
@@ -102,8 +107,8 @@ watch(
<label class="field"><span>USt-IdNr.</span><input v-model="form.vat_id" type="text" /></label>
<label class="field"><span>E-Mail der Firma</span><input v-model="form.email" type="email" placeholder="info@example.com" required /></label>
<label class="field"><span>Telefon</span><input v-model="form.phone" type="tel" /></label>
<label class="field"><span>Standard-Steuersatz</span><input v-model="form.default_tax_rate" type="number" required /></label>
<label class="field"><span>Standard-Zahlungsziel</span><input v-model="form.default_payment_days" type="number" required /></label>
<label class="field"><span>Standard-Steuersatz</span><select v-model="form.default_tax_rate" required><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
<label class="field"><span>Standard-Zahlungsziel</span><input v-model="form.default_payment_days" inputmode="numeric" required /></label>
</div>
<div class="form-actions">
<button type="submit" :disabled="pending">Firmendaten speichern</button>

View File

@@ -4,21 +4,24 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Customer, Item, OutgoingInvoice, OutgoingInvoiceItem, Quote } from "../types";
import type { Activity, Customer, Item, OutgoingInvoice, OutgoingInvoiceItem, Quote } from "../types";
const emptyItem = (): OutgoingInvoiceItem => ({ item_id: "", description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
const emptyItem = (): OutgoingInvoiceItem => ({ line_kind: "item", item_id: "", activity_id: null, description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
const emptyForm = () => ({ invoice_number: "", customer_id: "", status: "draft", cash_discount_term_id: null as string | null, customer_discount_percent: "0", issued_at: null as string | null, due_at: null as string | null, source_quote_id: null as string | null, items: [emptyItem()] });
const invoices = ref<OutgoingInvoice[]>([]);
const customers = ref<Customer[]>([]);
const items = ref<Item[]>([]);
const activities = ref<Activity[]>([]);
const quotes = ref<Quote[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
const search = ref("");
const editingLineIndex = ref(0);
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
const filteredInvoices = computed(() => {
const needle = search.value.trim().toLowerCase();
@@ -29,25 +32,93 @@ const filteredInvoices = computed(() => {
});
async function load() {
const [invoiceResult, customerResult, itemResult, quoteResult] = await Promise.all([
apiGet<OutgoingInvoice[]>("/api/v1/outgoing-invoices"), apiGet<Customer[]>("/api/v1/customers"), apiGet<Item[]>("/api/v1/items"), apiGet<Quote[]>("/api/v1/quotes")
const [invoiceResult, customerResult, itemResult, quoteResult, activityResult] = await Promise.all([
apiGet<OutgoingInvoice[]>("/api/v1/outgoing-invoices"), apiGet<Customer[]>("/api/v1/customers"), apiGet<Item[]>("/api/v1/items"), apiGet<Quote[]>("/api/v1/quotes"), apiGet<Activity[]>("/api/v1/activities")
]);
if (invoiceResult.ok) invoices.value = invoiceResult.data; else { status.value = invoiceResult.message; kind.value = "error"; }
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
if (quoteResult.ok) quotes.value = quoteResult.data;
if (activityResult.ok) activities.value = activityResult.data.filter((activity) => activity.status === "active");
}
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.invoice_number = (await reserveNextNumber("outgoing_invoices")) ?? ""; }
function select(invoice: OutgoingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item })) }); }
function addLine() { form.items.push(emptyItem()); }
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; form.invoice_number = (await reserveNextNumber("outgoing_invoices")) ?? ""; }
function select(invoice: OutgoingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item, line_kind: item.line_kind ?? "item", tax_rate: normalizeTaxRate(item.tax_rate) })) }); editingLineIndex.value = 0; }
function addLine() { form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1); }
function applyItemDefaults(line: OutgoingInvoiceItem) {
const item = items.value.find((record) => record.id === line.item_id);
if (!item) return;
line.description = item.name; line.unit_price = item.default_sales_price ?? "0"; line.original_unit_price = item.default_sales_price ?? null; line.tax_rate = item.tax_rate;
line.description = item.name; line.unit_price = item.default_sales_price ?? "0"; line.original_unit_price = item.default_sales_price ?? null; line.tax_rate = normalizeTaxRate(item.tax_rate);
}
const lineSourceOptions = computed(() => [
...items.value.map((item) => ({ id: `item:${item.id}`, number: item.item_number, name: item.name })),
...activities.value.map((activity) => ({ id: `activity:${activity.id}`, number: activity.activity_number, name: activity.title }))
]);
function lineSourceValue(line: OutgoingInvoiceItem) {
return line.line_kind === "activity" ? `activity:${line.activity_id ?? ""}` : `item:${line.item_id ?? ""}`;
}
function selectLineSource(line: OutgoingInvoiceItem, value: string | null) {
if (!value) return;
const [kind, id] = value.split(":");
if (kind === "activity") {
const activity = activities.value.find((record) => record.id === id);
if (!activity) return;
line.line_kind = "activity";
line.activity_id = id;
line.item_id = null;
line.description = activity.title;
line.unit_price = "0";
line.original_unit_price = null;
line.tax_rate = "19";
return;
}
const item = items.value.find((record) => record.id === id);
if (!item) return;
line.line_kind = "item";
line.item_id = id;
line.activity_id = null;
applyItemDefaults(line);
}
function applyCustomerDefaults(customerId: string | null) {
if (!customerId) return;
const customer = customers.value.find((record) => record.id === customerId);
if (!customer) return;
form.customer_discount_percent = customer.standard_discount_percent || "0";
form.cash_discount_term_id = customer.cash_discount_term_id ?? null;
}
function lineName(line: OutgoingInvoiceItem) {
if (line.line_kind === "activity") {
const activity = activities.value.find((record) => record.id === line.activity_id);
return activity ? `${activity.activity_number ?? "Aktivität"} - ${activity.title}` : "Keine Aktivität gewählt";
}
if (!line.item_id) return "Kein Artikel gewählt";
const item = items.value.find((record) => record.id === line.item_id);
return item ? `${item.item_number} - ${item.name}` : line.item_id;
}
function lineTotal(line: OutgoingInvoiceItem) {
const quantity = Number(decimalString(line.quantity, "0"));
const unitPrice = Number(decimalString(line.unit_price, "0"));
const discount = Number(decimalString(line.discount_percent, "0"));
const total = quantity * unitPrice * (1 - discount / 100);
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
}
async function save() {
const result = selectedId.value ? await apiPut<OutgoingInvoice>(`/api/v1/outgoing-invoices/${selectedId.value}`, form) : await apiPost<OutgoingInvoice>("/api/v1/outgoing-invoices", form);
const payload = {
...form,
customer_discount_percent: decimalString(form.customer_discount_percent),
items: form.items.map((item) => ({
...item,
line_kind: item.line_kind ?? "item",
item_id: item.line_kind === "activity" ? null : item.item_id,
activity_id: item.line_kind === "activity" ? item.activity_id : null,
quantity: decimalString(item.quantity, "1"),
unit_price: decimalString(item.unit_price),
original_unit_price: item.original_unit_price === null ? null : decimalString(item.original_unit_price),
discount_percent: decimalString(item.discount_percent),
tax_rate: normalizeTaxRate(item.tax_rate)
}))
};
const result = selectedId.value ? await apiPut<OutgoingInvoice>(`/api/v1/outgoing-invoices/${selectedId.value}`, payload) : await apiPost<OutgoingInvoice>("/api/v1/outgoing-invoices", payload);
status.value = result.ok ? "Rechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function finalize() { if (!selectedId.value) return; const result = await apiPost(`/api/v1/outgoing-invoices/${selectedId.value}/finalize`, {}); status.value = result.ok ? "Rechnung abgeschlossen." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
@@ -67,16 +138,29 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
</section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
<label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required /></label>
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required @change="applyCustomerDefaults" /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="finalized">Abgeschlossen</option><option value="sent">Gesendet</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label>
<label class="field"><span>Ausgestellt</span><input v-model="form.issued_at" type="date" /></label>
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
</div><div class="sub-panel"><div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" /></label>
</div><div class="sub-panel"><div class="section-title"><h2>Positionen</h2></div><div class="position-table-header"><span>Position</span><span>Menge</span><span>Einzelpreis</span><span>Gesamtpreis</span><span>Steuer</span><span>Aktion</span></div>
<div v-for="(line, index) in form.items" :key="index" class="quote-line">
<label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" required @change="applyItemDefaults(line)" /></label>
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" /></label><label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label><label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
<template v-if="editingLineIndex !== index">
<div class="position-summary">
<strong>{{ lineName(line) }}</strong>
<span>{{ line.quantity }}</span>
<span>{{ line.unit_price }}</span>
<span>{{ lineTotal(line) }}</span>
<span>{{ line.tax_rate }} %</span>
</div>
<button type="button" class="secondary" @click="editingLineIndex = index">Bearbeiten</button>
</template>
<template v-else>
<label class="field full-width"><span>Artikel oder Aktivität</span><SearchSelect :model-value="lineSourceValue(line)" :options="lineSourceOptions" placeholder="Artikel-/Aktivitäts-Nr. oder Name suchen" required @change="selectLineSource(line, $event)" /></label>
<label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" /></label><label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" inputmode="decimal" /></label><label class="field"><span>Steuer %</span><select v-model="line.tax_rate"><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
<div class="position-actions"><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><button type="button" @click="addLine">Position hinzufügen</button></div>
</template>
</div></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="finalize">Abschließen</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
</div>
</template>

View File

@@ -7,7 +7,7 @@ import type { PriceListImportApplyResponse, PriceListImportPreview } from "../ty
const sourceName = ref("Preisliste.csv");
const delimiter = ref(";");
const content = ref("item_number;name;unit;tax_rate;purchase_price;sales_price\nAR-IMPORT-1;Importartikel;Stk;19;10.00;25.00");
const content = ref("item_number;name;manufacturer_code;unit;tax_rate;purchase_price;sales_price;supplier_number;supplier_item_number;currency\nAR-IMPORT-1;Importartikel;HERST-001;Stck;19,00;10,00;25,00;LI000000001;EXT-4711;EUR");
const preview = ref<PriceListImportPreview | null>(null);
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
@@ -45,7 +45,9 @@ async function applyImport() {
<strong>{{ row.row_number }} {{ row.action }}</strong>
<span>{{ row.item_number }}</span>
<span>{{ row.name }}</span>
<small>{{ row.error ?? `${row.purchase_price ?? "-"} / ${row.sales_price ?? "-"}` }}</small>
<span>{{ row.manufacturer_code ?? "-" }}</span>
<span>{{ row.supplier_number ? `${row.supplier_number} / ${row.supplier_item_number}` : "-" }}</span>
<small>{{ row.error ?? `${row.purchase_price ?? "-"} ${row.currency ?? "EUR"} / ${row.sales_price ?? "-"}` }}</small>
</div>
</section>
</template>

View File

@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import type { PriceRule } from "../types";
const rules = ref<PriceRule[]>([]);
@@ -20,7 +21,11 @@ const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
function payload() {
return { ...form, source_id: form.source_id.trim() || null };
return {
...form,
source_id: form.source_id.trim() || null,
markup_percent: decimalString(form.markup_percent)
};
}
function createNew() {
@@ -86,7 +91,7 @@ onMounted(load);
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
<label class="field"><span>Quellentyp</span><select v-model="form.source_type"><option value="import">Import</option><option value="api">API</option><option value="supplier">Lieferant</option></select></label>
<label class="field"><span>Quell-ID</span><input v-model="form.source_id" placeholder="optional" /></label>
<label class="field"><span>Aufschlag %</span><input v-model="form.markup_percent" type="number" min="-100" max="1000" step="0.0001" required /></label>
<label class="field"><span>Aufschlag %</span><input v-model="form.markup_percent" inputmode="decimal" required /></label>
<label class="field"><span>Rundung</span><select v-model="form.rounding_mode"><option value="none">Keine</option><option value="cent">Cent</option><option value="five_cent">5 Cent</option><option value="ten_cent">10 Cent</option><option value="whole">Ganze Beträge</option></select></label>
<label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
</div>

View File

@@ -4,22 +4,25 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Customer, Item, Quote, QuoteItem } from "../types";
import type { Activity, Customer, Item, Quote, QuoteItem } from "../types";
const emptyItem = (): QuoteItem => ({ item_id: "", description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
const emptyItem = (): QuoteItem => ({ line_kind: "item", item_id: "", activity_id: null, description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
const emptyForm = () => ({ quote_number: "", customer_id: "", status: "draft", valid_until: null as string | null, cash_discount_term_id: null as string | null, customer_discount_percent: "0", notes: "", items: [emptyItem()] });
const quotes = ref<Quote[]>([]);
const customers = ref<Customer[]>([]);
const items = ref<Item[]>([]);
const activities = ref<Activity[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
const selectedQuote = computed(() => quotes.value.find((quote) => quote.id === selectedId.value));
const search = ref("");
const editingLineIndex = ref(0);
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
const filteredQuotes = computed(() => {
const needle = search.value.trim().toLowerCase();
@@ -30,33 +33,39 @@ const filteredQuotes = computed(() => {
});
async function load() {
const [quoteResult, customerResult, itemResult] = await Promise.all([
const [quoteResult, customerResult, itemResult, activityResult] = await Promise.all([
apiGet<Quote[]>("/api/v1/quotes"),
apiGet<Customer[]>("/api/v1/customers"),
apiGet<Item[]>("/api/v1/items")
apiGet<Item[]>("/api/v1/items"),
apiGet<Activity[]>("/api/v1/activities")
]);
if (quoteResult.ok) quotes.value = quoteResult.data; else { status.value = quoteResult.message; kind.value = "error"; }
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
if (activityResult.ok) activities.value = activityResult.data.filter((activity) => activity.status === "active");
}
async function createNew() {
selectedId.value = null;
Object.assign(form, emptyForm());
editingLineIndex.value = 0;
form.quote_number = (await reserveNextNumber("quotes")) ?? "";
}
function select(quote: Quote) {
selectedId.value = quote.id;
Object.assign(form, { ...quote, items: quote.items.map((item) => ({ ...item })) });
Object.assign(form, { ...quote, items: quote.items.map((item) => ({ ...item, line_kind: item.line_kind ?? "item", tax_rate: normalizeTaxRate(item.tax_rate) })) });
editingLineIndex.value = 0;
}
function addLine() {
form.items.push(emptyItem());
editingLineIndex.value = form.items.length - 1;
}
function removeLine(index: number) {
if (form.items.length > 1) form.items.splice(index, 1);
editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1);
}
function applyItemDefaults(line: QuoteItem) {
@@ -65,13 +74,86 @@ function applyItemDefaults(line: QuoteItem) {
line.description = item.name;
line.unit_price = item.default_sales_price ?? "0";
line.original_unit_price = item.default_sales_price ?? null;
line.tax_rate = item.tax_rate;
line.tax_rate = normalizeTaxRate(item.tax_rate);
}
const lineSourceOptions = computed(() => [
...items.value.map((item) => ({ id: `item:${item.id}`, number: item.item_number, name: item.name })),
...activities.value.map((activity) => ({ id: `activity:${activity.id}`, number: activity.activity_number, name: activity.title }))
]);
function lineSourceValue(line: QuoteItem) {
return line.line_kind === "activity" ? `activity:${line.activity_id ?? ""}` : `item:${line.item_id ?? ""}`;
}
function selectLineSource(line: QuoteItem, value: string | null) {
if (!value) return;
const [kind, id] = value.split(":");
if (kind === "activity") {
const activity = activities.value.find((record) => record.id === id);
if (!activity) return;
line.line_kind = "activity";
line.activity_id = id;
line.item_id = null;
line.description = activity.title;
line.unit_price = "0";
line.original_unit_price = null;
line.tax_rate = "19";
return;
}
const item = items.value.find((record) => record.id === id);
if (!item) return;
line.line_kind = "item";
line.item_id = id;
line.activity_id = null;
applyItemDefaults(line);
}
function applyCustomerDefaults(customerId: string | null) {
if (!customerId) return;
const customer = customers.value.find((record) => record.id === customerId);
if (!customer) return;
form.customer_discount_percent = customer.standard_discount_percent || "0";
form.cash_discount_term_id = customer.cash_discount_term_id ?? null;
}
function lineName(line: QuoteItem) {
if (line.line_kind === "activity") {
const activity = activities.value.find((record) => record.id === line.activity_id);
return activity ? `${activity.activity_number ?? "Aktivität"} - ${activity.title}` : "Keine Aktivität gewählt";
}
if (!line.item_id) return "Kein Artikel gewählt";
const item = items.value.find((record) => record.id === line.item_id);
return item ? `${item.item_number} - ${item.name}` : line.item_id;
}
function lineTotal(line: QuoteItem) {
const quantity = Number(decimalString(line.quantity, "0"));
const unitPrice = Number(decimalString(line.unit_price, "0"));
const discount = Number(decimalString(line.discount_percent, "0"));
const total = quantity * unitPrice * (1 - discount / 100);
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
}
async function save() {
const payload = {
...form,
customer_discount_percent: decimalString(form.customer_discount_percent),
items: form.items.map((item) => ({
...item,
line_kind: item.line_kind ?? "item",
item_id: item.line_kind === "activity" ? null : item.item_id,
activity_id: item.line_kind === "activity" ? item.activity_id : null,
quantity: decimalString(item.quantity, "1"),
unit_price: decimalString(item.unit_price),
original_unit_price: item.original_unit_price === null ? null : decimalString(item.original_unit_price),
discount_percent: decimalString(item.discount_percent),
tax_rate: normalizeTaxRate(item.tax_rate)
}))
};
const result = selectedId.value
? await apiPut<Quote>(`/api/v1/quotes/${selectedId.value}`, form)
: await apiPost<Quote>("/api/v1/quotes", form);
? await apiPut<Quote>(`/api/v1/quotes/${selectedId.value}`, payload)
: await apiPost<Quote>("/api/v1/quotes", payload);
status.value = result.ok ? "Angebot gespeichert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); }
@@ -107,22 +189,38 @@ watch(() => liveUpdateState.revision, load);
<form @submit.prevent="save">
<div class="form-grid">
<label class="field"><span>Angebotsnummer</span><div class="readonly-value">{{ form.quote_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required /></label>
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required @change="applyCustomerDefaults" /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="sent">Gesendet</option><option value="accepted">Angenommen</option><option value="rejected">Abgelehnt</option><option value="expired">Abgelaufen</option><option value="cancelled">Storniert</option></select></label>
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" /></label>
<label class="field full-width"><span>Notizen</span><textarea v-model="form.notes" rows="3" /></label>
</div>
<div class="sub-panel">
<div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div>
<div class="section-title"><h2>Positionen</h2></div>
<div class="position-table-header"><span>Position</span><span>Menge</span><span>Einzelpreis</span><span>Gesamtpreis</span><span>Steuer</span><span>Aktion</span></div>
<div v-for="(line, index) in form.items" :key="index" class="quote-line">
<label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" required @change="applyItemDefaults(line)" /></label>
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" required /></label>
<label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" required /></label>
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label>
<label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label>
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
<template v-if="editingLineIndex !== index">
<div class="position-summary">
<strong>{{ lineName(line) }}</strong>
<span>{{ line.quantity }}</span>
<span>{{ line.unit_price }}</span>
<span>{{ lineTotal(line) }}</span>
<span>{{ line.tax_rate }} %</span>
</div>
<button type="button" class="secondary" @click="editingLineIndex = index">Bearbeiten</button>
</template>
<template v-else>
<label class="field full-width"><span>Artikel oder Aktivität</span><SearchSelect :model-value="lineSourceValue(line)" :options="lineSourceOptions" placeholder="Artikel-/Aktivitäts-Nr. oder Name suchen" required @change="selectLineSource(line, $event)" /></label>
<label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" required /></label>
<label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" required /></label>
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" inputmode="decimal" /></label>
<label class="field"><span>Steuer %</span><select v-model="line.tax_rate"><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
<div class="position-actions">
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
<button type="button" @click="addLine">Position hinzufügen</button>
</div>
</template>
</div>
</div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div>

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm, Supplier } from "../types";
@@ -40,9 +41,13 @@ async function createNew() {
}
function select(item: Supplier) { selectedId.value = item.id; Object.assign(form, { ...item, details: { ...item.details } }); }
async function save() {
const payload = {
...form,
standard_discount_percent: decimalString(form.standard_discount_percent)
};
const result = selectedId.value
? await apiPut<Supplier>(`/api/v1/suppliers/${selectedId.value}`, form)
: await apiPost<Supplier>("/api/v1/suppliers", form);
? await apiPut<Supplier>(`/api/v1/suppliers/${selectedId.value}`, payload)
: await apiPost<Supplier>("/api/v1/suppliers", payload);
status.value = result.ok ? "Lieferant gespeichert." : result.message; kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); }
}
@@ -75,7 +80,7 @@ watch(() => liveUpdateState.revision, () => Promise.all([load(), loadCashDiscoun
<label class="field"><span>Lieferantennummer</span><div class="readonly-value">{{ form.supplier_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
<label class="field"><span>Rabatt %</span><input v-model="form.standard_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
<label class="field"><span>Rabatt %</span><input v-model="form.standard_discount_percent" inputmode="decimal" /></label>
<label class="field">
<span>Skonto-Regel</span>
<select v-model="form.cash_discount_term_id">