feat: enhance forms with decimal formatting and validation
- Updated CustomersPage.vue to use decimalString for standard discount percent. - Enhanced IncomingInvoicesPage.vue to format item quantities, unit prices, and tax rates using decimalString. - Improved ItemsPage.vue with new supplier price management and decimal formatting for prices. - Modified OrganizationSetupPage.vue to use a dropdown for default tax rates and ensure numeric input for payment days. - Updated OutgoingInvoicesPage.vue to apply decimal formatting for customer discounts and item details. - Enhanced PriceImportsPage.vue to include additional fields in the import format. - Improved PriceRulesPage.vue to use decimal input for markup percentages. - Updated QuotesPage.vue to apply decimal formatting for customer discounts and item details. - Enhanced SuppliersPage.vue to use decimal input for standard discount percent. - Added a new SQL migration to set default unit for items to 'Stck'. - Introduced format.ts for centralized decimal and currency formatting utilities.
This commit is contained in:
22
PLANUNG.md
22
PLANUNG.md
@@ -809,8 +809,12 @@ Geplante Daten:
|
||||
- Einkaufspreis
|
||||
- Verkaufspreis
|
||||
- Steuersatz
|
||||
- Lieferant
|
||||
- Herstellerartikelnummer
|
||||
- Hersteller-Code / Herstellerartikelnummer
|
||||
- mehrere Lieferantenreferenzen je Artikel
|
||||
- externe Artikelnummer je Lieferant
|
||||
- Einkaufspreis je Lieferant und externer Artikelnummer
|
||||
- Währung je Lieferantenpreis
|
||||
- Kennzeichnung bevorzugter Lieferant
|
||||
- EAN/GTIN
|
||||
- Preisgültigkeit
|
||||
- Lagerbestand
|
||||
@@ -823,6 +827,8 @@ Preisberechnung:
|
||||
- Einkaufspreis aus Quelle übernehmen
|
||||
- Einkaufspreis mal Multiplikator
|
||||
- Staffelpreise
|
||||
- günstigsten aktiven Lieferantenpreis ermitteln
|
||||
- bevorzugten Lieferanten optional gegenüber dem günstigsten Preis priorisieren
|
||||
- kundenspezifische Preise
|
||||
- kundenbezogener Standardrabatt
|
||||
- positionsbezogener Sonderpreis oder Sonderrabatt
|
||||
@@ -1021,6 +1027,18 @@ Import-Pipeline:
|
||||
8. Verkaufspreise neu berechnen
|
||||
9. betroffene Clients per WebSocket informieren
|
||||
|
||||
Preislistenimporte berücksichtigen optional:
|
||||
|
||||
- Hersteller-Code (`manufacturer_code`)
|
||||
- Lieferantennummer (`supplier_number`)
|
||||
- externe Lieferanten-Artikelnummer (`supplier_item_number`)
|
||||
- Lieferanten-Einkaufspreis (`purchase_price`)
|
||||
- Währung (`currency`)
|
||||
|
||||
Wenn Lieferantennummer und externe Artikelnummer im Import vorhanden sind, wird
|
||||
neben dem internen Artikel eine Lieferantenpreis-Verknüpfung aktualisiert. Die
|
||||
interne Artikelnummer bleibt unabhängig davon der primäre Objekt-Identifier.
|
||||
|
||||
### Frei konfigurierbare Preislisten
|
||||
|
||||
Da Lieferantenpreislisten frei konfigurierbar sein sollen, braucht das System
|
||||
|
||||
2
backend/company-migrations/0013_item_unit_default.sql
Normal file
2
backend/company-migrations/0013_item_unit_default.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
alter table {schema}.items
|
||||
alter column unit set default 'Stck';
|
||||
13
backend/company-migrations/0014_activity_line_sources.sql
Normal file
13
backend/company-migrations/0014_activity_line_sources.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
alter table {schema}.quote_items
|
||||
add column if not exists line_kind text not null default 'item',
|
||||
add column if not exists activity_id uuid references {schema}.activities(id);
|
||||
|
||||
alter table {schema}.quote_items
|
||||
alter column item_id drop not null;
|
||||
|
||||
alter table {schema}.outgoing_invoice_items
|
||||
add column if not exists line_kind text not null default 'item',
|
||||
add column if not exists activity_id uuid references {schema}.activities(id);
|
||||
|
||||
alter table {schema}.outgoing_invoice_items
|
||||
alter column item_id drop not null;
|
||||
@@ -0,0 +1,15 @@
|
||||
alter table {schema}.activities
|
||||
drop constraint if exists activities_status_valid;
|
||||
|
||||
update {schema}.activities
|
||||
set status = case
|
||||
when status in ('open', 'in_progress') then 'active'
|
||||
else 'inactive'
|
||||
end
|
||||
where status in ('open', 'in_progress', 'done', 'cancelled');
|
||||
|
||||
alter table {schema}.activities
|
||||
alter column status set default 'active';
|
||||
|
||||
alter table {schema}.activities
|
||||
add constraint activities_status_valid check (status in ('active', 'inactive'));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -585,6 +585,75 @@ fn number_or_pending(number: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_combo(ui: &mut egui::Ui, unit: &mut String) {
|
||||
let selected_text = if unit.trim().is_empty() {
|
||||
"Bitte wählen".to_string()
|
||||
} else {
|
||||
unit.clone()
|
||||
};
|
||||
egui::ComboBox::from_id_salt(ui.next_auto_id())
|
||||
.selected_text(selected_text)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.text_edit_singleline(unit);
|
||||
ui.separator();
|
||||
for option in ["Stck", "kg", "g", "L", "mg", "ml", "qm", "m", "cm", "mm"] {
|
||||
ui.selectable_value(unit, option.to_string(), option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn tax_rate_combo(ui: &mut egui::Ui, tax_rate: &mut String) {
|
||||
egui::ComboBox::from_id_salt(ui.next_auto_id())
|
||||
.selected_text(format!("{} %", tax_rate.trim()))
|
||||
.show_ui(ui, |ui| {
|
||||
for option in ["0", "7", "19"] {
|
||||
ui.selectable_value(tax_rate, option.to_string(), format!("{option} %"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn normalize_tax_rate(value: &str) -> String {
|
||||
match value.trim().parse::<f64>() {
|
||||
Ok(number) if (number - 0.0).abs() < f64::EPSILON => "0".to_string(),
|
||||
Ok(number) if (number - 7.0).abs() < f64::EPSILON => "7".to_string(),
|
||||
Ok(number) if (number - 19.0).abs() < f64::EPSILON => "19".to_string(),
|
||||
_ => value.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_euro(value: Option<&str>) -> String {
|
||||
let Some(value) = value else {
|
||||
return "-".to_string();
|
||||
};
|
||||
let normalized = if value.contains(',') {
|
||||
value.replace('.', "").replace(',', ".")
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
match normalized.trim().parse::<f64>() {
|
||||
Ok(number) => format!("{} EUR", format_number_de(number, 2)),
|
||||
Err(_) => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number_de(number: f64, decimals: usize) -> String {
|
||||
let raw = format!("{number:.decimals$}");
|
||||
let (integer, fraction) = raw.split_once('.').unwrap_or((&raw, ""));
|
||||
let mut grouped = String::new();
|
||||
for (index, character) in integer.chars().rev().enumerate() {
|
||||
if index > 0 && index % 3 == 0 {
|
||||
grouped.push('.');
|
||||
}
|
||||
grouped.push(character);
|
||||
}
|
||||
let integer = grouped.chars().rev().collect::<String>();
|
||||
if decimals == 0 {
|
||||
integer
|
||||
} else {
|
||||
format!("{integer},{fraction}")
|
||||
}
|
||||
}
|
||||
|
||||
fn customer_combo(
|
||||
ui: &mut egui::Ui,
|
||||
selected_id: &mut String,
|
||||
@@ -688,7 +757,21 @@ fn apply_quote_item_defaults(line: &mut QuoteItemForm, items: &[Item]) {
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0".to_string());
|
||||
line.original_unit_price = item.default_sales_price.clone();
|
||||
line.tax_rate = item.tax_rate.clone();
|
||||
line.tax_rate = normalize_tax_rate(&item.tax_rate);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_quote_customer_defaults(form: &mut QuoteForm, customers: &[Customer]) {
|
||||
if let Some(customer) = customers.iter().find(|customer| customer.id == form.customer_id) {
|
||||
form.customer_discount_percent = customer.standard_discount_percent.clone();
|
||||
form.cash_discount_term_id = customer.cash_discount_term_id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_outgoing_invoice_customer_defaults(form: &mut OutgoingInvoiceForm, customers: &[Customer]) {
|
||||
if let Some(customer) = customers.iter().find(|customer| customer.id == form.customer_id) {
|
||||
form.customer_discount_percent = customer.standard_discount_percent.clone();
|
||||
form.cash_discount_term_id = customer.cash_discount_term_id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,7 +804,7 @@ fn invoice_items_editor(
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0".to_string());
|
||||
line.original_unit_price = item.default_sales_price.clone();
|
||||
line.tax_rate = item.tax_rate.clone();
|
||||
line.tax_rate = normalize_tax_rate(&item.tax_rate);
|
||||
}
|
||||
}
|
||||
form_row(ui, "Beschreibung", |ui| {
|
||||
@@ -741,7 +824,7 @@ fn invoice_items_editor(
|
||||
ui.text_edit_singleline(&mut line.discount_percent);
|
||||
ui.end_row();
|
||||
ui.label("Steuer %");
|
||||
ui.text_edit_singleline(&mut line.tax_rate);
|
||||
tax_rate_combo(ui, &mut line.tax_rate);
|
||||
ui.end_row();
|
||||
});
|
||||
if can_remove && ui.button("Entfernen").clicked() {
|
||||
@@ -794,7 +877,7 @@ fn incoming_invoice_items_editor(
|
||||
ui.text_edit_singleline(&mut line.unit_price);
|
||||
ui.end_row();
|
||||
ui.label("Steuer %");
|
||||
ui.text_edit_singleline(&mut line.tax_rate);
|
||||
tax_rate_combo(ui, &mut line.tax_rate);
|
||||
ui.end_row();
|
||||
});
|
||||
if can_remove && ui.button("Entfernen").clicked() {
|
||||
@@ -1006,6 +1089,7 @@ struct CompanyToolApp {
|
||||
customer_list_search: String,
|
||||
supplier_list_search: String,
|
||||
item_list_search: String,
|
||||
item_sort_mode: String,
|
||||
activity_list_search: String,
|
||||
quote_list_search: String,
|
||||
outgoing_invoice_list_search: String,
|
||||
@@ -1142,6 +1226,7 @@ impl CompanyToolApp {
|
||||
customer_list_search: String::new(),
|
||||
supplier_list_search: String::new(),
|
||||
item_list_search: String::new(),
|
||||
item_sort_mode: "name_desc".to_string(),
|
||||
activity_list_search: String::new(),
|
||||
quote_list_search: String::new(),
|
||||
outgoing_invoice_list_search: String::new(),
|
||||
@@ -1626,7 +1711,7 @@ impl CompanyToolApp {
|
||||
},
|
||||
AdminEvent::ActivityDeleted(result) => {
|
||||
self.activities_status = result
|
||||
.map(|_| "Aktivität storniert.".to_string())
|
||||
.map(|_| "Aktivität deaktiviert.".to_string())
|
||||
.unwrap_or_else(|message| message);
|
||||
self.load_activities();
|
||||
}
|
||||
@@ -2638,6 +2723,7 @@ impl CompanyToolApp {
|
||||
self.restore_window("items");
|
||||
self.items_window_open = true;
|
||||
self.load_items();
|
||||
self.load_suppliers();
|
||||
}
|
||||
|
||||
fn open_activities_window(&mut self) {
|
||||
@@ -4091,8 +4177,25 @@ impl CompanyToolApp {
|
||||
self.reserve_next_number("items");
|
||||
}
|
||||
ui.text_edit_singleline(&mut self.item_list_search);
|
||||
egui::ComboBox::from_id_salt("item_sort_mode")
|
||||
.selected_text(match self.item_sort_mode.as_str() {
|
||||
"number_desc" => "Artikelnummer absteigend",
|
||||
_ => "Artikelname absteigend",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(
|
||||
&mut self.item_sort_mode,
|
||||
"name_desc".to_string(),
|
||||
"Artikelname absteigend",
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut self.item_sort_mode,
|
||||
"number_desc".to_string(),
|
||||
"Artikelnummer absteigend",
|
||||
);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let filtered_items: Vec<Item> = self
|
||||
let mut filtered_items: Vec<Item> = self
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
@@ -4104,6 +4207,18 @@ impl CompanyToolApp {
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if self.item_sort_mode == "number_desc" {
|
||||
filtered_items.sort_by(|left, right| {
|
||||
right
|
||||
.item_number
|
||||
.to_lowercase()
|
||||
.cmp(&left.item_number.to_lowercase())
|
||||
});
|
||||
} else {
|
||||
filtered_items.sort_by(|left, right| {
|
||||
right.name.to_lowercase().cmp(&left.name.to_lowercase())
|
||||
});
|
||||
}
|
||||
for item in filtered_items {
|
||||
if ui
|
||||
.selectable_label(
|
||||
@@ -4125,14 +4240,17 @@ impl CompanyToolApp {
|
||||
form_row(ui, "Artikelnummer", |ui| {
|
||||
ui.label(number_or_pending(&self.item_form.item_number));
|
||||
});
|
||||
form_row(ui, "Hersteller-Code", |ui| {
|
||||
ui.text_edit_singleline(&mut self.item_form.manufacturer_code);
|
||||
});
|
||||
form_row(ui, "Bezeichnung", |ui| {
|
||||
ui.text_edit_singleline(&mut self.item_form.name);
|
||||
});
|
||||
form_row(ui, "Einheit", |ui| {
|
||||
ui.text_edit_singleline(&mut self.item_form.unit);
|
||||
unit_combo(ui, &mut self.item_form.unit);
|
||||
});
|
||||
form_row(ui, "Steuersatz %", |ui| {
|
||||
ui.text_edit_singleline(&mut self.item_form.tax_rate);
|
||||
tax_rate_combo(ui, &mut self.item_form.tax_rate);
|
||||
});
|
||||
form_row(ui, "Einkaufspreis", |ui| {
|
||||
ui.text_edit_singleline(&mut self.item_form.default_purchase_price);
|
||||
@@ -4140,6 +4258,46 @@ impl CompanyToolApp {
|
||||
form_row(ui, "Verkaufspreis", |ui| {
|
||||
ui.text_edit_singleline(&mut self.item_form.default_sales_price);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Lieferantenpreise");
|
||||
if ui.button("Lieferant hinzufügen").clicked() {
|
||||
self.item_form
|
||||
.supplier_prices
|
||||
.push(ItemSupplierPriceForm::default());
|
||||
}
|
||||
});
|
||||
let suppliers = self.suppliers.clone();
|
||||
let mut remove_supplier_price = None;
|
||||
for (index, supplier_price) in
|
||||
self.item_form.supplier_prices.iter_mut().enumerate()
|
||||
{
|
||||
ui.separator();
|
||||
form_row(ui, "Lieferant", |ui| {
|
||||
supplier_combo(
|
||||
ui,
|
||||
&mut supplier_price.supplier_id,
|
||||
&suppliers,
|
||||
&mut self.supplier_lookup_search,
|
||||
);
|
||||
});
|
||||
form_row(ui, "Externe Artikelnr.", |ui| {
|
||||
ui.text_edit_singleline(&mut supplier_price.external_item_number);
|
||||
});
|
||||
form_row(ui, "Einkaufspreis", |ui| {
|
||||
ui.text_edit_singleline(&mut supplier_price.purchase_price);
|
||||
});
|
||||
form_row(ui, "Währung", |ui| {
|
||||
ui.text_edit_singleline(&mut supplier_price.currency);
|
||||
});
|
||||
ui.checkbox(&mut supplier_price.is_preferred, "Bevorzugt");
|
||||
if ui.button("Lieferantenpreis entfernen").clicked() {
|
||||
remove_supplier_price = Some(index);
|
||||
}
|
||||
}
|
||||
if let Some(index) = remove_supplier_price {
|
||||
self.item_form.supplier_prices.remove(index);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Speichern").clicked() {
|
||||
self.save_item();
|
||||
@@ -4166,12 +4324,9 @@ impl CompanyToolApp {
|
||||
ui.label(format!("ID {}", &entry.id[..entry.id.len().min(8)]));
|
||||
ui.label(format!(
|
||||
"EK {}",
|
||||
entry.purchase_price.as_deref().unwrap_or("-")
|
||||
));
|
||||
ui.label(format!(
|
||||
"VK {}",
|
||||
entry.sales_price.as_deref().unwrap_or("-")
|
||||
format_euro(entry.purchase_price.as_deref())
|
||||
));
|
||||
ui.label(format!("VK {}", format_euro(entry.sales_price.as_deref())));
|
||||
ui.label(&entry.source);
|
||||
ui.label(format!(
|
||||
"Item {}",
|
||||
@@ -4237,6 +4392,8 @@ impl CompanyToolApp {
|
||||
ui.label("Aktion");
|
||||
ui.label("Artikelnummer");
|
||||
ui.label("Name");
|
||||
ui.label("Hersteller");
|
||||
ui.label("Lieferant");
|
||||
ui.label("Einheit");
|
||||
ui.label("Steuer");
|
||||
ui.label("EK");
|
||||
@@ -4248,9 +4405,26 @@ impl CompanyToolApp {
|
||||
ui.label(&row.action);
|
||||
ui.label(&row.item_number);
|
||||
ui.label(&row.name);
|
||||
ui.label(row.manufacturer_code.as_deref().unwrap_or("-"));
|
||||
ui.label(
|
||||
row.supplier_number
|
||||
.as_deref()
|
||||
.map(|supplier| {
|
||||
format!(
|
||||
"{} / {}",
|
||||
supplier,
|
||||
row.supplier_item_number.as_deref().unwrap_or("-")
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
);
|
||||
ui.label(&row.unit);
|
||||
ui.label(&row.tax_rate);
|
||||
ui.label(row.purchase_price.as_deref().unwrap_or("-"));
|
||||
ui.label(format!(
|
||||
"{} {}",
|
||||
row.purchase_price.as_deref().unwrap_or("-"),
|
||||
row.currency.as_deref().unwrap_or("EUR")
|
||||
));
|
||||
ui.label(row.sales_price.as_deref().unwrap_or("-"));
|
||||
ui.label(row.error.as_deref().unwrap_or("-"));
|
||||
ui.end_row();
|
||||
@@ -4624,12 +4798,16 @@ impl CompanyToolApp {
|
||||
ui.label(number_or_pending(&self.quote_form.quote_number));
|
||||
});
|
||||
form_row(ui, "Kunde", |ui| {
|
||||
let previous_customer_id = self.quote_form.customer_id.clone();
|
||||
customer_combo(
|
||||
ui,
|
||||
&mut self.quote_form.customer_id,
|
||||
&self.customers,
|
||||
&mut self.customer_lookup_search,
|
||||
);
|
||||
if self.quote_form.customer_id != previous_customer_id {
|
||||
apply_quote_customer_defaults(&mut self.quote_form, &self.customers);
|
||||
}
|
||||
});
|
||||
form_row(ui, "Status", |ui| {
|
||||
egui::ComboBox::from_id_salt("quote_status")
|
||||
@@ -4703,7 +4881,7 @@ impl CompanyToolApp {
|
||||
ui.text_edit_singleline(&mut line.discount_percent);
|
||||
ui.end_row();
|
||||
ui.label("Steuer %");
|
||||
ui.text_edit_singleline(&mut line.tax_rate);
|
||||
tax_rate_combo(ui, &mut line.tax_rate);
|
||||
ui.end_row();
|
||||
});
|
||||
if can_remove_quote_item && ui.button("Position entfernen").clicked() {
|
||||
@@ -4814,12 +4992,20 @@ impl CompanyToolApp {
|
||||
));
|
||||
});
|
||||
form_row(ui, "Kunde", |ui| {
|
||||
let previous_customer_id =
|
||||
self.outgoing_invoice_form.customer_id.clone();
|
||||
customer_combo(
|
||||
ui,
|
||||
&mut self.outgoing_invoice_form.customer_id,
|
||||
&self.customers,
|
||||
&mut self.customer_lookup_search,
|
||||
);
|
||||
if self.outgoing_invoice_form.customer_id != previous_customer_id {
|
||||
apply_outgoing_invoice_customer_defaults(
|
||||
&mut self.outgoing_invoice_form,
|
||||
&self.customers,
|
||||
);
|
||||
}
|
||||
});
|
||||
form_row(ui, "Status", |ui| {
|
||||
ui.text_edit_singleline(&mut self.outgoing_invoice_form.status);
|
||||
@@ -5063,7 +5249,23 @@ impl CompanyToolApp {
|
||||
ui.text_edit_singleline(&mut self.activity_form.activity_type);
|
||||
});
|
||||
form_row(ui, "Status", |ui| {
|
||||
ui.text_edit_singleline(&mut self.activity_form.status);
|
||||
egui::ComboBox::from_id_salt("activity_status")
|
||||
.selected_text(match self.activity_form.status.as_str() {
|
||||
"inactive" => "Nicht aktiv",
|
||||
_ => "Aktiv",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(
|
||||
&mut self.activity_form.status,
|
||||
"active".to_string(),
|
||||
"Aktiv",
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut self.activity_form.status,
|
||||
"inactive".to_string(),
|
||||
"Nicht aktiv",
|
||||
);
|
||||
});
|
||||
});
|
||||
form_row(ui, "Priorität", |ui| {
|
||||
ui.text_edit_singleline(&mut self.activity_form.priority);
|
||||
@@ -5078,7 +5280,7 @@ impl CompanyToolApp {
|
||||
if ui
|
||||
.add_enabled(
|
||||
self.selected_activity_id.is_some(),
|
||||
egui::Button::new("Stornieren"),
|
||||
egui::Button::new("Deaktivieren"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
@@ -5536,11 +5738,22 @@ struct Item {
|
||||
id: String,
|
||||
item_number: String,
|
||||
name: String,
|
||||
manufacturer_code: Option<String>,
|
||||
unit: String,
|
||||
tax_rate: String,
|
||||
default_purchase_price: Option<String>,
|
||||
default_sales_price: Option<String>,
|
||||
status: String,
|
||||
supplier_prices: Vec<ItemSupplierPrice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ItemSupplierPrice {
|
||||
supplier_id: String,
|
||||
external_item_number: String,
|
||||
purchase_price: String,
|
||||
currency: String,
|
||||
is_preferred: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -5558,22 +5771,35 @@ struct ItemPriceHistory {
|
||||
struct ItemForm {
|
||||
item_number: String,
|
||||
name: String,
|
||||
manufacturer_code: String,
|
||||
unit: String,
|
||||
tax_rate: String,
|
||||
default_purchase_price: String,
|
||||
default_sales_price: String,
|
||||
status: String,
|
||||
supplier_prices: Vec<ItemSupplierPriceForm>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ItemSupplierPriceForm {
|
||||
supplier_id: String,
|
||||
external_item_number: String,
|
||||
purchase_price: String,
|
||||
currency: String,
|
||||
is_preferred: bool,
|
||||
}
|
||||
impl Default for ItemForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
item_number: String::new(),
|
||||
name: String::new(),
|
||||
unit: "Stk".to_string(),
|
||||
manufacturer_code: String::new(),
|
||||
unit: "Stck".to_string(),
|
||||
tax_rate: "19".to_string(),
|
||||
default_purchase_price: "0".to_string(),
|
||||
default_sales_price: "0".to_string(),
|
||||
status: "active".to_string(),
|
||||
supplier_prices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5582,11 +5808,41 @@ impl From<&Item> for ItemForm {
|
||||
Self {
|
||||
item_number: value.item_number.clone(),
|
||||
name: value.name.clone(),
|
||||
manufacturer_code: value.manufacturer_code.clone().unwrap_or_default(),
|
||||
unit: value.unit.clone(),
|
||||
tax_rate: value.tax_rate.clone(),
|
||||
tax_rate: normalize_tax_rate(&value.tax_rate),
|
||||
default_purchase_price: value.default_purchase_price.clone().unwrap_or_default(),
|
||||
default_sales_price: value.default_sales_price.clone().unwrap_or_default(),
|
||||
status: value.status.clone(),
|
||||
supplier_prices: value
|
||||
.supplier_prices
|
||||
.iter()
|
||||
.map(ItemSupplierPriceForm::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ItemSupplierPriceForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
supplier_id: String::new(),
|
||||
external_item_number: String::new(),
|
||||
purchase_price: "0".to_string(),
|
||||
currency: "EUR".to_string(),
|
||||
is_preferred: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ItemSupplierPrice> for ItemSupplierPriceForm {
|
||||
fn from(value: &ItemSupplierPrice) -> Self {
|
||||
Self {
|
||||
supplier_id: value.supplier_id.clone(),
|
||||
external_item_number: value.external_item_number.clone(),
|
||||
purchase_price: value.purchase_price.clone(),
|
||||
currency: value.currency.clone(),
|
||||
is_preferred: value.is_preferred,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5695,7 +5951,7 @@ impl From<&QuoteItem> for QuoteItemForm {
|
||||
unit_price: value.unit_price.clone(),
|
||||
original_unit_price: value.original_unit_price.clone(),
|
||||
discount_percent: value.discount_percent.clone(),
|
||||
tax_rate: value.tax_rate.clone(),
|
||||
tax_rate: normalize_tax_rate(&value.tax_rate),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5813,7 +6069,7 @@ impl From<&OutgoingInvoiceItem> for OutgoingInvoiceItemForm {
|
||||
unit_price: value.unit_price.clone(),
|
||||
original_unit_price: value.original_unit_price.clone(),
|
||||
discount_percent: value.discount_percent.clone(),
|
||||
tax_rate: value.tax_rate.clone(),
|
||||
tax_rate: normalize_tax_rate(&value.tax_rate),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5913,7 +6169,7 @@ impl From<&IncomingInvoiceItem> for IncomingInvoiceItemForm {
|
||||
description: value.description.clone(),
|
||||
quantity: value.quantity.clone(),
|
||||
unit_price: value.unit_price.clone(),
|
||||
tax_rate: value.tax_rate.clone(),
|
||||
tax_rate: normalize_tax_rate(&value.tax_rate),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5946,7 +6202,7 @@ impl Default for ActivityForm {
|
||||
activity_type: "task".to_string(),
|
||||
title: String::new(),
|
||||
body: String::new(),
|
||||
status: "open".to_string(),
|
||||
status: "active".to_string(),
|
||||
priority: "normal".to_string(),
|
||||
due_at: None,
|
||||
}
|
||||
@@ -5977,7 +6233,7 @@ impl Default for PriceListImportForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source_name: "Preisliste.csv".to_string(),
|
||||
content: "item_number;name;unit;tax_rate;purchase_price;sales_price\nAR-IMPORT-1;Importartikel;Stk;19;10.00;25.00".to_string(),
|
||||
content: "item_number;name;manufacturer_code;unit;tax_rate;purchase_price;sales_price;supplier_number;supplier_item_number;currency\nAR-IMPORT-1;Importartikel;HERST-001;Stck;19,00;10,00;25,00;LI000000001;EXT-4711;EUR".to_string(),
|
||||
delimiter: Some(";".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -5996,10 +6252,14 @@ struct PriceListImportRow {
|
||||
row_number: usize,
|
||||
item_number: String,
|
||||
name: String,
|
||||
manufacturer_code: Option<String>,
|
||||
unit: String,
|
||||
tax_rate: String,
|
||||
purchase_price: Option<String>,
|
||||
sales_price: Option<String>,
|
||||
supplier_number: Option<String>,
|
||||
supplier_item_number: Option<String>,
|
||||
currency: Option<String>,
|
||||
action: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ async function apiRequest<T>(method: string, path: string, body?: unknown): Prom
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
const data = text ? parseResponseBody(text) : {};
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, message: data.message ?? `HTTP ${response.status}` };
|
||||
return { ok: false, message: responseMessage(data, text, response.status) };
|
||||
}
|
||||
|
||||
return { ok: true, data };
|
||||
return { ok: true, data: data as T };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -49,3 +49,20 @@ async function apiRequest<T>(method: string, path: string, body?: unknown): Prom
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseResponseBody(text: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : { message: String(parsed) };
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
function responseMessage(data: Record<string, unknown>, text: string, status: number): string {
|
||||
for (const key of ["message", "detail"]) {
|
||||
const value = data[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
return text || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string | null];
|
||||
change: [];
|
||||
change: [value: string | null];
|
||||
}>();
|
||||
|
||||
const query = ref("");
|
||||
const open = ref(false);
|
||||
|
||||
const selected = computed(() => props.options.find((option) => option.id === props.modelValue));
|
||||
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
|
||||
@@ -33,7 +34,15 @@ const filteredOptions = computed(() => {
|
||||
|
||||
function select(value: string | null) {
|
||||
emit("update:modelValue", value);
|
||||
emit("change");
|
||||
emit("change", value);
|
||||
query.value = "";
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function closeSoon() {
|
||||
window.setTimeout(() => {
|
||||
open.value = false;
|
||||
}, 120);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -44,11 +53,14 @@ function select(value: string | null) {
|
||||
type="search"
|
||||
:placeholder="placeholder ?? 'Nach Nummer oder Name suchen'"
|
||||
:required="required && !modelValue"
|
||||
@focus="open = true"
|
||||
@input="open = true"
|
||||
@blur="closeSoon"
|
||||
/>
|
||||
<div v-if="selected" class="search-select-current">
|
||||
Ausgewählt: <strong>{{ selected.number ? `${selected.number} - ${selected.name}` : selected.name }}</strong>
|
||||
</div>
|
||||
<div class="search-select-options">
|
||||
<div v-if="open" class="search-select-options">
|
||||
<button
|
||||
v-if="allowEmpty"
|
||||
type="button"
|
||||
|
||||
62
web-frontend/src/format.ts
Normal file
62
web-frontend/src/format.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const decimalFormatter = new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4
|
||||
});
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4
|
||||
});
|
||||
|
||||
export const unitOptions = ["Stck", "kg", "g", "L", "mg", "ml", "qm", "m", "cm", "mm"];
|
||||
export const taxRateOptions = ["0", "7", "19"];
|
||||
|
||||
export function formatDecimal(value: string | number | null | undefined): string {
|
||||
const number = toNumber(value);
|
||||
return number === null ? "" : decimalFormatter.format(number);
|
||||
}
|
||||
|
||||
export function formatEuro(value: string | number | null | undefined): string {
|
||||
const number = toNumber(value);
|
||||
return number === null ? "-" : currencyFormatter.format(number);
|
||||
}
|
||||
|
||||
export function normalizeDecimal(value: string | number | null | undefined): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const text = String(value).trim();
|
||||
if (!text) return null;
|
||||
const compact = text.replace(/\s/g, "");
|
||||
if (compact.includes(",")) return compact.replace(/\./g, "").replace(",", ".");
|
||||
const dotCount = (compact.match(/\./g) ?? []).length;
|
||||
if (dotCount > 1) return compact.replace(/\./g, "");
|
||||
if (/^-?\d{1,3}\.\d{3}$/.test(compact)) return compact.replace(".", "");
|
||||
return compact;
|
||||
}
|
||||
|
||||
export function formatDecimalInput<T extends Record<string, unknown>>(target: T, key: keyof T) {
|
||||
const normalized = normalizeDecimal(target[key] as string | number | null | undefined);
|
||||
target[key] = (normalized === null ? null : formatDecimal(normalized)) as T[keyof T];
|
||||
}
|
||||
|
||||
export function decimalString(value: string | number | null | undefined, fallback = "0"): string {
|
||||
return normalizeDecimal(value) ?? fallback;
|
||||
}
|
||||
|
||||
export function normalizeTaxRate(value: string | number | null | undefined, fallback = "19"): string {
|
||||
const normalized = normalizeDecimal(value);
|
||||
if (normalized === null) return fallback;
|
||||
const number = Number(normalized);
|
||||
if (!Number.isFinite(number)) return normalized;
|
||||
const compact = String(number);
|
||||
return taxRateOptions.includes(compact) ? compact : normalized;
|
||||
}
|
||||
|
||||
function toNumber(value: string | number | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const normalized = normalizeDecimal(value);
|
||||
if (normalized === null) return null;
|
||||
const number = Number(normalized);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
@@ -422,10 +422,58 @@ code {
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.quote-line:has(.position-summary) {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.position-table-header {
|
||||
align-items: center;
|
||||
color: #65757b;
|
||||
display: grid;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
|
||||
padding: 0 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.position-table-header.compact {
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
|
||||
}
|
||||
|
||||
.position-summary {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-column: span 5;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
|
||||
.position-summary.compact {
|
||||
grid-column: span 5;
|
||||
}
|
||||
|
||||
.position-summary span {
|
||||
color: #435258;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.position-summary strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.position-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
max-width: 720px;
|
||||
}
|
||||
@@ -566,22 +614,31 @@ select[multiple] {
|
||||
|
||||
.search-select {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-select-current {
|
||||
color: #435258;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.search-select-options {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3e6;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 14px 28px rgba(15, 37, 45, 0.16);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
left: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.search-option {
|
||||
|
||||
@@ -52,11 +52,27 @@ export type Item = {
|
||||
id: string;
|
||||
item_number: string;
|
||||
name: string;
|
||||
manufacturer_code?: string | null;
|
||||
unit: string;
|
||||
tax_rate: string;
|
||||
default_purchase_price?: string | null;
|
||||
default_sales_price?: string | null;
|
||||
status: string;
|
||||
supplier_prices: ItemSupplierPrice[];
|
||||
};
|
||||
|
||||
export type ItemSupplierPrice = {
|
||||
id?: string;
|
||||
supplier_id: string;
|
||||
supplier_number?: string;
|
||||
supplier_name?: string;
|
||||
external_item_number: string;
|
||||
purchase_price: string;
|
||||
currency: string;
|
||||
is_preferred: boolean;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type ItemPriceHistory = {
|
||||
@@ -108,7 +124,9 @@ export type NumberRange = {
|
||||
export type QuoteItem = {
|
||||
id?: string;
|
||||
line_number?: number;
|
||||
item_id: string;
|
||||
line_kind?: "item" | "activity";
|
||||
item_id?: string | null;
|
||||
activity_id?: string | null;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unit_price: string;
|
||||
@@ -171,10 +189,14 @@ export type PriceListImportRow = {
|
||||
row_number: number;
|
||||
item_number: string;
|
||||
name: string;
|
||||
manufacturer_code?: string | null;
|
||||
unit: string;
|
||||
tax_rate: string;
|
||||
purchase_price?: string | null;
|
||||
sales_price?: string | null;
|
||||
supplier_number?: string | null;
|
||||
supplier_item_number?: string | null;
|
||||
currency?: string | null;
|
||||
action: string;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import PageHeader from "../components/PageHeader.vue";
|
||||
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { Activity } from "../types";
|
||||
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "task", title: "", body: "", status: "open", priority: "normal", due_at: null as string | null });
|
||||
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "task", title: "", body: "", status: "active", priority: "normal", due_at: null as string | null });
|
||||
const records = ref<Activity[]>([]); const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
|
||||
const filteredRecords = computed(() =>
|
||||
records.value.filter((record) => matchesObjectSearch(record.activity_number, record.title, search.value))
|
||||
@@ -15,7 +15,7 @@ async function load() { const r = await apiGet<Activity[]>("/api/v1/activities")
|
||||
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.activity_number = await reserveNextNumber("activities"); }
|
||||
function select(record: Activity) { selectedId.value = record.id; Object.assign(form, record); }
|
||||
async function save() { const r = selectedId.value ? await apiPut<Activity>(`/api/v1/activities/${selectedId.value}`, form) : await apiPost<Activity>("/api/v1/activities", form); status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await load(); } }
|
||||
async function cancel() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität storniert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) await load(); }
|
||||
async function deactivate() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität deaktiviert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { form.status = "inactive"; await load(); } }
|
||||
onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||
</script>
|
||||
<template>
|
||||
@@ -31,9 +31,9 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||
<label class="field"><span>Aktivitätsnummer</span><div class="readonly-value">{{ form.activity_number || "wird automatisch vergeben" }}</div></label>
|
||||
<label class="field"><span>Typ</span><select v-model="form.activity_type"><option value="task">Aufgabe</option><option value="follow_up">Wiedervorlage</option><option value="phone_note">Telefonnotiz</option><option value="internal_note">Interne Notiz</option></select></label>
|
||||
<label class="field"><span>Titel</span><input v-model="form.title" required /></label>
|
||||
<label class="field"><span>Status</span><select v-model="form.status"><option value="open">Offen</option><option value="in_progress">In Bearbeitung</option><option value="done">Erledigt</option><option value="cancelled">Storniert</option></select></label>
|
||||
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Nicht aktiv</option></select></label>
|
||||
<label class="field"><span>Priorität</span><select v-model="form.priority"><option value="low">Niedrig</option><option value="normal">Normal</option><option value="high">Hoch</option><option value="critical">Kritisch</option></select></label>
|
||||
<label class="field full-width"><span>Beschreibung</span><textarea v-model="form.body" rows="5" /></label>
|
||||
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="cancel">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
|
||||
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import { decimalString } from "../format";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { CashDiscountTerm } from "../types";
|
||||
|
||||
@@ -53,9 +54,13 @@ function select(term: CashDiscountTerm) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = {
|
||||
...form,
|
||||
discount_percent: decimalString(form.discount_percent)
|
||||
};
|
||||
const result = selectedId.value
|
||||
? await apiPut<CashDiscountTerm>(`/api/v1/cash-discount-terms/${selectedId.value}`, form)
|
||||
: await apiPost<CashDiscountTerm>("/api/v1/cash-discount-terms", form);
|
||||
? await apiPut<CashDiscountTerm>(`/api/v1/cash-discount-terms/${selectedId.value}`, payload)
|
||||
: await apiPost<CashDiscountTerm>("/api/v1/cash-discount-terms", payload);
|
||||
status.value = result.ok ? "Skonto-Regel gespeichert." : result.message;
|
||||
kind.value = result.ok ? "success" : "error";
|
||||
if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||
@@ -74,7 +79,7 @@ watch(() => liveUpdateState.revision, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="Skonto" description="Zahlungsbedingungen für Kunden, Lieferanten und Belege." />
|
||||
<PageHeader title="Skonto-Regeln" description="Zahlungsbedingungen für Kunden, Lieferanten und Belege." />
|
||||
<div class="workspace-split">
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
@@ -90,7 +95,7 @@ watch(() => liveUpdateState.revision, load);
|
||||
<div class="form-grid">
|
||||
<label class="field"><span>Code</span><input v-model="form.code" required /></label>
|
||||
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
|
||||
<label class="field"><span>Skonto %</span><input v-model="form.discount_percent" type="number" min="0" max="100" step="0.01" required /></label>
|
||||
<label class="field"><span>Skonto %</span><input v-model="form.discount_percent" inputmode="decimal" required /></label>
|
||||
<label class="field"><span>Skontofrist Tage</span><input v-model="form.discount_days" type="number" min="0" required /></label>
|
||||
<label class="field"><span>Nettoziel Tage</span><input v-model="form.net_days" type="number" min="0" /></label>
|
||||
<label class="field"><span>Gültig ab</span><input v-model="form.valid_from" type="date" /></label>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import { decimalString } from "../format";
|
||||
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { CashDiscountTerm, Customer } from "../types";
|
||||
@@ -73,9 +74,13 @@ function selectCustomer(customer: Customer) {
|
||||
|
||||
async function save() {
|
||||
pending.value = true;
|
||||
const payload = {
|
||||
...form,
|
||||
standard_discount_percent: decimalString(form.standard_discount_percent)
|
||||
};
|
||||
const result = selectedId.value
|
||||
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, form)
|
||||
: await apiPost<Customer>("/api/v1/customers", form);
|
||||
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, payload)
|
||||
: await apiPost<Customer>("/api/v1/customers", payload);
|
||||
pending.value = false;
|
||||
if (!result.ok) {
|
||||
status.value = result.message;
|
||||
@@ -145,7 +150,7 @@ watch(
|
||||
<option value="blocked">Gesperrt</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><span>Standardrabatt %</span><input v-model="form.standard_discount_percent" type="number" min="0" max="100" step="0.01" required /></label>
|
||||
<label class="field"><span>Standardrabatt %</span><input v-model="form.standard_discount_percent" inputmode="decimal" required /></label>
|
||||
<label class="field">
|
||||
<span>Skonto-Regel</span>
|
||||
<select v-model="form.cash_discount_term_id">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import SearchSelect from "../components/SearchSelect.vue";
|
||||
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
|
||||
import { reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { IncomingInvoice, IncomingInvoiceItem, Item, Supplier } from "../types";
|
||||
@@ -12,6 +13,7 @@ const emptyItem = (): IncomingInvoiceItem => ({ item_id: null, description: "",
|
||||
const emptyForm = () => ({ invoice_number: "", supplier_id: "", status: "received", cash_discount_term_id: null as string | null, invoice_date: null as string | null, due_at: null as string | null, items: [emptyItem()] });
|
||||
const invoices = ref<IncomingInvoice[]>([]); const suppliers = ref<Supplier[]>([]); const items = ref<Item[]>([]);
|
||||
const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
|
||||
const editingLineIndex = ref(0);
|
||||
const supplierName = (supplierId: string) => suppliers.value.find((supplier) => supplier.id === supplierId)?.name ?? supplierId;
|
||||
const filteredInvoices = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase();
|
||||
@@ -21,11 +23,34 @@ const filteredInvoices = computed(() => {
|
||||
);
|
||||
});
|
||||
async function load() { const [ir, sr, itemr] = await Promise.all([apiGet<IncomingInvoice[]>("/api/v1/incoming-invoices"), apiGet<Supplier[]>("/api/v1/suppliers"), apiGet<Item[]>("/api/v1/items")]); if (ir.ok) invoices.value = ir.data; else { status.value = ir.message; kind.value = "error"; } if (sr.ok) suppliers.value = sr.data.filter((supplier) => supplier.status === "active"); if (itemr.ok) items.value = itemr.data.filter((item) => item.status === "active"); }
|
||||
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.invoice_number = (await reserveNextNumber("incoming_invoices")) ?? ""; }
|
||||
function select(invoice: IncomingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item })) }); }
|
||||
function addLine() { form.items.push(emptyItem()); }
|
||||
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); }
|
||||
async function save() { const result = selectedId.value ? await apiPut<IncomingInvoice>(`/api/v1/incoming-invoices/${selectedId.value}`, form) : await apiPost<IncomingInvoice>("/api/v1/incoming-invoices", form); status.value = result.ok ? "Eingangsrechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); } }
|
||||
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; form.invoice_number = (await reserveNextNumber("incoming_invoices")) ?? ""; }
|
||||
function select(invoice: IncomingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item, tax_rate: normalizeTaxRate(item.tax_rate) })) }); editingLineIndex.value = 0; }
|
||||
function addLine() { form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
|
||||
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1); }
|
||||
function itemName(itemId: string | null | undefined) {
|
||||
if (!itemId) return "Kein Artikel gewählt";
|
||||
const item = items.value.find((record) => record.id === itemId);
|
||||
return item ? `${item.item_number} - ${item.name}` : itemId;
|
||||
}
|
||||
function lineTotal(line: IncomingInvoiceItem) {
|
||||
const quantity = Number(decimalString(line.quantity, "0"));
|
||||
const unitPrice = Number(decimalString(line.unit_price, "0"));
|
||||
const total = quantity * unitPrice;
|
||||
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
|
||||
}
|
||||
async function save() {
|
||||
const payload = {
|
||||
...form,
|
||||
items: form.items.map((item) => ({
|
||||
...item,
|
||||
quantity: decimalString(item.quantity, "1"),
|
||||
unit_price: decimalString(item.unit_price),
|
||||
tax_rate: normalizeTaxRate(item.tax_rate)
|
||||
}))
|
||||
};
|
||||
const result = selectedId.value ? await apiPut<IncomingInvoice>(`/api/v1/incoming-invoices/${selectedId.value}`, payload) : await apiPost<IncomingInvoice>("/api/v1/incoming-invoices", payload);
|
||||
status.value = result.ok ? "Eingangsrechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||
}
|
||||
async function cancelInvoice() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/incoming-invoices/${selectedId.value}`); status.value = result.ok ? "Eingangsrechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||
onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||
</script>
|
||||
@@ -33,6 +58,6 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||
<PageHeader title="Eingangsrechnungen" description="Lieferantenrechnungen mit Skonto-Bezug und Positionen." />
|
||||
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Eingangsrechnungen</h2><button type="button" @click="createNew">Neu</button></div><label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Lieferant oder Status" /></label><button v-for="invoice in filteredInvoices" :key="invoice.id" type="button" class="list-row" :class="{ selected: selectedId === invoice.id }" @click="select(invoice)"><strong>{{ invoice.invoice_number }}</strong><span>{{ supplierName(invoice.supplier_id) }}</span><small>{{ invoice.status }}</small></button><p v-if="invoices.length === 0" class="empty">Keine Eingangsrechnungen vorhanden.</p><p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p></section>
|
||||
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"><label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label><label class="field"><span>Lieferant</span><SearchSelect v-model="form.supplier_id" :options="suppliers.map((supplier) => ({ id: supplier.id, number: supplier.supplier_number, name: supplier.name }))" placeholder="Lieferanten-Nr. oder Name suchen" required /></label><label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="received">Erhalten</option><option value="approved">Freigegeben</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label><label class="field"><span>Datum</span><input v-model="form.invoice_date" type="date" /></label><label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label></div>
|
||||
<div class="sub-panel"><div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div><div v-for="(line, index) in form.items" :key="index" class="quote-line"><label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" allow-empty empty-label="Kein Artikel" /></label><label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" /></label><label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label></div></div>
|
||||
<div class="sub-panel"><div class="section-title"><h2>Positionen</h2></div><div class="position-table-header compact"><span>Position</span><span>Menge</span><span>Einzelpreis</span><span>Gesamtpreis</span><span>Steuer</span><span>Aktion</span></div><div v-for="(line, index) in form.items" :key="index" class="quote-line"><template v-if="editingLineIndex !== index"><div class="position-summary compact"><strong>{{ itemName(line.item_id) }}</strong><span>{{ line.quantity }}</span><span>{{ line.unit_price }}</span><span>{{ lineTotal(line) }}</span><span>{{ line.tax_rate }} %</span></div><button type="button" class="secondary" @click="editingLineIndex = index">Bearbeiten</button></template><template v-else><label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" allow-empty empty-label="Kein Artikel" /></label><label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" /></label><label class="field"><span>Steuer %</span><select v-model="line.tax_rate"><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label><label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label><div class="position-actions"><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><button type="button" @click="addLine">Position hinzufügen</button></div></template></div></div>
|
||||
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section></div>
|
||||
</template>
|
||||
|
||||
@@ -3,33 +3,89 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import SearchSelect from "../components/SearchSelect.vue";
|
||||
import { formatDecimal, formatDecimalInput, formatEuro, normalizeDecimal, normalizeTaxRate, taxRateOptions, unitOptions } from "../format";
|
||||
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { Item, ItemPriceHistory } from "../types";
|
||||
import type { Item, ItemPriceHistory, ItemSupplierPrice, Supplier } from "../types";
|
||||
|
||||
const emptyForm = () => ({ item_number: "", name: "", unit: "Stk", tax_rate: "19", default_purchase_price: null as string | null, default_sales_price: null as string | null, status: "active" });
|
||||
const emptySupplierPrice = (): ItemSupplierPrice => ({ supplier_id: "", external_item_number: "", purchase_price: "0,00", currency: "EUR", is_preferred: false, valid_from: null, valid_until: null });
|
||||
const emptyForm = () => ({ item_number: "", name: "", manufacturer_code: null as string | null, unit: "Stck", tax_rate: "19", default_purchase_price: null as string | null, default_sales_price: null as string | null, status: "active", supplier_prices: [] as ItemSupplierPrice[] });
|
||||
const items = ref<Item[]>([]);
|
||||
const suppliers = ref<Supplier[]>([]);
|
||||
const priceHistory = ref<ItemPriceHistory[]>([]);
|
||||
const selectedId = ref<string | null>(null);
|
||||
const form = reactive(emptyForm());
|
||||
const status = ref("");
|
||||
const kind = ref<"info" | "success" | "error">("info");
|
||||
const search = ref("");
|
||||
const sortMode = ref<"name_desc" | "number_desc">("name_desc");
|
||||
const filteredItems = computed(() =>
|
||||
items.value.filter((item) => matchesObjectSearch(item.item_number, item.name, search.value))
|
||||
items.value
|
||||
.filter((item) => matchesObjectSearch(item.item_number, item.name, search.value))
|
||||
.sort((left, right) => {
|
||||
if (sortMode.value === "number_desc") {
|
||||
return right.item_number.localeCompare(left.item_number, "de", { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
return right.name.localeCompare(left.name, "de", { numeric: true, sensitivity: "base" });
|
||||
})
|
||||
);
|
||||
async function load() { const r = await apiGet<Item[]>("/api/v1/items"); if (r.ok) items.value = r.data; else { status.value = r.message; kind.value = "error"; } }
|
||||
async function load() {
|
||||
const [itemResult, supplierResult] = await Promise.all([apiGet<Item[]>("/api/v1/items"), apiGet<Supplier[]>("/api/v1/suppliers")]);
|
||||
if (itemResult.ok) items.value = itemResult.data; else { status.value = itemResult.message; kind.value = "error"; }
|
||||
if (supplierResult.ok) suppliers.value = supplierResult.data.filter((supplier) => supplier.status === "active");
|
||||
}
|
||||
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.item_number = (await reserveNextNumber("items")) ?? ""; priceHistory.value = []; }
|
||||
async function select(item: Item) { selectedId.value = item.id; Object.assign(form, item); await loadPriceHistory(item.id); }
|
||||
async function select(item: Item) {
|
||||
selectedId.value = item.id;
|
||||
Object.assign(form, displayItem(item));
|
||||
await loadPriceHistory(item.id);
|
||||
}
|
||||
async function loadPriceHistory(itemId: string) {
|
||||
const result = await apiGet<ItemPriceHistory[]>(`/api/v1/items/${itemId}/prices`);
|
||||
if (result.ok) priceHistory.value = result.data;
|
||||
}
|
||||
async function save() {
|
||||
const r = selectedId.value ? await apiPut<Item>(`/api/v1/items/${selectedId.value}`, form) : await apiPost<Item>("/api/v1/items", form);
|
||||
status.value = r.ok ? "Artikel gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await Promise.all([load(), loadPriceHistory(r.data.id)]); }
|
||||
const payload = apiItemPayload();
|
||||
const r = selectedId.value ? await apiPut<Item>(`/api/v1/items/${selectedId.value}`, payload) : await apiPost<Item>("/api/v1/items", payload);
|
||||
status.value = r.ok ? "Artikel gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; Object.assign(form, displayItem(r.data)); await Promise.all([load(), loadPriceHistory(r.data.id)]); }
|
||||
}
|
||||
async function deactivate() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/items/${selectedId.value}`); status.value = r.ok ? "Artikel deaktiviert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) await load(); }
|
||||
function addSupplierPrice() { form.supplier_prices.push(emptySupplierPrice()); }
|
||||
function removeSupplierPrice(index: number) { form.supplier_prices.splice(index, 1); }
|
||||
function displayItem(item: Item) {
|
||||
return {
|
||||
...item,
|
||||
tax_rate: normalizeTaxRate(item.tax_rate),
|
||||
default_purchase_price: formatDecimal(item.default_purchase_price),
|
||||
default_sales_price: formatDecimal(item.default_sales_price),
|
||||
supplier_prices: item.supplier_prices.map((price) => ({ ...price, purchase_price: formatDecimal(price.purchase_price) }))
|
||||
};
|
||||
}
|
||||
function apiItemPayload() {
|
||||
return {
|
||||
...form,
|
||||
tax_rate: normalizeTaxRate(form.tax_rate),
|
||||
default_purchase_price: normalizeDecimal(form.default_purchase_price),
|
||||
default_sales_price: normalizeDecimal(form.default_sales_price),
|
||||
supplier_prices: form.supplier_prices.map((price) => ({
|
||||
...price,
|
||||
purchase_price: normalizeDecimal(price.purchase_price) ?? "0",
|
||||
currency: price.currency.trim().toUpperCase()
|
||||
}))
|
||||
};
|
||||
}
|
||||
function selectedUnitValue() {
|
||||
return unitOptions.includes(form.unit) ? form.unit : "__custom";
|
||||
}
|
||||
function selectUnit(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
if (value === "__custom") {
|
||||
if (unitOptions.includes(form.unit)) form.unit = "";
|
||||
} else {
|
||||
form.unit = value;
|
||||
}
|
||||
}
|
||||
onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(); if (selectedId.value) await loadPriceHistory(selectedId.value); });
|
||||
</script>
|
||||
<template>
|
||||
@@ -38,6 +94,7 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
|
||||
<section class="panel list-panel">
|
||||
<div class="section-title"><h2>Artikel</h2><button type="button" @click="createNew">Neu</button></div>
|
||||
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Artikelnummer oder Bezeichnung" /></label>
|
||||
<label class="field list-search"><span>Sortierung</span><select v-model="sortMode"><option value="name_desc">Artikelname absteigend</option><option value="number_desc">Artikelnummer absteigend</option></select></label>
|
||||
<button v-for="item in filteredItems" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)"><strong>{{ item.item_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small></button>
|
||||
<p v-if="items.length === 0" class="empty">Keine Artikel vorhanden.</p>
|
||||
<p v-else-if="filteredItems.length === 0" class="empty">Keine Treffer.</p>
|
||||
@@ -45,19 +102,34 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
|
||||
<section class="panel detail-panel">
|
||||
<form @submit.prevent="save"><div class="form-grid">
|
||||
<label class="field"><span>Artikelnummer</span><div class="readonly-value">{{ form.item_number || "wird automatisch vergeben" }}</div></label>
|
||||
<label class="field"><span>Hersteller-Code</span><input v-model="form.manufacturer_code" /></label>
|
||||
<label class="field"><span>Bezeichnung</span><input v-model="form.name" required /></label>
|
||||
<label class="field"><span>Einheit</span><input v-model="form.unit" required /></label>
|
||||
<label class="field"><span>Steuersatz %</span><input v-model="form.tax_rate" type="number" min="0" step="0.01" required /></label>
|
||||
<label class="field"><span>Einkaufspreis</span><input v-model="form.default_purchase_price" type="number" min="0" step="0.01" /></label>
|
||||
<label class="field"><span>Verkaufspreis</span><input v-model="form.default_sales_price" type="number" min="0" step="0.01" /></label>
|
||||
<label class="field"><span>Einheit</span><select :value="selectedUnitValue()" required @change="selectUnit"><option v-for="unit in unitOptions" :key="unit" :value="unit">{{ unit }}</option><option value="__custom">Eigene Einheit</option></select></label>
|
||||
<label v-if="selectedUnitValue() === '__custom'" class="field"><span>Eigene Einheit</span><input v-model="form.unit" required /></label>
|
||||
<label class="field"><span>Steuersatz %</span><select v-model="form.tax_rate" required><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
|
||||
<label class="field"><span>Einkaufspreis</span><input v-model="form.default_purchase_price" inputmode="decimal" @blur="formatDecimalInput(form, 'default_purchase_price')" /></label>
|
||||
<label class="field"><span>Verkaufspreis</span><input v-model="form.default_sales_price" inputmode="decimal" @blur="formatDecimalInput(form, 'default_sales_price')" /></label>
|
||||
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
|
||||
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form>
|
||||
</div>
|
||||
<div class="sub-panel">
|
||||
<div class="section-title"><h2>Lieferantenpreise</h2><button type="button" @click="addSupplierPrice">Lieferant</button></div>
|
||||
<div v-for="(price, index) in form.supplier_prices" :key="index" class="quote-line">
|
||||
<label class="field full-width"><span>Lieferant</span><SearchSelect v-model="price.supplier_id" :options="suppliers.map((supplier) => ({ id: supplier.id, number: supplier.supplier_number, name: supplier.name }))" placeholder="Lieferanten-Nr. oder Name suchen" required /></label>
|
||||
<label class="field"><span>Externe Artikelnr.</span><input v-model="price.external_item_number" required /></label>
|
||||
<label class="field"><span>Einkaufspreis</span><input v-model="price.purchase_price" inputmode="decimal" required @blur="formatDecimalInput(price, 'purchase_price')" /></label>
|
||||
<label class="field"><span>Währung</span><input v-model="price.currency" maxlength="3" required /></label>
|
||||
<label class="check-row"><input v-model="price.is_preferred" type="checkbox" /><span>Bevorzugt</span></label>
|
||||
<button type="button" class="secondary" @click="removeSupplierPrice(index)">Entfernen</button>
|
||||
</div>
|
||||
<p v-if="form.supplier_prices.length === 0" class="empty">Keine Lieferantenpreise vorhanden.</p>
|
||||
</div>
|
||||
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form>
|
||||
<div v-if="selectedId" class="sub-panel">
|
||||
<h2>Preishistorie</h2>
|
||||
<div v-for="entry in priceHistory" :key="entry.id" class="data-row">
|
||||
<strong>{{ new Date(entry.valid_from).toLocaleString("de-DE") }}</strong>
|
||||
<span>EK {{ entry.purchase_price ?? "-" }}</span>
|
||||
<span>VK {{ entry.sales_price ?? "-" }}</span>
|
||||
<span>EK {{ formatEuro(entry.purchase_price) }}</span>
|
||||
<span>VK {{ formatEuro(entry.sales_price) }}</span>
|
||||
<small>{{ entry.source }}</small>
|
||||
</div>
|
||||
<p v-if="priceHistory.length === 0" class="empty">Noch keine Preisänderung vorhanden.</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { apiGet, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import { decimalString, taxRateOptions } from "../format";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
|
||||
type OrganizationSetupForm = {
|
||||
@@ -73,7 +74,11 @@ async function submit() {
|
||||
status.value = "Sende Anfrage...";
|
||||
statusKind.value = "info";
|
||||
|
||||
const result = await apiPut("/api/v1/organizations/current/setup", form);
|
||||
const result = await apiPut("/api/v1/organizations/current/setup", {
|
||||
...form,
|
||||
default_tax_rate: decimalString(form.default_tax_rate, "19"),
|
||||
default_payment_days: String(form.default_payment_days)
|
||||
});
|
||||
pending.value = false;
|
||||
status.value = result.ok ? "Gespeichert." : result.message;
|
||||
statusKind.value = result.ok ? "success" : "error";
|
||||
@@ -102,8 +107,8 @@ watch(
|
||||
<label class="field"><span>USt-IdNr.</span><input v-model="form.vat_id" type="text" /></label>
|
||||
<label class="field"><span>E-Mail der Firma</span><input v-model="form.email" type="email" placeholder="info@example.com" required /></label>
|
||||
<label class="field"><span>Telefon</span><input v-model="form.phone" type="tel" /></label>
|
||||
<label class="field"><span>Standard-Steuersatz</span><input v-model="form.default_tax_rate" type="number" required /></label>
|
||||
<label class="field"><span>Standard-Zahlungsziel</span><input v-model="form.default_payment_days" type="number" required /></label>
|
||||
<label class="field"><span>Standard-Steuersatz</span><select v-model="form.default_tax_rate" required><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
|
||||
<label class="field"><span>Standard-Zahlungsziel</span><input v-model="form.default_payment_days" inputmode="numeric" required /></label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="pending">Firmendaten speichern</button>
|
||||
|
||||
@@ -4,21 +4,24 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import SearchSelect from "../components/SearchSelect.vue";
|
||||
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
|
||||
import { reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { Customer, Item, OutgoingInvoice, OutgoingInvoiceItem, Quote } from "../types";
|
||||
import type { Activity, Customer, Item, OutgoingInvoice, OutgoingInvoiceItem, Quote } from "../types";
|
||||
|
||||
const emptyItem = (): OutgoingInvoiceItem => ({ item_id: "", description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
|
||||
const emptyItem = (): OutgoingInvoiceItem => ({ line_kind: "item", item_id: "", activity_id: null, description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
|
||||
const emptyForm = () => ({ invoice_number: "", customer_id: "", status: "draft", cash_discount_term_id: null as string | null, customer_discount_percent: "0", issued_at: null as string | null, due_at: null as string | null, source_quote_id: null as string | null, items: [emptyItem()] });
|
||||
const invoices = ref<OutgoingInvoice[]>([]);
|
||||
const customers = ref<Customer[]>([]);
|
||||
const items = ref<Item[]>([]);
|
||||
const activities = ref<Activity[]>([]);
|
||||
const quotes = ref<Quote[]>([]);
|
||||
const selectedId = ref<string | null>(null);
|
||||
const form = reactive(emptyForm());
|
||||
const status = ref("");
|
||||
const kind = ref<"info" | "success" | "error">("info");
|
||||
const search = ref("");
|
||||
const editingLineIndex = ref(0);
|
||||
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
|
||||
const filteredInvoices = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase();
|
||||
@@ -29,25 +32,93 @@ const filteredInvoices = computed(() => {
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const [invoiceResult, customerResult, itemResult, quoteResult] = await Promise.all([
|
||||
apiGet<OutgoingInvoice[]>("/api/v1/outgoing-invoices"), apiGet<Customer[]>("/api/v1/customers"), apiGet<Item[]>("/api/v1/items"), apiGet<Quote[]>("/api/v1/quotes")
|
||||
const [invoiceResult, customerResult, itemResult, quoteResult, activityResult] = await Promise.all([
|
||||
apiGet<OutgoingInvoice[]>("/api/v1/outgoing-invoices"), apiGet<Customer[]>("/api/v1/customers"), apiGet<Item[]>("/api/v1/items"), apiGet<Quote[]>("/api/v1/quotes"), apiGet<Activity[]>("/api/v1/activities")
|
||||
]);
|
||||
if (invoiceResult.ok) invoices.value = invoiceResult.data; else { status.value = invoiceResult.message; kind.value = "error"; }
|
||||
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
|
||||
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
|
||||
if (quoteResult.ok) quotes.value = quoteResult.data;
|
||||
if (activityResult.ok) activities.value = activityResult.data.filter((activity) => activity.status === "active");
|
||||
}
|
||||
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.invoice_number = (await reserveNextNumber("outgoing_invoices")) ?? ""; }
|
||||
function select(invoice: OutgoingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item })) }); }
|
||||
function addLine() { form.items.push(emptyItem()); }
|
||||
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); }
|
||||
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; form.invoice_number = (await reserveNextNumber("outgoing_invoices")) ?? ""; }
|
||||
function select(invoice: OutgoingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item, line_kind: item.line_kind ?? "item", tax_rate: normalizeTaxRate(item.tax_rate) })) }); editingLineIndex.value = 0; }
|
||||
function addLine() { form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
|
||||
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1); }
|
||||
function applyItemDefaults(line: OutgoingInvoiceItem) {
|
||||
const item = items.value.find((record) => record.id === line.item_id);
|
||||
if (!item) return;
|
||||
line.description = item.name; line.unit_price = item.default_sales_price ?? "0"; line.original_unit_price = item.default_sales_price ?? null; line.tax_rate = item.tax_rate;
|
||||
line.description = item.name; line.unit_price = item.default_sales_price ?? "0"; line.original_unit_price = item.default_sales_price ?? null; line.tax_rate = normalizeTaxRate(item.tax_rate);
|
||||
}
|
||||
const lineSourceOptions = computed(() => [
|
||||
...items.value.map((item) => ({ id: `item:${item.id}`, number: item.item_number, name: item.name })),
|
||||
...activities.value.map((activity) => ({ id: `activity:${activity.id}`, number: activity.activity_number, name: activity.title }))
|
||||
]);
|
||||
function lineSourceValue(line: OutgoingInvoiceItem) {
|
||||
return line.line_kind === "activity" ? `activity:${line.activity_id ?? ""}` : `item:${line.item_id ?? ""}`;
|
||||
}
|
||||
function selectLineSource(line: OutgoingInvoiceItem, value: string | null) {
|
||||
if (!value) return;
|
||||
const [kind, id] = value.split(":");
|
||||
if (kind === "activity") {
|
||||
const activity = activities.value.find((record) => record.id === id);
|
||||
if (!activity) return;
|
||||
line.line_kind = "activity";
|
||||
line.activity_id = id;
|
||||
line.item_id = null;
|
||||
line.description = activity.title;
|
||||
line.unit_price = "0";
|
||||
line.original_unit_price = null;
|
||||
line.tax_rate = "19";
|
||||
return;
|
||||
}
|
||||
const item = items.value.find((record) => record.id === id);
|
||||
if (!item) return;
|
||||
line.line_kind = "item";
|
||||
line.item_id = id;
|
||||
line.activity_id = null;
|
||||
applyItemDefaults(line);
|
||||
}
|
||||
function applyCustomerDefaults(customerId: string | null) {
|
||||
if (!customerId) return;
|
||||
const customer = customers.value.find((record) => record.id === customerId);
|
||||
if (!customer) return;
|
||||
form.customer_discount_percent = customer.standard_discount_percent || "0";
|
||||
form.cash_discount_term_id = customer.cash_discount_term_id ?? null;
|
||||
}
|
||||
function lineName(line: OutgoingInvoiceItem) {
|
||||
if (line.line_kind === "activity") {
|
||||
const activity = activities.value.find((record) => record.id === line.activity_id);
|
||||
return activity ? `${activity.activity_number ?? "Aktivität"} - ${activity.title}` : "Keine Aktivität gewählt";
|
||||
}
|
||||
if (!line.item_id) return "Kein Artikel gewählt";
|
||||
const item = items.value.find((record) => record.id === line.item_id);
|
||||
return item ? `${item.item_number} - ${item.name}` : line.item_id;
|
||||
}
|
||||
function lineTotal(line: OutgoingInvoiceItem) {
|
||||
const quantity = Number(decimalString(line.quantity, "0"));
|
||||
const unitPrice = Number(decimalString(line.unit_price, "0"));
|
||||
const discount = Number(decimalString(line.discount_percent, "0"));
|
||||
const total = quantity * unitPrice * (1 - discount / 100);
|
||||
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
|
||||
}
|
||||
async function save() {
|
||||
const result = selectedId.value ? await apiPut<OutgoingInvoice>(`/api/v1/outgoing-invoices/${selectedId.value}`, form) : await apiPost<OutgoingInvoice>("/api/v1/outgoing-invoices", form);
|
||||
const payload = {
|
||||
...form,
|
||||
customer_discount_percent: decimalString(form.customer_discount_percent),
|
||||
items: form.items.map((item) => ({
|
||||
...item,
|
||||
line_kind: item.line_kind ?? "item",
|
||||
item_id: item.line_kind === "activity" ? null : item.item_id,
|
||||
activity_id: item.line_kind === "activity" ? item.activity_id : null,
|
||||
quantity: decimalString(item.quantity, "1"),
|
||||
unit_price: decimalString(item.unit_price),
|
||||
original_unit_price: item.original_unit_price === null ? null : decimalString(item.original_unit_price),
|
||||
discount_percent: decimalString(item.discount_percent),
|
||||
tax_rate: normalizeTaxRate(item.tax_rate)
|
||||
}))
|
||||
};
|
||||
const result = selectedId.value ? await apiPut<OutgoingInvoice>(`/api/v1/outgoing-invoices/${selectedId.value}`, payload) : await apiPost<OutgoingInvoice>("/api/v1/outgoing-invoices", payload);
|
||||
status.value = result.ok ? "Rechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||
}
|
||||
async function finalize() { if (!selectedId.value) return; const result = await apiPost(`/api/v1/outgoing-invoices/${selectedId.value}/finalize`, {}); status.value = result.ok ? "Rechnung abgeschlossen." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
|
||||
@@ -67,16 +138,29 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
|
||||
</section>
|
||||
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
|
||||
<label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label>
|
||||
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required /></label>
|
||||
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required @change="applyCustomerDefaults" /></label>
|
||||
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="finalized">Abgeschlossen</option><option value="sent">Gesendet</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label>
|
||||
<label class="field"><span>Ausgestellt</span><input v-model="form.issued_at" type="date" /></label>
|
||||
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label>
|
||||
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
|
||||
</div><div class="sub-panel"><div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div>
|
||||
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" /></label>
|
||||
</div><div class="sub-panel"><div class="section-title"><h2>Positionen</h2></div><div class="position-table-header"><span>Position</span><span>Menge</span><span>Einzelpreis</span><span>Gesamtpreis</span><span>Steuer</span><span>Aktion</span></div>
|
||||
<div v-for="(line, index) in form.items" :key="index" class="quote-line">
|
||||
<label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" required @change="applyItemDefaults(line)" /></label>
|
||||
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" /></label><label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label><label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
|
||||
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
|
||||
<template v-if="editingLineIndex !== index">
|
||||
<div class="position-summary">
|
||||
<strong>{{ lineName(line) }}</strong>
|
||||
<span>{{ line.quantity }}</span>
|
||||
<span>{{ line.unit_price }}</span>
|
||||
<span>{{ lineTotal(line) }}</span>
|
||||
<span>{{ line.tax_rate }} %</span>
|
||||
</div>
|
||||
<button type="button" class="secondary" @click="editingLineIndex = index">Bearbeiten</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="field full-width"><span>Artikel oder Aktivität</span><SearchSelect :model-value="lineSourceValue(line)" :options="lineSourceOptions" placeholder="Artikel-/Aktivitäts-Nr. oder Name suchen" required @change="selectLineSource(line, $event)" /></label>
|
||||
<label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" /></label><label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" inputmode="decimal" /></label><label class="field"><span>Steuer %</span><select v-model="line.tax_rate"><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
|
||||
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
|
||||
<div class="position-actions"><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><button type="button" @click="addLine">Position hinzufügen</button></div>
|
||||
</template>
|
||||
</div></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="finalize">Abschließen</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { PriceListImportApplyResponse, PriceListImportPreview } from "../ty
|
||||
|
||||
const sourceName = ref("Preisliste.csv");
|
||||
const delimiter = ref(";");
|
||||
const content = ref("item_number;name;unit;tax_rate;purchase_price;sales_price\nAR-IMPORT-1;Importartikel;Stk;19;10.00;25.00");
|
||||
const content = ref("item_number;name;manufacturer_code;unit;tax_rate;purchase_price;sales_price;supplier_number;supplier_item_number;currency\nAR-IMPORT-1;Importartikel;HERST-001;Stck;19,00;10,00;25,00;LI000000001;EXT-4711;EUR");
|
||||
const preview = ref<PriceListImportPreview | null>(null);
|
||||
const status = ref("");
|
||||
const kind = ref<"info" | "success" | "error">("info");
|
||||
@@ -45,7 +45,9 @@ async function applyImport() {
|
||||
<strong>{{ row.row_number }} {{ row.action }}</strong>
|
||||
<span>{{ row.item_number }}</span>
|
||||
<span>{{ row.name }}</span>
|
||||
<small>{{ row.error ?? `${row.purchase_price ?? "-"} / ${row.sales_price ?? "-"}` }}</small>
|
||||
<span>{{ row.manufacturer_code ?? "-" }}</span>
|
||||
<span>{{ row.supplier_number ? `${row.supplier_number} / ${row.supplier_item_number}` : "-" }}</span>
|
||||
<small>{{ row.error ?? `${row.purchase_price ?? "-"} ${row.currency ?? "EUR"} / ${row.sales_price ?? "-"}` }}</small>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from "vue";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import { decimalString } from "../format";
|
||||
import type { PriceRule } from "../types";
|
||||
|
||||
const rules = ref<PriceRule[]>([]);
|
||||
@@ -20,7 +21,11 @@ const status = ref("");
|
||||
const kind = ref<"info" | "success" | "error">("info");
|
||||
|
||||
function payload() {
|
||||
return { ...form, source_id: form.source_id.trim() || null };
|
||||
return {
|
||||
...form,
|
||||
source_id: form.source_id.trim() || null,
|
||||
markup_percent: decimalString(form.markup_percent)
|
||||
};
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
@@ -86,7 +91,7 @@ onMounted(load);
|
||||
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
|
||||
<label class="field"><span>Quellentyp</span><select v-model="form.source_type"><option value="import">Import</option><option value="api">API</option><option value="supplier">Lieferant</option></select></label>
|
||||
<label class="field"><span>Quell-ID</span><input v-model="form.source_id" placeholder="optional" /></label>
|
||||
<label class="field"><span>Aufschlag %</span><input v-model="form.markup_percent" type="number" min="-100" max="1000" step="0.0001" required /></label>
|
||||
<label class="field"><span>Aufschlag %</span><input v-model="form.markup_percent" inputmode="decimal" required /></label>
|
||||
<label class="field"><span>Rundung</span><select v-model="form.rounding_mode"><option value="none">Keine</option><option value="cent">Cent</option><option value="five_cent">5 Cent</option><option value="ten_cent">10 Cent</option><option value="whole">Ganze Beträge</option></select></label>
|
||||
<label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
|
||||
</div>
|
||||
|
||||
@@ -4,22 +4,25 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import SearchSelect from "../components/SearchSelect.vue";
|
||||
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
|
||||
import { reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { Customer, Item, Quote, QuoteItem } from "../types";
|
||||
import type { Activity, Customer, Item, Quote, QuoteItem } from "../types";
|
||||
|
||||
const emptyItem = (): QuoteItem => ({ item_id: "", description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
|
||||
const emptyItem = (): QuoteItem => ({ line_kind: "item", item_id: "", activity_id: null, description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
|
||||
const emptyForm = () => ({ quote_number: "", customer_id: "", status: "draft", valid_until: null as string | null, cash_discount_term_id: null as string | null, customer_discount_percent: "0", notes: "", items: [emptyItem()] });
|
||||
|
||||
const quotes = ref<Quote[]>([]);
|
||||
const customers = ref<Customer[]>([]);
|
||||
const items = ref<Item[]>([]);
|
||||
const activities = ref<Activity[]>([]);
|
||||
const selectedId = ref<string | null>(null);
|
||||
const form = reactive(emptyForm());
|
||||
const status = ref("");
|
||||
const kind = ref<"info" | "success" | "error">("info");
|
||||
const selectedQuote = computed(() => quotes.value.find((quote) => quote.id === selectedId.value));
|
||||
const search = ref("");
|
||||
const editingLineIndex = ref(0);
|
||||
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
|
||||
const filteredQuotes = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase();
|
||||
@@ -30,33 +33,39 @@ const filteredQuotes = computed(() => {
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const [quoteResult, customerResult, itemResult] = await Promise.all([
|
||||
const [quoteResult, customerResult, itemResult, activityResult] = await Promise.all([
|
||||
apiGet<Quote[]>("/api/v1/quotes"),
|
||||
apiGet<Customer[]>("/api/v1/customers"),
|
||||
apiGet<Item[]>("/api/v1/items")
|
||||
apiGet<Item[]>("/api/v1/items"),
|
||||
apiGet<Activity[]>("/api/v1/activities")
|
||||
]);
|
||||
if (quoteResult.ok) quotes.value = quoteResult.data; else { status.value = quoteResult.message; kind.value = "error"; }
|
||||
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
|
||||
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
|
||||
if (activityResult.ok) activities.value = activityResult.data.filter((activity) => activity.status === "active");
|
||||
}
|
||||
|
||||
async function createNew() {
|
||||
selectedId.value = null;
|
||||
Object.assign(form, emptyForm());
|
||||
editingLineIndex.value = 0;
|
||||
form.quote_number = (await reserveNextNumber("quotes")) ?? "";
|
||||
}
|
||||
|
||||
function select(quote: Quote) {
|
||||
selectedId.value = quote.id;
|
||||
Object.assign(form, { ...quote, items: quote.items.map((item) => ({ ...item })) });
|
||||
Object.assign(form, { ...quote, items: quote.items.map((item) => ({ ...item, line_kind: item.line_kind ?? "item", tax_rate: normalizeTaxRate(item.tax_rate) })) });
|
||||
editingLineIndex.value = 0;
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
form.items.push(emptyItem());
|
||||
editingLineIndex.value = form.items.length - 1;
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
if (form.items.length > 1) form.items.splice(index, 1);
|
||||
editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1);
|
||||
}
|
||||
|
||||
function applyItemDefaults(line: QuoteItem) {
|
||||
@@ -65,13 +74,86 @@ function applyItemDefaults(line: QuoteItem) {
|
||||
line.description = item.name;
|
||||
line.unit_price = item.default_sales_price ?? "0";
|
||||
line.original_unit_price = item.default_sales_price ?? null;
|
||||
line.tax_rate = item.tax_rate;
|
||||
line.tax_rate = normalizeTaxRate(item.tax_rate);
|
||||
}
|
||||
|
||||
const lineSourceOptions = computed(() => [
|
||||
...items.value.map((item) => ({ id: `item:${item.id}`, number: item.item_number, name: item.name })),
|
||||
...activities.value.map((activity) => ({ id: `activity:${activity.id}`, number: activity.activity_number, name: activity.title }))
|
||||
]);
|
||||
|
||||
function lineSourceValue(line: QuoteItem) {
|
||||
return line.line_kind === "activity" ? `activity:${line.activity_id ?? ""}` : `item:${line.item_id ?? ""}`;
|
||||
}
|
||||
|
||||
function selectLineSource(line: QuoteItem, value: string | null) {
|
||||
if (!value) return;
|
||||
const [kind, id] = value.split(":");
|
||||
if (kind === "activity") {
|
||||
const activity = activities.value.find((record) => record.id === id);
|
||||
if (!activity) return;
|
||||
line.line_kind = "activity";
|
||||
line.activity_id = id;
|
||||
line.item_id = null;
|
||||
line.description = activity.title;
|
||||
line.unit_price = "0";
|
||||
line.original_unit_price = null;
|
||||
line.tax_rate = "19";
|
||||
return;
|
||||
}
|
||||
const item = items.value.find((record) => record.id === id);
|
||||
if (!item) return;
|
||||
line.line_kind = "item";
|
||||
line.item_id = id;
|
||||
line.activity_id = null;
|
||||
applyItemDefaults(line);
|
||||
}
|
||||
|
||||
function applyCustomerDefaults(customerId: string | null) {
|
||||
if (!customerId) return;
|
||||
const customer = customers.value.find((record) => record.id === customerId);
|
||||
if (!customer) return;
|
||||
form.customer_discount_percent = customer.standard_discount_percent || "0";
|
||||
form.cash_discount_term_id = customer.cash_discount_term_id ?? null;
|
||||
}
|
||||
|
||||
function lineName(line: QuoteItem) {
|
||||
if (line.line_kind === "activity") {
|
||||
const activity = activities.value.find((record) => record.id === line.activity_id);
|
||||
return activity ? `${activity.activity_number ?? "Aktivität"} - ${activity.title}` : "Keine Aktivität gewählt";
|
||||
}
|
||||
if (!line.item_id) return "Kein Artikel gewählt";
|
||||
const item = items.value.find((record) => record.id === line.item_id);
|
||||
return item ? `${item.item_number} - ${item.name}` : line.item_id;
|
||||
}
|
||||
|
||||
function lineTotal(line: QuoteItem) {
|
||||
const quantity = Number(decimalString(line.quantity, "0"));
|
||||
const unitPrice = Number(decimalString(line.unit_price, "0"));
|
||||
const discount = Number(decimalString(line.discount_percent, "0"));
|
||||
const total = quantity * unitPrice * (1 - discount / 100);
|
||||
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = {
|
||||
...form,
|
||||
customer_discount_percent: decimalString(form.customer_discount_percent),
|
||||
items: form.items.map((item) => ({
|
||||
...item,
|
||||
line_kind: item.line_kind ?? "item",
|
||||
item_id: item.line_kind === "activity" ? null : item.item_id,
|
||||
activity_id: item.line_kind === "activity" ? item.activity_id : null,
|
||||
quantity: decimalString(item.quantity, "1"),
|
||||
unit_price: decimalString(item.unit_price),
|
||||
original_unit_price: item.original_unit_price === null ? null : decimalString(item.original_unit_price),
|
||||
discount_percent: decimalString(item.discount_percent),
|
||||
tax_rate: normalizeTaxRate(item.tax_rate)
|
||||
}))
|
||||
};
|
||||
const result = selectedId.value
|
||||
? await apiPut<Quote>(`/api/v1/quotes/${selectedId.value}`, form)
|
||||
: await apiPost<Quote>("/api/v1/quotes", form);
|
||||
? await apiPut<Quote>(`/api/v1/quotes/${selectedId.value}`, payload)
|
||||
: await apiPost<Quote>("/api/v1/quotes", payload);
|
||||
status.value = result.ok ? "Angebot gespeichert." : result.message;
|
||||
kind.value = result.ok ? "success" : "error";
|
||||
if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||
@@ -107,22 +189,38 @@ watch(() => liveUpdateState.revision, load);
|
||||
<form @submit.prevent="save">
|
||||
<div class="form-grid">
|
||||
<label class="field"><span>Angebotsnummer</span><div class="readonly-value">{{ form.quote_number || "wird automatisch vergeben" }}</div></label>
|
||||
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required /></label>
|
||||
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required @change="applyCustomerDefaults" /></label>
|
||||
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="sent">Gesendet</option><option value="accepted">Angenommen</option><option value="rejected">Abgelehnt</option><option value="expired">Abgelaufen</option><option value="cancelled">Storniert</option></select></label>
|
||||
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" /></label>
|
||||
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
|
||||
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" /></label>
|
||||
<label class="field full-width"><span>Notizen</span><textarea v-model="form.notes" rows="3" /></label>
|
||||
</div>
|
||||
<div class="sub-panel">
|
||||
<div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div>
|
||||
<div class="section-title"><h2>Positionen</h2></div>
|
||||
<div class="position-table-header"><span>Position</span><span>Menge</span><span>Einzelpreis</span><span>Gesamtpreis</span><span>Steuer</span><span>Aktion</span></div>
|
||||
<div v-for="(line, index) in form.items" :key="index" class="quote-line">
|
||||
<label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" required @change="applyItemDefaults(line)" /></label>
|
||||
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" required /></label>
|
||||
<label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" required /></label>
|
||||
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label>
|
||||
<label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label>
|
||||
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
|
||||
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
|
||||
<template v-if="editingLineIndex !== index">
|
||||
<div class="position-summary">
|
||||
<strong>{{ lineName(line) }}</strong>
|
||||
<span>{{ line.quantity }}</span>
|
||||
<span>{{ line.unit_price }}</span>
|
||||
<span>{{ lineTotal(line) }}</span>
|
||||
<span>{{ line.tax_rate }} %</span>
|
||||
</div>
|
||||
<button type="button" class="secondary" @click="editingLineIndex = index">Bearbeiten</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="field full-width"><span>Artikel oder Aktivität</span><SearchSelect :model-value="lineSourceValue(line)" :options="lineSourceOptions" placeholder="Artikel-/Aktivitäts-Nr. oder Name suchen" required @change="selectLineSource(line, $event)" /></label>
|
||||
<label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" required /></label>
|
||||
<label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" required /></label>
|
||||
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" inputmode="decimal" /></label>
|
||||
<label class="field"><span>Steuer %</span><select v-model="line.tax_rate"><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
|
||||
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
|
||||
<div class="position-actions">
|
||||
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
|
||||
<button type="button" @click="addLine">Position hinzufügen</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
|
||||
import FormStatus from "../components/FormStatus.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import { decimalString } from "../format";
|
||||
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
|
||||
import { liveUpdateState } from "../realtime";
|
||||
import type { CashDiscountTerm, Supplier } from "../types";
|
||||
@@ -40,9 +41,13 @@ async function createNew() {
|
||||
}
|
||||
function select(item: Supplier) { selectedId.value = item.id; Object.assign(form, { ...item, details: { ...item.details } }); }
|
||||
async function save() {
|
||||
const payload = {
|
||||
...form,
|
||||
standard_discount_percent: decimalString(form.standard_discount_percent)
|
||||
};
|
||||
const result = selectedId.value
|
||||
? await apiPut<Supplier>(`/api/v1/suppliers/${selectedId.value}`, form)
|
||||
: await apiPost<Supplier>("/api/v1/suppliers", form);
|
||||
? await apiPut<Supplier>(`/api/v1/suppliers/${selectedId.value}`, payload)
|
||||
: await apiPost<Supplier>("/api/v1/suppliers", payload);
|
||||
status.value = result.ok ? "Lieferant gespeichert." : result.message; kind.value = result.ok ? "success" : "error";
|
||||
if (result.ok) { selectedId.value = result.data.id; await load(); }
|
||||
}
|
||||
@@ -75,7 +80,7 @@ watch(() => liveUpdateState.revision, () => Promise.all([load(), loadCashDiscoun
|
||||
<label class="field"><span>Lieferantennummer</span><div class="readonly-value">{{ form.supplier_number || "wird automatisch vergeben" }}</div></label>
|
||||
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
|
||||
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
|
||||
<label class="field"><span>Rabatt %</span><input v-model="form.standard_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
|
||||
<label class="field"><span>Rabatt %</span><input v-model="form.standard_discount_percent" inputmode="decimal" /></label>
|
||||
<label class="field">
|
||||
<span>Skonto-Regel</span>
|
||||
<select v-model="form.cash_discount_term_id">
|
||||
|
||||
Reference in New Issue
Block a user