Files
company-tool/web-frontend/src/views/CustomersPage.vue
Torsten Schulz (local) d5b6f39177 feat: enhance forms with decimal formatting and validation
- Updated CustomersPage.vue to use decimalString for standard discount percent.
- Enhanced IncomingInvoicesPage.vue to format item quantities, unit prices, and tax rates using decimalString.
- Improved ItemsPage.vue with new supplier price management and decimal formatting for prices.
- Modified OrganizationSetupPage.vue to use a dropdown for default tax rates and ensure numeric input for payment days.
- Updated OutgoingInvoicesPage.vue to apply decimal formatting for customer discounts and item details.
- Enhanced PriceImportsPage.vue to include additional fields in the import format.
- Improved PriceRulesPage.vue to use decimal input for markup percentages.
- Updated QuotesPage.vue to apply decimal formatting for customer discounts and item details.
- Enhanced SuppliersPage.vue to use decimal input for standard discount percent.
- Added a new SQL migration to set default unit for items to 'Stck'.
- Introduced format.ts for centralized decimal and currency formatting utilities.
2026-06-03 09:25:10 +02:00

177 lines
6.6 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm, Customer } from "../types";
const emptyCustomer = () => ({
customer_number: "",
name: "",
status: "active",
details: {
street: "",
postal_code: "",
city: "",
country: "Deutschland",
email: "",
phone: ""
},
standard_discount_percent: "0",
cash_discount_term_id: null as string | null
});
const customers = ref<Customer[]>([]);
const cashDiscountTerms = ref<CashDiscountTerm[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyCustomer());
const status = ref("");
const statusKind = ref<"info" | "success" | "error">("info");
const pending = ref(false);
const search = ref("");
const filteredCustomers = computed(() =>
customers.value.filter((customer) => matchesObjectSearch(customer.customer_number, customer.name, search.value))
);
async function loadCustomers() {
const result = await apiGet<Customer[]>("/api/v1/customers");
if (!result.ok) {
status.value = result.message;
statusKind.value = "error";
return;
}
customers.value = result.data;
}
async function loadCashDiscountTerms() {
const result = await apiGet<CashDiscountTerm[]>("/api/v1/cash-discount-terms");
if (result.ok) cashDiscountTerms.value = result.data.filter((term) => term.is_active);
}
async function newCustomer() {
selectedId.value = null;
Object.assign(form, emptyCustomer());
form.details = { ...emptyCustomer().details };
form.customer_number = (await reserveNextNumber("customers")) ?? "";
status.value = "Neuer Kunde.";
statusKind.value = "info";
}
function selectCustomer(customer: Customer) {
selectedId.value = customer.id;
Object.assign(form, {
customer_number: customer.customer_number,
name: customer.name,
status: customer.status,
details: { ...customer.details },
standard_discount_percent: customer.standard_discount_percent,
cash_discount_term_id: customer.cash_discount_term_id ?? null
});
}
async function save() {
pending.value = true;
const payload = {
...form,
standard_discount_percent: decimalString(form.standard_discount_percent)
};
const result = selectedId.value
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, payload)
: await apiPost<Customer>("/api/v1/customers", payload);
pending.value = false;
if (!result.ok) {
status.value = result.message;
statusKind.value = "error";
return;
}
selectedId.value = result.data.id;
status.value = "Kunde gespeichert.";
statusKind.value = "success";
await loadCustomers();
}
async function deactivate() {
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`);
status.value = result.ok ? "Kunde deaktiviert." : result.message;
statusKind.value = result.ok ? "success" : "error";
if (result.ok) {
form.status = "inactive";
await loadCustomers();
}
}
onMounted(async () => {
await Promise.all([loadCustomers(), loadCashDiscountTerms()]);
});
watch(
() => liveUpdateState.revision,
() => Promise.all([loadCustomers(), loadCashDiscountTerms()])
);
</script>
<template>
<PageHeader title="Kunden" description="Kundenstamm, Rabatt und Kontaktdaten." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title">
<h2>Kundenliste</h2>
<button type="button" @click="newCustomer">Neu</button>
</div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Kundennummer oder Name" /></label>
<button
v-for="customer in filteredCustomers"
:key="customer.id"
type="button"
class="list-row"
:class="{ selected: selectedId === customer.id }"
@click="selectCustomer(customer)"
>
<strong>{{ customer.customer_number }}</strong>
<span>{{ customer.name }}</span>
<small>{{ customer.status }}</small>
</button>
<p v-if="customers.length === 0" class="empty">Keine Kunden vorhanden.</p>
<p v-else-if="filteredCustomers.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>Kundennummer</span><div class="readonly-value">{{ form.customer_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
<label class="field">
<span>Status</span>
<select v-model="form.status">
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="blocked">Gesperrt</option>
</select>
</label>
<label class="field"><span>Standardrabatt %</span><input v-model="form.standard_discount_percent" inputmode="decimal" required /></label>
<label class="field">
<span>Skonto-Regel</span>
<select v-model="form.cash_discount_term_id">
<option :value="null">Keine</option>
<option v-for="term in cashDiscountTerms" :key="term.id" :value="term.id">{{ term.code }} - {{ term.name }}</option>
</select>
</label>
<label class="field"><span>Straße</span><input v-model="form.details.street" /></label>
<label class="field"><span>PLZ</span><input v-model="form.details.postal_code" /></label>
<label class="field"><span>Ort</span><input v-model="form.details.city" /></label>
<label class="field"><span>Land</span><input v-model="form.details.country" /></label>
<label class="field"><span>E-Mail</span><input v-model="form.details.email" type="email" /></label>
<label class="field"><span>Telefon</span><input v-model="form.details.phone" /></label>
</div>
<div class="form-actions">
<button type="submit" :disabled="pending">Speichern</button>
<button v-if="selectedId" type="button" class="secondary" :disabled="pending" @click="deactivate">Deaktivieren</button>
</div>
<FormStatus :message="status" :kind="statusKind" />
</form>
</section>
</div>
</template>