feat: enhance UI and functionality for price rules, quotes, and suppliers
- Updated PriceRulesPage.vue to replace "Neu" button with a more intuitive "+" button for creating new rules. - Enhanced QuotesPage.vue with improved handling of quote lines, including read-only states, unique line validation, and better formatting for monetary values. - Added accordion sections for better organization of quote details and improved user experience. - Updated SuppliersPage.vue to replace "Neu" button with a "+" button for adding new suppliers. - Introduced new database migrations to add price category and default sales price fields to activities, and to include 'invoice_created' status in quotes.
This commit is contained in:
@@ -38,6 +38,14 @@ button.secondary {
|
||||
color: #26343b;
|
||||
}
|
||||
|
||||
button.add-button {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
min-height: 34px;
|
||||
min-width: 38px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
@@ -422,7 +430,7 @@ code {
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
|
||||
grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -438,13 +446,13 @@ code {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
|
||||
grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px;
|
||||
padding: 0 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.position-table-header.compact {
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
|
||||
grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px;
|
||||
}
|
||||
|
||||
.position-summary {
|
||||
@@ -474,6 +482,38 @@ code {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.position-add-row {
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.position-add-row button {
|
||||
min-height: 34px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.position-add-row .add-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.position-row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.position-row-actions button {
|
||||
min-height: 28px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.position-row-actions .icon-button {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
max-width: 720px;
|
||||
}
|
||||
@@ -541,9 +581,14 @@ code {
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-title button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title h2,
|
||||
.split-row h2 {
|
||||
font-size: 18px;
|
||||
@@ -555,6 +600,100 @@ form {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.accordion-section {
|
||||
border: 1px solid #dbe3e6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion-section.active {
|
||||
border-color: #b9d9d4;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
align-items: center;
|
||||
background: #f6faf9;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: #26343b;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accordion-header:hover,
|
||||
.accordion-section.active .accordion-header {
|
||||
background: #def4f0;
|
||||
color: #10545c;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.accordion-header span {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.accordion-header strong {
|
||||
color: #65757b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.accordion-body.sub-panel {
|
||||
border-top: 1px solid #dbe3e6;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.document-form {
|
||||
min-height: 100%;
|
||||
padding-bottom: 84px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.totals-panel {
|
||||
align-items: center;
|
||||
background: #eef8f5;
|
||||
border: 1px solid #c8e1dc;
|
||||
border-radius: 8px;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -10px 24px rgb(28 43 48 / 8%);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
margin-top: 6px;
|
||||
padding: 12px 14px;
|
||||
position: sticky;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.totals-panel div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.totals-panel span {
|
||||
color: #65757b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.totals-panel strong {
|
||||
color: #172026;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.totals-panel .grand-total strong {
|
||||
color: #0b6f68;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -596,6 +735,15 @@ textarea {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.readonly-notice {
|
||||
background: #fff7e6;
|
||||
border: 1px solid #efd49a;
|
||||
border-radius: 8px;
|
||||
color: #6b4a08;
|
||||
font-weight: 750;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.list-search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@ export type Activity = {
|
||||
title: string;
|
||||
body: string;
|
||||
status: string;
|
||||
price_category: string;
|
||||
default_sales_price?: string | null;
|
||||
priority: string;
|
||||
due_at?: string | null;
|
||||
};
|
||||
|
||||
@@ -3,25 +3,33 @@ 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 { formatDecimal, formatDecimalInput, normalizeDecimal } from "../format";
|
||||
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: "active", priority: "normal", due_at: null as string | null });
|
||||
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "work_step", title: "", body: "", status: "active", price_category: "h", default_sales_price: null as string | null, 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))
|
||||
);
|
||||
async function load() { const r = await apiGet<Activity[]>("/api/v1/activities"); if (r.ok) records.value = r.data; else { status.value = r.message; kind.value = "error"; } }
|
||||
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 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(); } }
|
||||
function select(record: Activity) { selectedId.value = record.id; Object.assign(form, { ...record, default_sales_price: formatDecimal(record.default_sales_price) }); }
|
||||
async function save() {
|
||||
const payload = { ...form, default_sales_price: normalizeDecimal(form.default_sales_price) };
|
||||
const r = selectedId.value ? await apiPut<Activity>(`/api/v1/activities/${selectedId.value}`, payload) : await apiPost<Activity>("/api/v1/activities", payload);
|
||||
status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error";
|
||||
if (r.ok) { selectedId.value = r.data.id; Object.assign(form, { ...r.data, default_sales_price: formatDecimal(r.data.default_sales_price) }); await load(); }
|
||||
return r.ok;
|
||||
}
|
||||
async function deactivate() { if (!selectedId.value) return; form.status = "inactive"; if (await save()) status.value = "Aktivität deaktiviert."; }
|
||||
async function remove() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität gelöscht." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = null; Object.assign(form, emptyForm()); await load(); } }
|
||||
onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||
</script>
|
||||
<template>
|
||||
<PageHeader title="Aktivitäten" description="Aufgaben, Wiedervorlagen und Gesprächsnotizen." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel"><div class="section-title"><h2>Aktivitäten</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<section class="panel list-panel"><div class="section-title"><h2>Aktivitäten</h2><button type="button" class="add-button" title="Neue Aktivität" @click="createNew">+</button></div>
|
||||
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Aktivitätsnummer oder Titel" /></label>
|
||||
<button v-for="record in filteredRecords" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)"><strong>{{ record.activity_number ?? record.activity_type }}</strong><span>{{ record.title }}</span><small>{{ record.status }}</small></button>
|
||||
<p v-if="records.length === 0" class="empty">Keine Aktivitäten vorhanden.</p>
|
||||
@@ -29,11 +37,12 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||
</section>
|
||||
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
|
||||
<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="active">Aktiv</option><option value="inactive">Nicht aktiv</option></select></label>
|
||||
<label class="field"><span>Preiskategorie</span><select v-model="form.price_category"><option value="h">h</option><option value="tag">Tag</option><option value="pauschal">Pauschal</option></select></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>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="deactivate">Deaktivieren</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><button v-if="selectedId" type="button" class="secondary" @click="remove">Löschen</button></div><FormStatus :message="status" :kind="kind" /></form></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,6 @@ onMounted(load);
|
||||
</script>
|
||||
<template>
|
||||
<PageHeader title="Preis-APIs" description="Externe Preisquellen konfigurieren und manuell abgleichen." />
|
||||
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Connectoren</h2><button type="button" @click="createNew">Neu</button></div><button v-for="connector in connectors" :key="connector.id" type="button" class="list-row" :class="{ selected: selectedId === connector.id }" @click="select(connector)"><strong>{{ connector.code }}</strong><span>{{ connector.name }}</span><small>{{ connector.last_sync_at ?? "kein Abgleich" }}</small></button></section>
|
||||
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Connectoren</h2><button type="button" class="add-button" title="Neuer Connector" @click="createNew">+</button></div><button v-for="connector in connectors" :key="connector.id" type="button" class="list-row" :class="{ selected: selectedId === connector.id }" @click="select(connector)"><strong>{{ connector.code }}</strong><span>{{ connector.name }}</span><small>{{ connector.last_sync_at ?? "kein Abgleich" }}</small></button></section>
|
||||
<section class="panel detail-panel"><form @submit.prevent="save"><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>Typ</span><input v-model="form.connector_type" required /></label><label class="field"><span>Intervall Minuten</span><input v-model="form.sync_interval_minutes" type="number" min="1" /></label><label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label><label class="field full-width"><span>Konfiguration JSON</span><textarea v-model="form.config" rows="8" /></label></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="sync">Abgleichen</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form></section></div>
|
||||
</template>
|
||||
|
||||
@@ -82,7 +82,7 @@ watch(() => liveUpdateState.revision, load);
|
||||
<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>
|
||||
<div class="section-title"><h2>Regeln</h2><button type="button" class="add-button" title="Neue Regel" @click="createNew">+</button></div>
|
||||
<button v-for="term in terms" :key="term.id" type="button" class="list-row" :class="{ selected: selectedId === term.id }" @click="select(term)">
|
||||
<strong>{{ term.code }}</strong>
|
||||
<span>{{ term.name }}</span>
|
||||
|
||||
@@ -75,7 +75,7 @@ watch(() => liveUpdateState.revision, load);
|
||||
<PageHeader title="Kommunikation" description="E-Mails, Telefonate, Briefe, Besprechungen und interne Notizen." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title"><h2>Verlauf</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<div class="section-title"><h2>Verlauf</h2><button type="button" class="add-button" title="Neue Kommunikation" @click="createNew">+</button></div>
|
||||
<button v-for="record in records" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)">
|
||||
<strong>{{ record.subject }}</strong>
|
||||
<span>{{ record.communication_type }} · {{ record.direction }}</span>
|
||||
@@ -99,7 +99,7 @@ watch(() => liveUpdateState.revision, load);
|
||||
<label class="field"><span>Typ</span><select v-model="linkType"><option value="customer">Kunde</option><option value="supplier">Lieferant</option><option value="activity">Aktivität</option><option value="quote">Angebot</option><option value="outgoing_invoice">Ausgangsrechnung</option><option value="incoming_invoice">Eingangsrechnung</option><option value="item">Artikel</option><option value="document">Dokument</option></select></label>
|
||||
<label class="field"><span>ID</span><input v-model="linkId" /></label>
|
||||
</div>
|
||||
<button type="button" class="secondary" @click="addLink">Bezug hinzufügen</button>
|
||||
<button type="button" class="secondary add-button" title="Bezug hinzufügen" @click="addLink">+</button>
|
||||
<div v-for="(link, index) in form.links" :key="`${link.entity_type}-${link.entity_id}`" class="data-row">
|
||||
<strong>{{ link.entity_type }}</strong><span>{{ link.entity_id }}</span><button type="button" class="secondary" @click="removeLink(index)">Entfernen</button>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ watch(
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title">
|
||||
<h2>Kundenliste</h2>
|
||||
<button type="button" @click="newCustomer">Neu</button>
|
||||
<button type="button" class="add-button" title="Neuer Kunde" @click="newCustomer">+</button>
|
||||
</div>
|
||||
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Kundennummer oder Name" /></label>
|
||||
<button
|
||||
|
||||
@@ -107,7 +107,7 @@ watch(() => liveUpdateState.revision, load);
|
||||
<PageHeader title="Dokumente" description="Dokumente verschlüsselt ablegen, zuordnen und herunterladen." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title"><h2>Dokumente</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<div class="section-title"><h2>Dokumente</h2><button type="button" class="add-button" title="Neues Dokument" @click="createNew">+</button></div>
|
||||
<button v-for="record in documents" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)">
|
||||
<strong>{{ record.title }}</strong>
|
||||
<span>{{ record.latest_version?.file_name ?? "ohne Datei" }}</span>
|
||||
@@ -130,7 +130,7 @@ watch(() => liveUpdateState.revision, load);
|
||||
<label class="field"><span>Typ</span><select v-model="linkType"><option value="customer">Kunde</option><option value="supplier">Lieferant</option><option value="activity">Aktivität</option><option value="communication">Kommunikation</option><option value="quote">Angebot</option><option value="outgoing_invoice">Ausgangsrechnung</option><option value="incoming_invoice">Eingangsrechnung</option><option value="item">Artikel</option></select></label>
|
||||
<label class="field"><span>ID</span><input v-model="linkId" /></label>
|
||||
</div>
|
||||
<button type="button" class="secondary" @click="addLink">Bezug hinzufügen</button>
|
||||
<button type="button" class="secondary add-button" title="Bezug hinzufügen" @click="addLink">+</button>
|
||||
<div v-for="(link, index) in form.links" :key="`${link.entity_type}-${link.entity_id}`" class="data-row">
|
||||
<strong>{{ link.entity_type }}</strong><span>{{ link.entity_id }}</span><button type="button" class="secondary" @click="removeLink(index)">Entfernen</button>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,9 @@ const emptyForm = () => ({ invoice_number: "", supplier_id: "", status: "receive
|
||||
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 activeSection = ref<"party" | "positions">("party");
|
||||
const readOnly = computed(() => ["paid", "cancelled"].includes(form.status));
|
||||
const readOnlyText = computed(() => form.status === "paid" ? "Bezahlt" : form.status === "cancelled" ? "Storniert" : "");
|
||||
const supplierName = (supplierId: string) => suppliers.value.find((supplier) => supplier.id === supplierId)?.name ?? supplierId;
|
||||
const filteredInvoices = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase();
|
||||
@@ -23,41 +26,123 @@ 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()); 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); }
|
||||
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; activeSection.value = "party"; 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 = -1; activeSection.value = "party"; }
|
||||
function addLine() { if (readOnly.value) return; activeSection.value = "positions"; const draftIndex = form.items.findIndex((line) => !shouldSendLine(line)); if (draftIndex >= 0) { editingLineIndex.value = draftIndex; return; } form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
|
||||
function removeLine(index: number) { if (readOnly.value) return; form.items.splice(index, 1); if (form.items.length === 0) { form.items.push(emptyItem()); editingLineIndex.value = 0; return; } editingLineIndex.value = -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 itemUnit(itemId: string | null | undefined) {
|
||||
return items.value.find((record) => record.id === itemId)?.unit ?? "";
|
||||
}
|
||||
function quantityWithUnit(line: IncomingInvoiceItem) {
|
||||
const unit = itemUnit(line.item_id);
|
||||
return unit ? `${line.quantity} ${unit}` : line.quantity;
|
||||
}
|
||||
function optionalId(value: string | null | undefined) {
|
||||
return value && value.trim() ? value : null;
|
||||
}
|
||||
function shouldSendLine(line: IncomingInvoiceItem) {
|
||||
if (optionalId(line.item_id)) return true;
|
||||
return Boolean(line.description.trim()) || decimalString(line.unit_price) !== "0";
|
||||
}
|
||||
function lineIdentity(line: IncomingInvoiceItem) {
|
||||
return [
|
||||
`item:${optionalId(line.item_id) ?? ""}`,
|
||||
line.description.trim(),
|
||||
decimalString(line.unit_price),
|
||||
normalizeTaxRate(line.tax_rate)
|
||||
].join("|");
|
||||
}
|
||||
function ensureUniqueLines() {
|
||||
const seen = new Set<string>();
|
||||
for (const line of form.items.filter(shouldSendLine)) {
|
||||
const key = lineIdentity(line);
|
||||
if (seen.has(key)) {
|
||||
status.value = "Diese Position ist mit gleichen Bedingungen bereits vorhanden. Bitte die vorhandene Position bearbeiten.";
|
||||
kind.value = "error";
|
||||
activeSection.value = "positions";
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function closeLineEditor() { if (ensureUniqueLines()) editingLineIndex.value = -1; }
|
||||
function isNewLine(line: IncomingInvoiceItem) { return !line.id; }
|
||||
function activateSection(section: "party" | "positions") { activeSection.value = section; }
|
||||
function lineTotal(line: IncomingInvoiceItem) {
|
||||
const quantity = Number(decimalString(line.quantity, "0"));
|
||||
const unitPrice = Number(decimalString(line.unit_price, "0"));
|
||||
const total = quantity * unitPrice;
|
||||
const total = lineNetTotal(line);
|
||||
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
|
||||
}
|
||||
function lineNetTotal(line: IncomingInvoiceItem) {
|
||||
const quantity = Number(decimalString(line.quantity, "0"));
|
||||
const unitPrice = Number(decimalString(line.unit_price, "0"));
|
||||
return quantity * unitPrice;
|
||||
}
|
||||
function lineTaxTotal(line: IncomingInvoiceItem) {
|
||||
const taxRate = Number(decimalString(line.tax_rate, "0"));
|
||||
return lineNetTotal(line) * taxRate / 100;
|
||||
}
|
||||
const totals = computed(() => {
|
||||
const sendableLines = form.items.filter(shouldSendLine);
|
||||
const net = sendableLines.reduce((sum, line) => sum + lineNetTotal(line), 0);
|
||||
const tax = sendableLines.reduce((sum, line) => sum + lineTaxTotal(line), 0);
|
||||
return { net, tax, gross: net + tax };
|
||||
});
|
||||
function money(value: number) {
|
||||
return value.toLocaleString("de-DE", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
async function save() {
|
||||
if (readOnly.value) return;
|
||||
if (!ensureUniqueLines()) return;
|
||||
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)
|
||||
}))
|
||||
items: form.items
|
||||
.filter(shouldSendLine)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
item_id: optionalId(item.item_id),
|
||||
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(); }
|
||||
async function cancelInvoice() { if (readOnly.value || !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>
|
||||
<template>
|
||||
<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></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>
|
||||
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Eingangsrechnungen</h2><button type="button" class="add-button" title="Neue Eingangsrechnung" @click="createNew">+</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 class="document-form" @submit.prevent="save">
|
||||
<div v-if="readOnly" class="readonly-notice">Diese Eingangsrechnung ist schreibgeschützt: {{ readOnlyText }}.</div>
|
||||
<div class="accordion-section" :class="{ active: activeSection === 'party' }">
|
||||
<button type="button" class="accordion-header" @click="activateSection('party')"><span>Lieferant</span><strong>{{ activeSection === 'party' ? "▾" : "▸" }}</strong></button>
|
||||
<div v-if="activeSection === 'party'" class="accordion-body 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><div v-if="readOnly" class="readonly-value">{{ supplierName(form.supplier_id) }}</div><SearchSelect v-else 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><div v-if="readOnly" class="readonly-value">{{ readOnlyText }}</div><select v-else v-model="form.status"><option value="draft">Entwurf</option><option value="received">Erhalten</option><option value="approved">Freigegeben</option><option value="overdue">Überfällig</option></select></label>
|
||||
<label class="field"><span>Datum</span><input v-model="form.invoice_date" type="date" :disabled="readOnly" /></label>
|
||||
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" :disabled="readOnly" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-section" :class="{ active: activeSection === 'positions' }">
|
||||
<button type="button" class="accordion-header" @click="activateSection('positions')"><span>Positionen</span><strong>{{ activeSection === 'positions' ? "▾" : "▸" }}</strong></button>
|
||||
<div v-if="activeSection === 'positions'" class="accordion-body sub-panel">
|
||||
<div class="section-title"><h2>Positionen</h2><button v-if="!readOnly" type="button" class="add-button" title="Neue Position" @click="addLine">+</button></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 && shouldSendLine(line)"><div class="position-summary compact"><strong>{{ itemName(line.item_id) }}</strong><span>{{ quantityWithUnit(line) }}</span><span>{{ line.unit_price }}</span><span>{{ lineTotal(line) }}</span><span>{{ line.tax_rate }} %</span></div><div class="position-row-actions"><button v-if="!readOnly" type="button" class="secondary icon-button" title="Bearbeiten" @click="editingLineIndex = index">✎</button><button v-if="!readOnly" type="button" class="secondary icon-button" title="Entfernen" @click="removeLine(index)">×</button></div></template><template v-else><label class="field full-width"><span>Artikel</span><div v-if="readOnly" class="readonly-value">{{ itemName(line.item_id) }}</div><SearchSelect v-else 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" :disabled="readOnly" /></label><label class="field"><span>Einheit</span><div class="readonly-value">{{ itemUnit(line.item_id) || "-" }}</div></label><label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" :disabled="readOnly" /></label><label class="field"><span>Steuer %</span><select v-model="line.tax_rate" :disabled="readOnly"><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" :disabled="readOnly" /></label><div v-if="!readOnly" class="position-actions"><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><button type="button" @click="closeLineEditor">{{ isNewLine(line) ? "+" : "Änderung übernehmen" }}</button></div></template></div>
|
||||
<div v-if="!readOnly" class="position-add-row"><button type="button" class="add-button" title="Neue Position" @click="addLine">+</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!readOnly" 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" /><div class="totals-panel"><div><span>Netto-Summe</span><strong>{{ money(totals.net) }}</strong></div><div><span>Steuer</span><strong>{{ money(totals.tax) }}</strong></div><div class="grand-total"><span>Gesamtsumme</span><strong>{{ money(totals.gross) }}</strong></div></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -49,8 +49,10 @@ async function save() {
|
||||
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)]); }
|
||||
return r.ok;
|
||||
}
|
||||
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(); }
|
||||
async function deactivate() { if (!selectedId.value) return; form.status = "inactive"; if (await save()) status.value = "Artikel deaktiviert."; }
|
||||
async function remove() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/items/${selectedId.value}`); status.value = r.ok ? "Artikel gelöscht." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = null; Object.assign(form, emptyForm()); priceHistory.value = []; await load(); } }
|
||||
function addSupplierPrice() { form.supplier_prices.push(emptySupplierPrice()); }
|
||||
function removeSupplierPrice(index: number) { form.supplier_prices.splice(index, 1); }
|
||||
function displayItem(item: Item) {
|
||||
@@ -92,7 +94,7 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
|
||||
<PageHeader title="Artikel" description="Artikelstamm und aktuelle Standardpreise." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title"><h2>Artikel</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<div class="section-title"><h2>Artikel</h2><button type="button" class="add-button" title="Neuer Artikel" @click="createNew">+</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>
|
||||
@@ -109,10 +111,10 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
|
||||
<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>
|
||||
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Nicht aktiv</option><option value="blocked">Gesperrt</option></select></label>
|
||||
</div>
|
||||
<div class="sub-panel">
|
||||
<div class="section-title"><h2>Lieferantenpreise</h2><button type="button" @click="addSupplierPrice">Lieferant</button></div>
|
||||
<div class="section-title"><h2>Lieferantenpreise</h2><button type="button" class="add-button" title="Lieferantenpreis hinzufügen" @click="addSupplierPrice">+</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>
|
||||
@@ -123,7 +125,7 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
|
||||
</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 class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button><button v-if="selectedId" type="button" class="secondary" @click="remove">Löschen</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">
|
||||
|
||||
@@ -4,7 +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 { decimalString, formatDecimal, formatDecimalInput, normalizeTaxRate, taxRateOptions } from "../format";
|
||||
import { reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { Activity, Customer, Item, OutgoingInvoice, OutgoingInvoiceItem, Quote } from "../types";
|
||||
@@ -22,6 +22,9 @@ const status = ref("");
|
||||
const kind = ref<"info" | "success" | "error">("info");
|
||||
const search = ref("");
|
||||
const editingLineIndex = ref(0);
|
||||
const activeSection = ref<"party" | "positions">("party");
|
||||
const readOnly = computed(() => ["finalized", "paid", "cancelled"].includes(form.status));
|
||||
const readOnlyText = computed(() => form.status === "finalized" ? "Abgeschlossen" : form.status === "paid" ? "Bezahlt" : form.status === "cancelled" ? "Storniert" : "");
|
||||
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
|
||||
const filteredInvoices = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase();
|
||||
@@ -38,17 +41,35 @@ async function load() {
|
||||
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");
|
||||
if (quoteResult.ok) quotes.value = quoteResult.data.filter((quote) => quote.status === "accepted");
|
||||
if (activityResult.ok) {
|
||||
activities.value = activityResult.data.filter((activity) => activity.status === "active");
|
||||
refreshActivityLineDefaults();
|
||||
}
|
||||
}
|
||||
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); }
|
||||
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; activeSection.value = "party"; 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 = -1; activeSection.value = "party"; }
|
||||
function addLine() { if (readOnly.value) return; activeSection.value = "positions"; const draftIndex = form.items.findIndex((line) => !shouldSendLine(line)); if (draftIndex >= 0) { editingLineIndex.value = draftIndex; return; } form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
|
||||
function removeLine(index: number) { if (readOnly.value) return; form.items.splice(index, 1); if (form.items.length === 0) { form.items.push(emptyItem()); editingLineIndex.value = 0; return; } editingLineIndex.value = -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 = normalizeTaxRate(item.tax_rate);
|
||||
line.description = item.name; applyLinePrice(line, item.default_sales_price); line.tax_rate = normalizeTaxRate(item.tax_rate);
|
||||
}
|
||||
function applyLinePrice(line: OutgoingInvoiceItem, price: string | number | null | undefined) {
|
||||
const normalized = decimalString(price);
|
||||
line.unit_price = formatDecimal(normalized);
|
||||
line.original_unit_price = normalized;
|
||||
}
|
||||
function refreshActivityLineDefaults() {
|
||||
for (const line of form.items) {
|
||||
if (line.line_kind !== "activity" || !line.activity_id) continue;
|
||||
const activity = activities.value.find((record) => record.id === line.activity_id);
|
||||
if (!activity?.default_sales_price) continue;
|
||||
const current = decimalString(line.unit_price);
|
||||
const original = line.original_unit_price === null ? "0" : decimalString(line.original_unit_price);
|
||||
if (current === original || current === "0") applyLinePrice(line, activity.default_sales_price);
|
||||
}
|
||||
}
|
||||
const lineSourceOptions = computed(() => [
|
||||
...items.value.map((item) => ({ id: `item:${item.id}`, number: item.item_number, name: item.name })),
|
||||
@@ -67,8 +88,7 @@ function selectLineSource(line: OutgoingInvoiceItem, value: string | null) {
|
||||
line.activity_id = id;
|
||||
line.item_id = null;
|
||||
line.description = activity.title;
|
||||
line.unit_price = "0";
|
||||
line.original_unit_price = null;
|
||||
applyLinePrice(line, activity.default_sales_price);
|
||||
line.tax_rate = "19";
|
||||
return;
|
||||
}
|
||||
@@ -95,72 +115,165 @@ function lineName(line: OutgoingInvoiceItem) {
|
||||
const item = items.value.find((record) => record.id === line.item_id);
|
||||
return item ? `${item.item_number} - ${item.name}` : line.item_id;
|
||||
}
|
||||
function lineUnit(line: OutgoingInvoiceItem) {
|
||||
if (line.line_kind === "activity") {
|
||||
const category = activities.value.find((record) => record.id === line.activity_id)?.price_category;
|
||||
if (category === "tag") return "Tag";
|
||||
if (category === "pauschal") return "Pauschal";
|
||||
return "h";
|
||||
}
|
||||
return items.value.find((record) => record.id === line.item_id)?.unit ?? "";
|
||||
}
|
||||
function quantityWithUnit(line: OutgoingInvoiceItem) {
|
||||
const unit = lineUnit(line);
|
||||
return unit ? `${line.quantity} ${unit}` : line.quantity;
|
||||
}
|
||||
function optionalId(value: string | null | undefined) {
|
||||
return value && value.trim() ? value : null;
|
||||
}
|
||||
function shouldSendLine(line: OutgoingInvoiceItem) {
|
||||
const hasSource = line.line_kind === "activity" ? optionalId(line.activity_id) !== null : optionalId(line.item_id) !== null;
|
||||
if (hasSource) return true;
|
||||
return Boolean(line.description.trim()) || decimalString(line.unit_price) !== "0";
|
||||
}
|
||||
function lineIdentity(line: OutgoingInvoiceItem) {
|
||||
const source = line.line_kind === "activity" ? `activity:${optionalId(line.activity_id) ?? ""}` : `item:${optionalId(line.item_id) ?? ""}`;
|
||||
return [
|
||||
source,
|
||||
line.description.trim(),
|
||||
decimalString(line.unit_price),
|
||||
decimalString(line.discount_percent),
|
||||
normalizeTaxRate(line.tax_rate)
|
||||
].join("|");
|
||||
}
|
||||
function ensureUniqueLines() {
|
||||
const seen = new Set<string>();
|
||||
for (const line of form.items.filter(shouldSendLine)) {
|
||||
const key = lineIdentity(line);
|
||||
if (seen.has(key)) {
|
||||
status.value = "Diese Position ist mit gleichen Bedingungen bereits vorhanden. Bitte die vorhandene Position bearbeiten.";
|
||||
kind.value = "error";
|
||||
activeSection.value = "positions";
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function closeLineEditor() { if (ensureUniqueLines()) editingLineIndex.value = -1; }
|
||||
function isNewLine(line: OutgoingInvoiceItem) { return !line.id; }
|
||||
function activateSection(section: "party" | "positions") { activeSection.value = section; }
|
||||
function lineTotal(line: OutgoingInvoiceItem) {
|
||||
const total = lineNetTotal(line);
|
||||
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
|
||||
}
|
||||
function lineNetTotal(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 }) : "-";
|
||||
return quantity * unitPrice * (1 - discount / 100);
|
||||
}
|
||||
function lineTaxTotal(line: OutgoingInvoiceItem) {
|
||||
const taxRate = Number(decimalString(line.tax_rate, "0"));
|
||||
return lineNetTotal(line) * taxRate / 100;
|
||||
}
|
||||
const totals = computed(() => {
|
||||
const sendableLines = form.items.filter(shouldSendLine);
|
||||
const net = sendableLines.reduce((sum, line) => sum + lineNetTotal(line), 0);
|
||||
const tax = sendableLines.reduce((sum, line) => sum + lineTaxTotal(line), 0);
|
||||
return { net, tax, gross: net + tax };
|
||||
});
|
||||
function money(value: number) {
|
||||
return value.toLocaleString("de-DE", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
async function save() {
|
||||
if (readOnly.value) return;
|
||||
if (!ensureUniqueLines()) return;
|
||||
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)
|
||||
}))
|
||||
items: form.items
|
||||
.filter(shouldSendLine)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
line_kind: item.line_kind ?? "item",
|
||||
item_id: item.line_kind === "activity" ? null : optionalId(item.item_id),
|
||||
activity_id: item.line_kind === "activity" ? optionalId(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(); }
|
||||
async function cancelInvoice() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/outgoing-invoices/${selectedId.value}`); status.value = result.ok ? "Rechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||
async function convertQuote(quoteId: string) { const result = await apiPost<OutgoingInvoice>(`/api/v1/quotes/${quoteId}/convert-to-invoice`, {}); status.value = result.ok ? "Angebot umgewandelt." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); } }
|
||||
async function finalize() { if (readOnly.value || !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(); }
|
||||
async function cancelInvoice() { if (readOnly.value || !selectedId.value) return; const result = await apiDelete(`/api/v1/outgoing-invoices/${selectedId.value}`); status.value = result.ok ? "Rechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||
async function convertQuote(quoteId: string) {
|
||||
const result = await apiPost<OutgoingInvoice>(`/api/v1/quotes/${quoteId}/convert-to-invoice`, {});
|
||||
status.value = result.ok ? "Angebot umgewandelt." : result.message;
|
||||
kind.value = result.ok ? "success" : "error";
|
||||
if (result.ok) {
|
||||
select(result.data);
|
||||
await load();
|
||||
select(result.data);
|
||||
}
|
||||
}
|
||||
onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||
</script>
|
||||
<template>
|
||||
<PageHeader title="Ausgangsrechnungen" description="Rechnungen erstellen, aus Angeboten übernehmen und abschließen." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel"><div class="section-title"><h2>Rechnungen</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<section class="panel list-panel"><div class="section-title"><h2>Rechnungen</h2><button type="button" class="add-button" title="Neue Rechnung" @click="createNew">+</button></div>
|
||||
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Kunde 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>{{ customerName(invoice.customer_id) }}</span><small>{{ invoice.status }}</small></button>
|
||||
<p v-if="invoices.length === 0" class="empty">Keine Rechnungen vorhanden.</p>
|
||||
<p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p>
|
||||
<div class="sub-panel"><h2>Aus Angebot</h2><button v-for="quote in quotes" :key="quote.id" type="button" class="secondary" @click="convertQuote(quote.id)">{{ quote.quote_number }}</button></div>
|
||||
</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 @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" 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">
|
||||
<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>
|
||||
<section class="panel detail-panel">
|
||||
<form class="document-form" @submit.prevent="save">
|
||||
<div v-if="readOnly" class="readonly-notice">Diese Rechnung ist schreibgeschützt: {{ readOnlyText }}.</div>
|
||||
<div class="accordion-section" :class="{ active: activeSection === 'party' }">
|
||||
<button type="button" class="accordion-header" @click="activateSection('party')"><span>Kunde</span><strong>{{ activeSection === 'party' ? "▾" : "▸" }}</strong></button>
|
||||
<div v-if="activeSection === 'party'" class="accordion-body 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><div v-if="readOnly" class="readonly-value">{{ customerName(form.customer_id) }}</div><SearchSelect v-else 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><div v-if="readOnly" class="readonly-value">{{ readOnlyText }}</div><select v-else v-model="form.status"><option value="draft">Entwurf</option><option value="sent">Gesendet</option><option value="overdue">Überfällig</option></select></label>
|
||||
<label class="field"><span>Ausgestellt</span><input v-model="form.issued_at" type="date" :disabled="readOnly" /></label>
|
||||
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" :disabled="readOnly" /></label>
|
||||
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" :disabled="readOnly" /></label>
|
||||
</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>
|
||||
<div class="accordion-section" :class="{ active: activeSection === 'positions' }">
|
||||
<button type="button" class="accordion-header" @click="activateSection('positions')"><span>Positionen</span><strong>{{ activeSection === 'positions' ? "▾" : "▸" }}</strong></button>
|
||||
<div v-if="activeSection === 'positions'" class="accordion-body sub-panel">
|
||||
<div class="section-title"><h2>Positionen</h2><button v-if="!readOnly" type="button" class="add-button" title="Neue Position" @click="addLine">+</button></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">
|
||||
<template v-if="editingLineIndex !== index && shouldSendLine(line)">
|
||||
<div class="position-summary">
|
||||
<strong>{{ lineName(line) }}</strong>
|
||||
<span>{{ quantityWithUnit(line) }}</span>
|
||||
<span>{{ line.unit_price }}</span>
|
||||
<span>{{ lineTotal(line) }}</span>
|
||||
<span>{{ line.tax_rate }} %</span>
|
||||
</div>
|
||||
<div class="position-row-actions"><button v-if="!readOnly" type="button" class="secondary icon-button" title="Bearbeiten" @click="editingLineIndex = index">✎</button><button v-if="!readOnly" type="button" class="secondary icon-button" title="Entfernen" @click="removeLine(index)">×</button></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="field full-width"><span>Artikel oder Aktivität</span><div v-if="readOnly" class="readonly-value">{{ lineName(line) }}</div><SearchSelect v-else :model-value="lineSourceValue(line)" :options="lineSourceOptions" placeholder="Artikel-/Aktivitäts-Nr. oder Name suchen" required @update:model-value="selectLineSource(line, $event)" /></label>
|
||||
<label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" :disabled="readOnly" @blur="formatDecimalInput(line, 'quantity')" /></label><label class="field"><span>Einheit</span><div class="readonly-value">{{ lineUnit(line) || "-" }}</div></label><label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" :disabled="readOnly" @blur="formatDecimalInput(line, 'unit_price')" /></label><label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" inputmode="decimal" :disabled="readOnly" @blur="formatDecimalInput(line, 'discount_percent')" /></label><label class="field"><span>Steuer %</span><select v-model="line.tax_rate" :disabled="readOnly"><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" :disabled="readOnly" /></label>
|
||||
<div v-if="!readOnly" class="position-actions"><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><button type="button" @click="closeLineEditor">{{ isNewLine(line) ? "+" : "Änderung übernehmen" }}</button></div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="!readOnly" class="position-add-row"><button type="button" class="add-button" title="Neue Position" @click="addLine">+</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!readOnly" 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" /><div class="totals-panel"><div><span>Netto-Summe</span><strong>{{ money(totals.net) }}</strong></div><div><span>Steuer</span><strong>{{ money(totals.tax) }}</strong></div><div class="grand-total"><span>Gesamtsumme</span><strong>{{ money(totals.gross) }}</strong></div></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,7 +76,7 @@ onMounted(load);
|
||||
<PageHeader title="Preisregeln" description="Aufschläge und Rundung je Preisquelle festlegen." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<div class="section-title"><h2>Regeln</h2><button type="button" class="add-button" title="Neue Regel" @click="createNew">+</button></div>
|
||||
<button v-for="rule in rules" :key="rule.id" type="button" class="list-row" :class="{ selected: selectedId === rule.id }" @click="select(rule)">
|
||||
<strong>{{ rule.code }}</strong>
|
||||
<span>{{ rule.name }}</span>
|
||||
|
||||
@@ -4,7 +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 { decimalString, formatDecimal, formatDecimalInput, normalizeTaxRate, taxRateOptions } from "../format";
|
||||
import { reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { Activity, Customer, Item, Quote, QuoteItem } from "../types";
|
||||
@@ -23,6 +23,9 @@ 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 activeSection = ref<"party" | "positions">("party");
|
||||
const readOnly = computed(() => ["invoice_created", "cancelled"].includes(form.status));
|
||||
const readOnlyText = computed(() => form.status === "invoice_created" ? "Rechnung erstellt" : form.status === "cancelled" ? "Storniert" : "");
|
||||
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
|
||||
const filteredQuotes = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase();
|
||||
@@ -42,41 +45,75 @@ async function load() {
|
||||
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");
|
||||
if (activityResult.ok) {
|
||||
activities.value = activityResult.data.filter((activity) => activity.status === "active");
|
||||
refreshActivityLineDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
async function createNew() {
|
||||
selectedId.value = null;
|
||||
Object.assign(form, emptyForm());
|
||||
editingLineIndex.value = 0;
|
||||
activeSection.value = "party";
|
||||
form.quote_number = (await reserveNextNumber("quotes")) ?? "";
|
||||
}
|
||||
|
||||
function select(quote: Quote) {
|
||||
selectedId.value = quote.id;
|
||||
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;
|
||||
editingLineIndex.value = -1;
|
||||
activeSection.value = "party";
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
if (readOnly.value) return;
|
||||
activeSection.value = "positions";
|
||||
const draftIndex = form.items.findIndex((line) => !shouldSendLine(line));
|
||||
if (draftIndex >= 0) {
|
||||
editingLineIndex.value = draftIndex;
|
||||
return;
|
||||
}
|
||||
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);
|
||||
if (readOnly.value) return;
|
||||
form.items.splice(index, 1);
|
||||
if (form.items.length === 0) {
|
||||
form.items.push(emptyItem());
|
||||
editingLineIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
editingLineIndex.value = -1;
|
||||
}
|
||||
|
||||
function applyItemDefaults(line: QuoteItem) {
|
||||
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;
|
||||
applyLinePrice(line, item.default_sales_price);
|
||||
line.tax_rate = normalizeTaxRate(item.tax_rate);
|
||||
}
|
||||
|
||||
function applyLinePrice(line: QuoteItem, price: string | number | null | undefined) {
|
||||
const normalized = decimalString(price);
|
||||
line.unit_price = formatDecimal(normalized);
|
||||
line.original_unit_price = normalized;
|
||||
}
|
||||
|
||||
function refreshActivityLineDefaults() {
|
||||
for (const line of form.items) {
|
||||
if (line.line_kind !== "activity" || !line.activity_id) continue;
|
||||
const activity = activities.value.find((record) => record.id === line.activity_id);
|
||||
if (!activity?.default_sales_price) continue;
|
||||
const current = decimalString(line.unit_price);
|
||||
const original = line.original_unit_price === null ? "0" : decimalString(line.original_unit_price);
|
||||
if (current === original || current === "0") applyLinePrice(line, activity.default_sales_price);
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
@@ -96,8 +133,7 @@ function selectLineSource(line: QuoteItem, value: string | null) {
|
||||
line.activity_id = id;
|
||||
line.item_id = null;
|
||||
line.description = activity.title;
|
||||
line.unit_price = "0";
|
||||
line.original_unit_price = null;
|
||||
applyLinePrice(line, activity.default_sales_price);
|
||||
line.tax_rate = "19";
|
||||
return;
|
||||
}
|
||||
@@ -127,29 +163,117 @@ function lineName(line: QuoteItem) {
|
||||
return item ? `${item.item_number} - ${item.name}` : line.item_id;
|
||||
}
|
||||
|
||||
function lineUnit(line: QuoteItem) {
|
||||
if (line.line_kind === "activity") {
|
||||
const category = activities.value.find((record) => record.id === line.activity_id)?.price_category;
|
||||
if (category === "tag") return "Tag";
|
||||
if (category === "pauschal") return "Pauschal";
|
||||
return "h";
|
||||
}
|
||||
return items.value.find((record) => record.id === line.item_id)?.unit ?? "";
|
||||
}
|
||||
|
||||
function quantityWithUnit(line: QuoteItem) {
|
||||
const unit = lineUnit(line);
|
||||
return unit ? `${line.quantity} ${unit}` : line.quantity;
|
||||
}
|
||||
|
||||
function optionalId(value: string | null | undefined) {
|
||||
return value && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function shouldSendLine(line: QuoteItem) {
|
||||
const hasSource = line.line_kind === "activity" ? optionalId(line.activity_id) !== null : optionalId(line.item_id) !== null;
|
||||
if (hasSource) return true;
|
||||
return Boolean(line.description.trim()) || decimalString(line.unit_price) !== "0";
|
||||
}
|
||||
|
||||
function lineIdentity(line: QuoteItem) {
|
||||
const source = line.line_kind === "activity" ? `activity:${optionalId(line.activity_id) ?? ""}` : `item:${optionalId(line.item_id) ?? ""}`;
|
||||
return [
|
||||
source,
|
||||
line.description.trim(),
|
||||
decimalString(line.unit_price),
|
||||
decimalString(line.discount_percent),
|
||||
normalizeTaxRate(line.tax_rate)
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function ensureUniqueLines() {
|
||||
const seen = new Set<string>();
|
||||
for (const line of form.items.filter(shouldSendLine)) {
|
||||
const key = lineIdentity(line);
|
||||
if (seen.has(key)) {
|
||||
status.value = "Diese Position ist mit gleichen Bedingungen bereits vorhanden. Bitte die vorhandene Position bearbeiten.";
|
||||
kind.value = "error";
|
||||
activeSection.value = "positions";
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeLineEditor() {
|
||||
if (!ensureUniqueLines()) return;
|
||||
editingLineIndex.value = -1;
|
||||
}
|
||||
|
||||
function isNewLine(line: QuoteItem) {
|
||||
return !line.id;
|
||||
}
|
||||
|
||||
function activateSection(section: "party" | "positions") {
|
||||
activeSection.value = section;
|
||||
}
|
||||
|
||||
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);
|
||||
const total = lineNetTotal(line);
|
||||
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
|
||||
}
|
||||
|
||||
function lineNetTotal(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"));
|
||||
return quantity * unitPrice * (1 - discount / 100);
|
||||
}
|
||||
|
||||
function lineTaxTotal(line: QuoteItem) {
|
||||
const taxRate = Number(decimalString(line.tax_rate, "0"));
|
||||
return lineNetTotal(line) * taxRate / 100;
|
||||
}
|
||||
|
||||
const totals = computed(() => {
|
||||
const sendableLines = form.items.filter(shouldSendLine);
|
||||
const net = sendableLines.reduce((sum, line) => sum + lineNetTotal(line), 0);
|
||||
const tax = sendableLines.reduce((sum, line) => sum + lineTaxTotal(line), 0);
|
||||
return { net, tax, gross: net + tax };
|
||||
});
|
||||
|
||||
function money(value: number) {
|
||||
return value.toLocaleString("de-DE", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (readOnly.value) return;
|
||||
if (!ensureUniqueLines()) return;
|
||||
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)
|
||||
}))
|
||||
items: form.items
|
||||
.filter(shouldSendLine)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
line_kind: item.line_kind ?? "item",
|
||||
item_id: item.line_kind === "activity" ? null : optionalId(item.item_id),
|
||||
activity_id: item.line_kind === "activity" ? optionalId(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}`, payload)
|
||||
@@ -160,6 +284,7 @@ async function save() {
|
||||
}
|
||||
|
||||
async function cancelQuote() {
|
||||
if (readOnly.value) return;
|
||||
if (!selectedId.value) return;
|
||||
const result = await apiDelete(`/api/v1/quotes/${selectedId.value}`);
|
||||
status.value = result.ok ? "Angebot storniert." : result.message;
|
||||
@@ -175,7 +300,7 @@ watch(() => liveUpdateState.revision, load);
|
||||
<PageHeader title="Angebote" description="Angebote mit festen Artikelpositionen und individuellen Preisen." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title"><h2>Angebote</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<div class="section-title"><h2>Angebote</h2><button type="button" class="add-button" title="Neues Angebot" @click="createNew">+</button></div>
|
||||
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Angebotsnummer, Kunde oder Status" /></label>
|
||||
<button v-for="quote in filteredQuotes" :key="quote.id" type="button" class="list-row" :class="{ selected: selectedId === quote.id }" @click="select(quote)">
|
||||
<strong>{{ quote.quote_number }}</strong>
|
||||
@@ -186,45 +311,62 @@ watch(() => liveUpdateState.revision, load);
|
||||
<p v-else-if="filteredQuotes.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>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 @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" 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></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">
|
||||
<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>
|
||||
<form class="document-form" @submit.prevent="save">
|
||||
<div v-if="readOnly" class="readonly-notice">Dieses Angebot ist schreibgeschützt: {{ readOnlyText }}.</div>
|
||||
<div class="accordion-section" :class="{ active: activeSection === 'party' }">
|
||||
<button type="button" class="accordion-header" @click="activateSection('party')"><span>Kunde</span><strong>{{ activeSection === 'party' ? "▾" : "▸" }}</strong></button>
|
||||
<div v-if="activeSection === 'party'" class="accordion-body 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><div v-if="readOnly" class="readonly-value">{{ customerName(form.customer_id) }}</div><SearchSelect v-else 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><div v-if="readOnly" class="readonly-value">{{ readOnlyText }}</div><select v-else 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></select></label>
|
||||
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" :disabled="readOnly" /></label>
|
||||
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" :disabled="readOnly" /></label>
|
||||
<label class="field full-width"><span>Notizen</span><textarea v-model="form.notes" rows="3" :disabled="readOnly" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div>
|
||||
<div class="accordion-section" :class="{ active: activeSection === 'positions' }">
|
||||
<button type="button" class="accordion-header" @click="activateSection('positions')"><span>Positionen</span><strong>{{ activeSection === 'positions' ? "▾" : "▸" }}</strong></button>
|
||||
<div v-if="activeSection === 'positions'" class="accordion-body sub-panel">
|
||||
<div class="section-title"><h2>Positionen</h2><button v-if="!readOnly" type="button" class="add-button" title="Neue Position" @click="addLine">+</button></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">
|
||||
<template v-if="editingLineIndex !== index && shouldSendLine(line)">
|
||||
<div class="position-summary">
|
||||
<strong>{{ lineName(line) }}</strong>
|
||||
<span>{{ quantityWithUnit(line) }}</span>
|
||||
<span>{{ line.unit_price }}</span>
|
||||
<span>{{ lineTotal(line) }}</span>
|
||||
<span>{{ line.tax_rate }} %</span>
|
||||
</div>
|
||||
<div class="position-row-actions">
|
||||
<button v-if="!readOnly" type="button" class="secondary icon-button" title="Bearbeiten" @click="editingLineIndex = index">✎</button>
|
||||
<button v-if="!readOnly" type="button" class="secondary icon-button" title="Entfernen" @click="removeLine(index)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="field full-width"><span>Artikel oder Aktivität</span><div v-if="readOnly" class="readonly-value">{{ lineName(line) }}</div><SearchSelect v-else :model-value="lineSourceValue(line)" :options="lineSourceOptions" placeholder="Artikel-/Aktivitäts-Nr. oder Name suchen" required @update:model-value="selectLineSource(line, $event)" /></label>
|
||||
<label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" required :disabled="readOnly" @blur="formatDecimalInput(line, 'quantity')" /></label>
|
||||
<label class="field"><span>Einheit</span><div class="readonly-value">{{ lineUnit(line) || "-" }}</div></label>
|
||||
<label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" required :disabled="readOnly" @blur="formatDecimalInput(line, 'unit_price')" /></label>
|
||||
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" inputmode="decimal" :disabled="readOnly" @blur="formatDecimalInput(line, 'discount_percent')" /></label>
|
||||
<label class="field"><span>Steuer %</span><select v-model="line.tax_rate" :disabled="readOnly"><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" :disabled="readOnly" /></label>
|
||||
<div v-if="!readOnly" class="position-actions">
|
||||
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
|
||||
<button type="button" @click="closeLineEditor">{{ isNewLine(line) ? "+" : "Änderung übernehmen" }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="!readOnly" class="position-add-row"><button type="button" class="add-button" title="Neue Position" @click="addLine">+</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!readOnly" class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div>
|
||||
<FormStatus :message="status" :kind="kind" />
|
||||
<div class="totals-panel">
|
||||
<div><span>Netto-Summe</span><strong>{{ money(totals.net) }}</strong></div>
|
||||
<div><span>Steuer</span><strong>{{ money(totals.tax) }}</strong></div>
|
||||
<div class="grand-total"><span>Gesamtsumme</span><strong>{{ money(totals.gross) }}</strong></div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ watch(() => liveUpdateState.revision, () => Promise.all([load(), loadCashDiscoun
|
||||
<PageHeader title="Lieferanten" description="Lieferantenstamm und Zahlungskonditionen." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title"><h2>Lieferanten</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<div class="section-title"><h2>Lieferanten</h2><button type="button" class="add-button" title="Neuer Lieferant" @click="createNew">+</button></div>
|
||||
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Lieferantennummer oder Name" /></label>
|
||||
<button v-for="item in filteredSuppliers" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)">
|
||||
<strong>{{ item.supplier_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small>
|
||||
|
||||
Reference in New Issue
Block a user