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:
Torsten Schulz (local)
2026-06-03 09:25:10 +02:00
parent 0e539710c0
commit d5b6f39177
22 changed files with 1420 additions and 183 deletions

View File

@@ -809,8 +809,12 @@ Geplante Daten:
- Einkaufspreis - Einkaufspreis
- Verkaufspreis - Verkaufspreis
- Steuersatz - Steuersatz
- Lieferant - Hersteller-Code / Herstellerartikelnummer
- Herstellerartikelnummer - mehrere Lieferantenreferenzen je Artikel
- externe Artikelnummer je Lieferant
- Einkaufspreis je Lieferant und externer Artikelnummer
- Währung je Lieferantenpreis
- Kennzeichnung bevorzugter Lieferant
- EAN/GTIN - EAN/GTIN
- Preisgültigkeit - Preisgültigkeit
- Lagerbestand - Lagerbestand
@@ -823,6 +827,8 @@ Preisberechnung:
- Einkaufspreis aus Quelle übernehmen - Einkaufspreis aus Quelle übernehmen
- Einkaufspreis mal Multiplikator - Einkaufspreis mal Multiplikator
- Staffelpreise - Staffelpreise
- günstigsten aktiven Lieferantenpreis ermitteln
- bevorzugten Lieferanten optional gegenüber dem günstigsten Preis priorisieren
- kundenspezifische Preise - kundenspezifische Preise
- kundenbezogener Standardrabatt - kundenbezogener Standardrabatt
- positionsbezogener Sonderpreis oder Sonderrabatt - positionsbezogener Sonderpreis oder Sonderrabatt
@@ -1021,6 +1027,18 @@ Import-Pipeline:
8. Verkaufspreise neu berechnen 8. Verkaufspreise neu berechnen
9. betroffene Clients per WebSocket informieren 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 ### Frei konfigurierbare Preislisten
Da Lieferantenpreislisten frei konfigurierbar sein sollen, braucht das System Da Lieferantenpreislisten frei konfigurierbar sein sollen, braucht das System

View File

@@ -0,0 +1,2 @@
alter table {schema}.items
alter column unit set default 'Stck';

View 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;

View File

@@ -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

View File

