- 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.
177 lines
6.6 KiB
Vue
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>
|