feat: Add password reset functionality with request and reset forms
feat: Implement price list import feature with preview and apply options feat: Create price rules management page with CRUD operations feat: Develop quotes management page with itemized quotes and status tracking feat: Introduce organization registration page for new users feat: Build suppliers management page with detailed supplier information feat: Create users management page for inviting and managing roles chore: Add TypeScript configuration for improved type checking chore: Set up Vite configuration for development server and API proxy chore: Add Vite environment type definitions for better TypeScript support
This commit is contained in:
171
web-frontend/src/views/CustomersPage.vue
Normal file
171
web-frontend/src/views/CustomersPage.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<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 { 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 result = selectedId.value
|
||||
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, form)
|
||||
: await apiPost<Customer>("/api/v1/customers", form);
|
||||
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" type="number" min="0" max="100" step="0.01" 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>
|
||||
Reference in New Issue
Block a user