@@ -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( fn customer_combo(
ui: &mut egui::Ui, ui: &mut egui::Ui,
selected_id: &mut String, selected_id: &mut String,
@@ -688,7 +757,21 @@ fn apply_quote_item_defaults(line: &mut QuoteItemForm, items: &[Item]) {
.clone() .clone()
.unwrap_or_else(|| "0".to_string()); .unwrap_or_else(|| "0".to_string());
line.original_unit_price = item.default_sales_price.clone(); 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() .clone()
.unwrap_or_else(|| "0".to_string()); .unwrap_or_else(|| "0".to_string());
line.original_unit_price = item.default_sales_price.clone(); 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| { form_row(ui, "Beschreibung", |ui| {
@@ -741,7 +824,7 @@ fn invoice_items_editor(
ui.text_edit_singleline(&mut line.discount_percent); ui.text_edit_singleline(&mut line.discount_percent);
ui.end_row(); ui.end_row();
ui.label("Steuer %"); ui.label("Steuer %");
ui.text_edit_singleline(&mut line.tax_rate); tax_rate_combo(ui, &mut line.tax_rate);
ui.end_row(); ui.end_row();
}); });
if can_remove && ui.button("Entfernen").clicked() { 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.text_edit_singleline(&mut line.unit_price);
ui.end_row(); ui.end_row();
ui.label("Steuer %"); ui.label("Steuer %");
ui.text_edit_singleline(&mut line.tax_rate); tax_rate_combo(ui, &mut line.tax_rate);
ui.end_row(); ui.end_row();
}); });
if can_remove && ui.button("Entfernen").clicked() { if can_remove && ui.button("Entfernen").clicked() {
@@ -1006,6 +1089,7 @@ struct CompanyToolApp {
customer_list_search: String, customer_list_search: String,
supplier_list_search: String, supplier_list_search: String,
item_list_search: String, item_list_search: String,
item_sort_mode: String,
activity_list_search: String, activity_list_search: String,
quote_list_search: String, quote_list_search: String,
outgoing_invoice_list_search: String, outgoing_invoice_list_search: String,
@@ -1142,6 +1226,7 @@ impl CompanyToolApp {
customer_list_search: String::new(), customer_list_search: String::new(),
supplier_list_search: String::new(), supplier_list_search: String::new(),
item_list_search: String::new(), item_list_search: String::new(),
item_sort_mode: "name_desc".to_string(),
activity_list_search: String::new(), activity_list_search: String::new(),
quote_list_search: String::new(), quote_list_search: String::new(),
outgoing_invoice_list_search: String::new(), outgoing_invoice_list_search: String::new(),
@@ -1626,7 +1711,7 @@ impl CompanyToolApp {
}, },
AdminEvent::ActivityDeleted(result) => { AdminEvent::ActivityDeleted(result) => {
self.activities_status = result self.activities_status = result
.map(|_| "Aktivität storniert.".to_string()) .map(|_| "Aktivität deaktiviert.".to_string())
.unwrap_or_else(|message| message); .unwrap_or_else(|message| message);
self.load_activities(); self.load_activities();
} }
@@ -2638,6 +2723,7 @@ impl CompanyToolApp {
self.restore_window("items"); self.restore_window("items");
self.items_window_open = true; self.items_window_open = true;
self.load_items(); self.load_items();
self.load_suppliers();
} }
fn open_activities_window(&mut self) { fn open_activities_window(&mut self) {
@@ -4091,8 +4177,25 @@ impl CompanyToolApp {
self.reserve_next_number("items"); self.reserve_next_number("items");
} }
ui.text_edit_singleline(&mut self.item_list_search); 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); ui.add_space(6.0);
let filtered_items: Vec<Item> = self let mut filtered_items: Vec<Item> = self
.items .items
.iter() .iter()
.filter(|item| { .filter(|item| {
@@ -4104,6 +4207,18 @@ impl CompanyToolApp {
}) })
.cloned() .cloned()
.collect(); .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 { for item in filtered_items {
if ui if ui
.selectable_label( .selectable_label(
@@ -4125,14 +4240,17 @@ impl CompanyToolApp {
form_row(ui, "Artikelnummer", |ui| { form_row(ui, "Artikelnummer", |ui| {
ui.label(number_or_pending(&self.item_form.item_number)); 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| { form_row(ui, "Bezeichnung", |ui| {
ui.text_edit_singleline(&mut self.item_form.name); ui.text_edit_singleline(&mut self.item_form.name);
}); });
form_row(ui, "Einheit", |ui| { 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| { 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| { form_row(ui, "Einkaufspreis", |ui| {
ui.text_edit_singleline(&mut self.item_form.default_purchase_price); ui.text_edit_singleline(&mut self.item_form.default_purchase_price);
@@ -4140,6 +4258,46 @@ impl CompanyToolApp {
form_row(ui, "Verkaufspreis", |ui| { form_row(ui, "Verkaufspreis", |ui| {
ui.text_edit_singleline(&mut self.item_form.default_sales_price); 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| { ui.horizontal(|ui| {
if ui.button("Speichern").clicked() { if ui.button("Speichern").clicked() {
self.save_item(); self.save_item();
@@ -4166,12 +4324,9 @@ impl CompanyToolApp {
ui.label(format!("ID {}", &entry.id[..entry.id.len().min(8)])); ui.label(format!("ID {}", &entry.id[..entry.id.len().min(8)]));
ui.label(format!( ui.label(format!(
"EK {}", "EK {}",
entry.purchase_price.as_deref().unwrap_or("-") format_euro(entry.purchase_price.as_deref())
));
ui.label(format!(
"VK {}",
entry.sales_price.as_deref().unwrap_or("-")
)); ));
ui.label(format!("VK {}", format_euro(entry.sales_price.as_deref())));
ui.label(&entry.source); ui.label(&entry.source);
ui.label(format!( ui.label(format!(
"Item {}", "Item {}",
@@ -4237,6 +4392,8 @@ impl CompanyToolApp {
ui.label("Aktion"); ui.label("Aktion");
ui.label("Artikelnummer"); ui.label("Artikelnummer");
ui.label("Name"); ui.label("Name");
ui.label("Hersteller");
ui.label("Lieferant");
ui.label("Einheit"); ui.label("Einheit");
ui.label("Steuer"); ui.label("Steuer");
ui.label("EK"); ui.label("EK");
@@ -4248,9 +4405,26 @@ impl CompanyToolApp {
ui.label(&row.action); ui.label(&row.action);
ui.label(&row.item_number); ui.label(&row.item_number);
ui.label(&row.name); 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.unit);
ui.label(&row.tax_rate); 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.sales_price.as_deref().unwrap_or("-"));
ui.label(row.error.as_deref().unwrap_or("-")); ui.label(row.error.as_deref().unwrap_or("-"));
ui.end_row(); ui.end_row();
@@ -4624,12 +4798,16 @@ impl CompanyToolApp {
ui.label(number_or_pending(&self.quote_form.quote_number)); ui.label(number_or_pending(&self.quote_form.quote_number));
}); });
form_row(ui, "Kunde", |ui| { form_row(ui, "Kunde", |ui| {
let previous_customer_id = self.quote_form.customer_id.clone();
customer_combo( customer_combo(
ui, ui,
&mut self.quote_form.customer_id, &mut self.quote_form.customer_id,
&self.customers, &self.customers,
&mut self.customer_lookup_search, &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| { form_row(ui, "Status", |ui| {
egui::ComboBox::from_id_salt("quote_status") egui::ComboBox::from_id_salt("quote_status")
@@ -4703,7 +4881,7 @@ impl CompanyToolApp {
ui.text_edit_singleline(&mut line.discount_percent); ui.text_edit_singleline(&mut line.discount_percent);
ui.end_row(); ui.end_row();
ui.label("Steuer %"); ui.label("Steuer %");
ui.text_edit_singleline(&mut line.tax_rate); tax_rate_combo(ui, &mut line.tax_rate);
ui.end_row(); ui.end_row();
}); });
if can_remove_quote_item && ui.button("Position entfernen").clicked() { if can_remove_quote_item && ui.button("Position entfernen").clicked() {
@@ -4814,12 +4992,20 @@ impl CompanyToolApp {
)); ));
}); });
form_row(ui, "Kunde", |ui| { form_row(ui, "Kunde", |ui| {
let previous_customer_id =
self.outgoing_invoice_form.customer_id.clone();
customer_combo( customer_combo(
ui, ui,
&mut self.outgoing_invoice_form.customer_id, &mut self.outgoing_invoice_form.customer_id,
&self.customers, &self.customers,
&mut self.customer_lookup_search, &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| { form_row(ui, "Status", |ui| {
ui.text_edit_singleline(&mut self.outgoing_invoice_form.status); 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); ui.text_edit_singleline(&mut self.activity_form.activity_type);
}); });
form_row(ui, "Status", |ui| { 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| { form_row(ui, "Priorität", |ui| {
ui.text_edit_singleline(&mut self.activity_form.priority); ui.text_edit_singleline(&mut self.activity_form.priority);
@@ -5078,7 +5280,7 @@ impl CompanyToolApp {
if ui if ui
.add_enabled( .add_enabled(
self.selected_activity_id.is_some(), self.selected_activity_id.is_some(),
egui::Button::new("Stornieren"), egui::Button::new("Deaktivieren"),
) )
.clicked() .clicked()
{ {
@@ -5536,11 +5738,22 @@ struct Item {
id: String, id: String,
item_number: String, item_number: String,
name: String, name: String,
manufacturer_code: Option<String>,
unit: String, unit: String,
tax_rate: String, tax_rate: String,
default_purchase_price: Option<String>, default_purchase_price: Option<String>,
default_sales_price: Option<String>, default_sales_price: Option<String>,
status: 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)] #[derive(Debug, Clone, Deserialize)]
@@ -5558,22 +5771,35 @@ struct ItemPriceHistory {
struct ItemForm { struct ItemForm {
item_number: String, item_number: String,
name: String, name: String,
manufacturer_code: String,
unit: String, unit: String,
tax_rate: String, tax_rate: String,
default_purchase_price: String, default_purchase_price: String,
default_sales_price: String, default_sales_price: String,
status: 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 { impl Default for ItemForm {
fn default() -> Self { fn default() -> Self {
Self { Self {
item_number: String::new(), item_number: String::new(),
name: String::new(), name: String::new(),
unit: "Stk".to_string(), manufacturer_code: String::new(),
unit: "Stck".to_string(),
tax_rate: "19".to_string(), tax_rate: "19".to_string(),
default_purchase_price: "0".to_string(), default_purchase_price: "0".to_string(),
default_sales_price: "0".to_string(), default_sales_price: "0".to_string(),
status: "active".to_string(), status: "active".to_string(),
supplier_prices: Vec::new(),
} }
} }
} }
@@ -5582,11 +5808,41 @@ impl From<&Item> for ItemForm {
Self { Self {
item_number: value.item_number.clone(), item_number: value.item_number.clone(),
name: value.name.clone(), name: value.name.clone(),
manufacturer_code: value.manufacturer_code.clone().unwrap_or_default(),
unit: value.unit.clone(), 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_purchase_price: value.default_purchase_price.clone().unwrap_or_default(),
default_sales_price: value.default_sales_price.clone().unwrap_or_default(), default_sales_price: value.default_sales_price.clone().unwrap_or_default(),
status: value.status.clone(), 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(), unit_price: value.unit_price.clone(),
original_unit_price: value.original_unit_price.clone(), original_unit_price: value.original_unit_price.clone(),
discount_percent: value.discount_percent.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(), unit_price: value.unit_price.clone(),
original_unit_price: value.original_unit_price.clone(), original_unit_price: value.original_unit_price.clone(),
discount_percent: value.discount_percent.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(), description: value.description.clone(),
quantity: value.quantity.clone(), quantity: value.quantity.clone(),
unit_price: value.unit_price.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(), activity_type: "task".to_string(),
title: String::new(), title: String::new(),
body: String::new(), body: String::new(),
status: "open".to_string(), status: "active".to_string(),
priority: "normal".to_string(), priority: "normal".to_string(),
due_at: None, due_at: None,
} }
@@ -5977,7 +6233,7 @@ impl Default for PriceListImportForm {
fn default() -> Self { fn default() -> Self {
Self { Self {
source_name: "Preisliste.csv".to_string(), 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()), delimiter: Some(";".to_string()),
} }
} }
@@ -5996,10 +6252,14 @@ struct PriceListImportRow {
row_number: usize, row_number: usize,
item_number: String, item_number: String,
name: String, name: String,
manufacturer_code: Option<String>,
unit: String, unit: String,
tax_rate: String, tax_rate: String,
purchase_price: Option<String>, purchase_price: Option<String>,
sales_price: Option<String>, sales_price: Option<String>,
supplier_number: Option<String>,
supplier_item_number: Option<String>,
currency: Option<String>,
action: String, action: String,
error: Option<String>, error: Option<String>,
} }

View File

@@ -35,13 +35,13 @@ async function apiRequest<T>(method: string, path: string, body?: unknown): Prom
}); });
const text = await response.text(); const text = await response.text();
const data = text ? JSON.parse(text) : {}; const data = text ? parseResponseBody(text) : {};
if (!response.ok) { 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) { } catch (error) {
return { return {
ok: false, 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}`;
}

View File

@@ -12,10 +12,11 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
"update:modelValue": [value: string | null]; "update:modelValue": [value: string | null];
change: []; change: [value: string | null];
}>(); }>();
const query = ref(""); const query = ref("");
const open = ref(false);
const selected = computed(() => props.options.find((option) => option.id === props.modelValue)); const selected = computed(() => props.options.find((option) => option.id === props.modelValue));
const normalizedQuery = computed(() => query.value.trim().toLowerCase()); const normalizedQuery = computed(() => query.value.trim().toLowerCase());
@@ -33,7 +34,15 @@ const filteredOptions = computed(() => {
function select(value: string | null) { function select(value: string | null) {
emit("update:modelValue", value); emit("update:modelValue", value);
emit("change"); emit("change", value);
query.value = "";
open.value = false;
}
function closeSoon() {
window.setTimeout(() => {
open.value = false;
}, 120);
} }
</script> </script>
@@ -44,11 +53,14 @@ function select(value: string | null) {
type="search" type="search"
:placeholder="placeholder ?? 'Nach Nummer oder Name suchen'" :placeholder="placeholder ?? 'Nach Nummer oder Name suchen'"
:required="required && !modelValue" :required="required && !modelValue"
@focus="open = true"
@input="open = true"
@blur="closeSoon"
/> />
<div v-if="selected" class="search-select-current"> <div v-if="selected" class="search-select-current">
Ausgewählt: <strong>{{ selected.number ? `${selected.number} - ${selected.name}` : selected.name }}</strong> Ausgewählt: <strong>{{ selected.number ? `${selected.number} - ${selected.name}` : selected.name }}</strong>
</div> </div>
<div class="search-select-options"> <div v-if="open" class="search-select-options">
<button <button
v-if="allowEmpty" v-if="allowEmpty"
type="button" type="button"

View 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;
}

View File

@@ -422,10 +422,58 @@ code {
border-radius: 8px; border-radius: 8px;
display: grid; display: grid;
gap: 12px; 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; 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 { .form-panel {
max-width: 720px; max-width: 720px;
} }
@@ -566,22 +614,31 @@ select[multiple] {
.search-select { .search-select {
display: grid; display: grid;
gap: 8px; gap: 6px;
position: relative;
} }
.search-select-current { .search-select-current {
color: #435258; color: #435258;
font-size: 13px; font-size: 13px;
line-height: 1.25;
} }
.search-select-options { .search-select-options {
background: #ffffff;
border: 1px solid #dbe3e6; border: 1px solid #dbe3e6;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 14px 28px rgba(15, 37, 45, 0.16);
display: grid; display: grid;
gap: 4px; gap: 4px;
left: 0;
max-height: 220px; max-height: 220px;
overflow: auto; overflow: auto;
padding: 6px; padding: 6px;
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 30;
} }
.search-option { .search-option {

View File

@@ -52,11 +52,27 @@ export type Item = {
id: string; id: string;
item_number: string; item_number: string;
name: string; name: string;
manufacturer_code?: string | null;
unit: string; unit: string;
tax_rate: string; tax_rate: string;
default_purchase_price?: string | null; default_purchase_price?: string | null;
default_sales_price?: string | null; default_sales_price?: string | null;
status: string; 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 = { export type ItemPriceHistory = {
@@ -108,7 +124,9 @@ export type NumberRange = {
export type QuoteItem = { export type QuoteItem = {
id?: string; id?: string;
line_number?: number; line_number?: number;
item_id: string; line_kind?: "item" | "activity";
item_id?: string | null;
activity_id?: string | null;
description: string; description: string;
quantity: string; quantity: string;
unit_price: string; unit_price: string;
@@ -171,10 +189,14 @@ export type PriceListImportRow = {
row_number: number; row_number: number;
item_number: string; item_number: string;
name: string; name: string;
manufacturer_code?: string | null;
unit: string; unit: string;
tax_rate: string; tax_rate: string;
purchase_price?: string | null; purchase_price?: string | null;
sales_price?: string | null; sales_price?: string | null;
supplier_number?: string | null;
supplier_item_number?: string | null;
currency?: string | null;
action: string; action: string;
error?: string | null; error?: string | null;
}; };

View File

@@ -6,7 +6,7 @@ import PageHeader from "../components/PageHeader.vue";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges"; import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime"; import { liveUpdateState } from "../realtime";
import type { Activity } from "../types"; 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 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(() => const filteredRecords = computed(() =>
records.value.filter((record) => matchesObjectSearch(record.activity_number, record.title, search.value)) 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"); } 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); } 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 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); onMounted(load); watch(() => liveUpdateState.revision, load);
</script> </script>
<template> <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>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>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>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"><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> <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> </div>
</template> </template>

View File

@@ -3,6 +3,7 @@ import { onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api"; import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import { liveUpdateState } from "../realtime"; import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm } from "../types"; import type { CashDiscountTerm } from "../types";
@@ -53,9 +54,13 @@ function select(term: CashDiscountTerm) {
} }
async function save() { async function save() {
const payload = {
...form,
discount_percent: decimalString(form.discount_percent)
};
const result = selectedId.value const result = selectedId.value
? await apiPut<CashDiscountTerm>(`/api/v1/cash-discount-terms/${selectedId.value}`, form) ? await apiPut<CashDiscountTerm>(`/api/v1/cash-discount-terms/${selectedId.value}`, payload)
: await apiPost<CashDiscountTerm>("/api/v1/cash-discount-terms", form); : await apiPost<CashDiscountTerm>("/api/v1/cash-discount-terms", payload);
status.value = result.ok ? "Skonto-Regel gespeichert." : result.message; status.value = result.ok ? "Skonto-Regel gespeichert." : result.message;
kind.value = result.ok ? "success" : "error"; kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); } if (result.ok) { selectedId.value = result.data.id; await load(); }
@@ -74,7 +79,7 @@ watch(() => liveUpdateState.revision, load);
</script> </script>
<template> <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"> <div class="workspace-split">
<section class="panel list-panel"> <section class="panel list-panel">
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div> <div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
@@ -90,7 +95,7 @@ watch(() => liveUpdateState.revision, load);
<div class="form-grid"> <div class="form-grid">
<label class="field"><span>Code</span><input v-model="form.code" required /></label> <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>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>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>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> <label class="field"><span>Gültig ab</span><input v-model="form.valid_from" type="date" /></label>

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api"; import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges"; import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime"; import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm, Customer } from "../types"; import type { CashDiscountTerm, Customer } from "../types";
@@ -73,9 +74,13 @@ function selectCustomer(customer: Customer) {
async function save() { async function save() {
pending.value = true; pending.value = true;
const payload = {
...form,
standard_discount_percent: decimalString(form.standard_discount_percent)
};
const result = selectedId.value const result = selectedId.value
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, form) ? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, payload)
: await apiPost<Customer>("/api/v1/customers", form); : await apiPost<Customer>("/api/v1/customers", payload);
pending.value = false; pending.value = false;
if (!result.ok) { if (!result.ok) {
status.value = result.message; status.value = result.message;
@@ -145,7 +150,7 @@ watch(
<option value="blocked">Gesperrt</option> <option value="blocked">Gesperrt</option>
</select> </select>
</label> </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"> <label class="field">
<span>Skonto-Regel</span> <span>Skonto-Regel</span>
<select v-model="form.cash_discount_term_id"> <select v-model="form.cash_discount_term_id">

View File

@@ -4,6 +4,7 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue"; import SearchSelect from "../components/SearchSelect.vue";
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
import { reserveNextNumber } from "../number-ranges"; import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime"; import { liveUpdateState } from "../realtime";
import type { IncomingInvoice, IncomingInvoiceItem, Item, Supplier } from "../types"; 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 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 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 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 supplierName = (supplierId: string) => suppliers.value.find((supplier) => supplier.id === supplierId)?.name ?? supplierId;
const filteredInvoices = computed(() => { const filteredInvoices = computed(() => {
const needle = search.value.trim().toLowerCase(); 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 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")) ?? ""; } 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 })) }); } 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()); } 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); } function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1); }
async function 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(); } } 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(); } 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); onMounted(load); watch(() => liveUpdateState.revision, load);
</script> </script>
@@ -33,6 +58,6 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
<PageHeader title="Eingangsrechnungen" description="Lieferantenrechnungen mit Skonto-Bezug und Positionen." /> <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> <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> <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> <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> </template>

View File

@@ -3,33 +3,89 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api"; import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.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 { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime"; 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 items = ref<Item[]>([]);
const suppliers = ref<Supplier[]>([]);
const priceHistory = ref<ItemPriceHistory[]>([]); const priceHistory = ref<ItemPriceHistory[]>([]);
const selectedId = ref<string | null>(null); const selectedId = ref<string | null>(null);
const form = reactive(emptyForm()); const form = reactive(emptyForm());
const status = ref(""); const status = ref("");
const kind = ref<"info" | "success" | "error">("info"); const kind = ref<"info" | "success" | "error">("info");
const search = ref(""); const search = ref("");
const sortMode = ref<"name_desc" | "number_desc">("name_desc");
const filteredItems = computed(() => 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 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) { async function loadPriceHistory(itemId: string) {
const result = await apiGet<ItemPriceHistory[]>(`/api/v1/items/${itemId}/prices`); const result = await apiGet<ItemPriceHistory[]>(`/api/v1/items/${itemId}/prices`);
if (result.ok) priceHistory.value = result.data; if (result.ok) priceHistory.value = result.data;
} }
async function save() { async function save() {
const r = selectedId.value ? await apiPut<Item>(`/api/v1/items/${selectedId.value}`, form) : await apiPost<Item>("/api/v1/items", form); const payload = apiItemPayload();
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 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(); } 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); }); onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(); if (selectedId.value) await loadPriceHistory(selectedId.value); });
</script> </script>
<template> <template>
@@ -38,6 +94,7 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
<section class="panel list-panel"> <section class="panel list-panel">
<div class="section-title"><h2>Artikel</h2><button type="button" @click="createNew">Neu</button></div> <div class="section-title"><h2>Artikel</h2><button type="button" @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>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> <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-if="items.length === 0" class="empty">Keine Artikel vorhanden.</p>
<p v-else-if="filteredItems.length === 0" class="empty">Keine Treffer.</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"> <section class="panel detail-panel">
<form @submit.prevent="save"><div class="form-grid"> <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>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>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>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 class="field"><span>Steuersatz %</span><input v-model="form.tax_rate" type="number" min="0" step="0.01" required /></label> <label v-if="selectedUnitValue() === '__custom'" class="field"><span>Eigene Einheit</span><input v-model="form.unit" 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>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>Verkaufspreis</span><input v-model="form.default_sales_price" type="number" min="0" step="0.01" /></label> <label class="field"><span>Einkaufspreis</span><input v-model="form.default_purchase_price" inputmode="decimal" @blur="formatDecimalInput(form, 'default_purchase_price')" /></label>
<label class="field"><span>Verkaufspreis</span><input v-model="form.default_sales_price" inputmode="decimal" @blur="formatDecimalInput(form, 'default_sales_price')" /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label> <label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">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"> <div v-if="selectedId" class="sub-panel">
<h2>Preishistorie</h2> <h2>Preishistorie</h2>
<div v-for="entry in priceHistory" :key="entry.id" class="data-row"> <div v-for="entry in priceHistory" :key="entry.id" class="data-row">
<strong>{{ new Date(entry.valid_from).toLocaleString("de-DE") }}</strong> <strong>{{ new Date(entry.valid_from).toLocaleString("de-DE") }}</strong>
<span>EK {{ entry.purchase_price ?? "-" }}</span> <span>EK {{ formatEuro(entry.purchase_price) }}</span>
<span>VK {{ entry.sales_price ?? "-" }}</span> <span>VK {{ formatEuro(entry.sales_price) }}</span>
<small>{{ entry.source }}</small> <small>{{ entry.source }}</small>
</div> </div>
<p v-if="priceHistory.length === 0" class="empty">Noch keine Preisänderung vorhanden.</p> <p v-if="priceHistory.length === 0" class="empty">Noch keine Preisänderung vorhanden.</p>

View File

@@ -3,6 +3,7 @@ import { onMounted, reactive, ref, watch } from "vue";
import { apiGet, apiPut } from "../api"; import { apiGet, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import { decimalString, taxRateOptions } from "../format";
import { liveUpdateState } from "../realtime"; import { liveUpdateState } from "../realtime";
type OrganizationSetupForm = { type OrganizationSetupForm = {
@@ -73,7 +74,11 @@ async function submit() {
status.value = "Sende Anfrage..."; status.value = "Sende Anfrage...";
statusKind.value = "info"; 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; pending.value = false;
status.value = result.ok ? "Gespeichert." : result.message; status.value = result.ok ? "Gespeichert." : result.message;
statusKind.value = result.ok ? "success" : "error"; 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>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>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>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-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" type="number" required /></label> <label class="field"><span>Standard-Zahlungsziel</span><input v-model="form.default_payment_days" inputmode="numeric" required /></label>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" :disabled="pending">Firmendaten speichern</button> <button type="submit" :disabled="pending">Firmendaten speichern</button>

View File

@@ -4,21 +4,24 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue"; import SearchSelect from "../components/SearchSelect.vue";
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
import { reserveNextNumber } from "../number-ranges"; import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime"; 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 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 invoices = ref<OutgoingInvoice[]>([]);
const customers = ref<Customer[]>([]); const customers = ref<Customer[]>([]);
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const activities = ref<Activity[]>([]);
const quotes = ref<Quote[]>([]); const quotes = ref<Quote[]>([]);
const selectedId = ref<string | null>(null); const selectedId = ref<string | null>(null);
const form = reactive(emptyForm()); const form = reactive(emptyForm());
const status = ref(""); const status = ref("");
const kind = ref<"info" | "success" | "error">("info"); const kind = ref<"info" | "success" | "error">("info");
const search = ref(""); const search = ref("");
const editingLineIndex = ref(0);
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId; const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
const filteredInvoices = computed(() => { const filteredInvoices = computed(() => {
const needle = search.value.trim().toLowerCase(); const needle = search.value.trim().toLowerCase();
@@ -29,25 +32,93 @@ const filteredInvoices = computed(() => {
}); });
async function load() { async function load() {
const [invoiceResult, customerResult, itemResult, quoteResult] = await Promise.all([ 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<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 (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 (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active"); if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
if (quoteResult.ok) quotes.value = quoteResult.data; 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")) ?? ""; } 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 })) }); } 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()); } 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); } 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) { function applyItemDefaults(line: OutgoingInvoiceItem) {
const item = items.value.find((record) => record.id === line.item_id); const item = items.value.find((record) => record.id === line.item_id);
if (!item) return; 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() { 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(); } status.value = result.ok ? "Rechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
} }
async function finalize() { if (!selectedId.value) return; const result = await apiPost(`/api/v1/outgoing-invoices/${selectedId.value}/finalize`, {}); status.value = result.ok ? "Rechnung abgeschlossen." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); } async function 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>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"> <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>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>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>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>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> <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><button type="button" @click="addLine">Position</button></div> </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"> <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> <template v-if="editingLineIndex !== index">
<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> <div class="position-summary">
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label> <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></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="finalize">Abschließen</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
</div> </div>
</template> </template>

View File

@@ -7,7 +7,7 @@ import type { PriceListImportApplyResponse, PriceListImportPreview } from "../ty
const sourceName = ref("Preisliste.csv"); const sourceName = ref("Preisliste.csv");
const delimiter = ref(";"); 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 preview = ref<PriceListImportPreview | null>(null);
const status = ref(""); const status = ref("");
const kind = ref<"info" | "success" | "error">("info"); const kind = ref<"info" | "success" | "error">("info");
@@ -45,7 +45,9 @@ async function applyImport() {
<strong>{{ row.row_number }} {{ row.action }}</strong> <strong>{{ row.row_number }} {{ row.action }}</strong>
<span>{{ row.item_number }}</span> <span>{{ row.item_number }}</span>
<span>{{ row.name }}</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> </div>
</section> </section>
</template> </template>

View File

@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api"; import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import type { PriceRule } from "../types"; import type { PriceRule } from "../types";
const rules = ref<PriceRule[]>([]); const rules = ref<PriceRule[]>([]);
@@ -20,7 +21,11 @@ const status = ref("");
const kind = ref<"info" | "success" | "error">("info"); const kind = ref<"info" | "success" | "error">("info");
function payload() { 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() { 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>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>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>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="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> <label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
</div> </div>

View File

@@ -4,22 +4,25 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue"; import SearchSelect from "../components/SearchSelect.vue";
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
import { reserveNextNumber } from "../number-ranges"; import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime"; 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 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 quotes = ref<Quote[]>([]);
const customers = ref<Customer[]>([]); const customers = ref<Customer[]>([]);
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const activities = ref<Activity[]>([]);
const selectedId = ref<string | null>(null); const selectedId = ref<string | null>(null);
const form = reactive(emptyForm()); const form = reactive(emptyForm());
const status = ref(""); const status = ref("");
const kind = ref<"info" | "success" | "error">("info"); const kind = ref<"info" | "success" | "error">("info");
const selectedQuote = computed(() => quotes.value.find((quote) => quote.id === selectedId.value)); const selectedQuote = computed(() => quotes.value.find((quote) => quote.id === selectedId.value));
const search = ref(""); const search = ref("");
const editingLineIndex = ref(0);
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId; const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
const filteredQuotes = computed(() => { const filteredQuotes = computed(() => {
const needle = search.value.trim().toLowerCase(); const needle = search.value.trim().toLowerCase();
@@ -30,33 +33,39 @@ const filteredQuotes = computed(() => {
}); });
async function load() { async function load() {
const [quoteResult, customerResult, itemResult] = await Promise.all([ const [quoteResult, customerResult, itemResult, activityResult] = await Promise.all([
apiGet<Quote[]>("/api/v1/quotes"), apiGet<Quote[]>("/api/v1/quotes"),
apiGet<Customer[]>("/api/v1/customers"), 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 (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 (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.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() { async function createNew() {
selectedId.value = null; selectedId.value = null;
Object.assign(form, emptyForm()); Object.assign(form, emptyForm());
editingLineIndex.value = 0;
form.quote_number = (await reserveNextNumber("quotes")) ?? ""; form.quote_number = (await reserveNextNumber("quotes")) ?? "";
} }
function select(quote: Quote) { function select(quote: Quote) {
selectedId.value = quote.id; 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() { function addLine() {
form.items.push(emptyItem()); form.items.push(emptyItem());
editingLineIndex.value = form.items.length - 1;
} }
function removeLine(index: number) { function removeLine(index: number) {
if (form.items.length > 1) form.items.splice(index, 1); if (form.items.length > 1) form.items.splice(index, 1);
editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1);
} }
function applyItemDefaults(line: QuoteItem) { function applyItemDefaults(line: QuoteItem) {
@@ -65,13 +74,86 @@ function applyItemDefaults(line: QuoteItem) {
line.description = item.name; line.description = item.name;
line.unit_price = item.default_sales_price ?? "0"; line.unit_price = item.default_sales_price ?? "0";
line.original_unit_price = item.default_sales_price ?? null; 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() { 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 const result = selectedId.value
? await apiPut<Quote>(`/api/v1/quotes/${selectedId.value}`, form) ? await apiPut<Quote>(`/api/v1/quotes/${selectedId.value}`, payload)
: await apiPost<Quote>("/api/v1/quotes", form); : await apiPost<Quote>("/api/v1/quotes", payload);
status.value = result.ok ? "Angebot gespeichert." : result.message; status.value = result.ok ? "Angebot gespeichert." : result.message;
kind.value = result.ok ? "success" : "error"; kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); } if (result.ok) { selectedId.value = result.data.id; await load(); }
@@ -107,22 +189,38 @@ watch(() => liveUpdateState.revision, load);
<form @submit.prevent="save"> <form @submit.prevent="save">
<div class="form-grid"> <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>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>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>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> <label class="field full-width"><span>Notizen</span><textarea v-model="form.notes" rows="3" /></label>
</div> </div>
<div class="sub-panel"> <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"> <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> <template v-if="editingLineIndex !== index">
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" required /></label> <div class="position-summary">
<label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" required /></label> <strong>{{ lineName(line) }}</strong>
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label> <span>{{ line.quantity }}</span>
<label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label> <span>{{ line.unit_price }}</span>
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label> <span>{{ lineTotal(line) }}</span>
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button> <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> </div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div> <div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div>

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api"; import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue"; import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue"; import PageHeader from "../components/PageHeader.vue";
import { decimalString } from "../format";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges"; import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime"; import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm, Supplier } from "../types"; 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 } }); } function select(item: Supplier) { selectedId.value = item.id; Object.assign(form, { ...item, details: { ...item.details } }); }
async function save() { async function save() {
const payload = {
...form,
standard_discount_percent: decimalString(form.standard_discount_percent)
};
const result = selectedId.value const result = selectedId.value
? await apiPut<Supplier>(`/api/v1/suppliers/${selectedId.value}`, form) ? await apiPut<Supplier>(`/api/v1/suppliers/${selectedId.value}`, payload)
: await apiPost<Supplier>("/api/v1/suppliers", form); : await apiPost<Supplier>("/api/v1/suppliers", payload);
status.value = result.ok ? "Lieferant gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; status.value = result.ok ? "Lieferant gespeichert." : result.message; kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); } 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>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>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>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"> <label class="field">
<span>Skonto-Regel</span> <span>Skonto-Regel</span>
<select v-model="form.cash_discount_term_id"> <select v-model="form.cash_discount_term_id">