feat: enhance UI and functionality for price rules, quotes, and suppliers

- Updated PriceRulesPage.vue to replace "Neu" button with a more intuitive "+" button for creating new rules.
- Enhanced QuotesPage.vue with improved handling of quote lines, including read-only states, unique line validation, and better formatting for monetary values.
- Added accordion sections for better organization of quote details and improved user experience.
- Updated SuppliersPage.vue to replace "Neu" button with a "+" button for adding new suppliers.
- Introduced new database migrations to add price category and default sales price fields to activities, and to include 'invoice_created' status in quotes.
This commit is contained in:
Torsten Schulz (local)
2026-06-04 21:47:42 +02:00
parent d5b6f39177
commit af928a838f
18 changed files with 970 additions and 185 deletions

View File

@@ -0,0 +1,17 @@
alter table {schema}.activities
add column if not exists price_category text not null default 'h',
add column if not exists default_sales_price numeric(14, 4);
alter table {schema}.activities
drop constraint if exists activities_price_category_valid;
alter table {schema}.activities
add constraint activities_price_category_valid check (price_category in ('h', 'tag', 'pauschal'));
alter table {schema}.activities
drop constraint if exists activities_default_sales_price_non_negative;
alter table {schema}.activities
add constraint activities_default_sales_price_non_negative check (
default_sales_price is null or default_sales_price >= 0
);

View File

@@ -0,0 +1,7 @@
alter table {schema}.quotes
drop constraint if exists quotes_status_valid;
alter table {schema}.quotes
add constraint quotes_status_valid check (
status in ('draft', 'sent', 'accepted', 'rejected', 'expired', 'cancelled', 'invoice_created')
);

View File

@@ -225,6 +225,14 @@ fn default_line_kind() -> String {
"item".to_string()
}
fn default_activity_type() -> String {
"work_step".to_string()
}
fn default_price_category() -> String {
"h".to_string()
}
fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
@@ -659,10 +667,15 @@ pub struct DocumentAuditLogResponse {
pub struct ActivityRequest {
#[serde(default)]
pub activity_number: Option<String>,
#[serde(default = "default_activity_type")]
pub activity_type: String,
pub title: String,
pub body: String,
pub status: String,
#[serde(default = "default_price_category")]
pub price_category: String,
#[serde(default, deserialize_with = "deserialize_optional_string_or_number")]
pub default_sales_price: Option<String>,
pub priority: String,
pub due_at: Option<DateTime<Utc>>,
}
@@ -675,6 +688,8 @@ pub struct ActivityResponse {
pub title: String,
pub body: String,
pub status: String,
pub price_category: String,
pub default_sales_price: Option<String>,
pub priority: String,
pub due_at: Option<DateTime<Utc>>,
}
@@ -1959,6 +1974,7 @@ pub async fn update_quote(
) -> Result<Json<QuoteResponse>, ApiError> {
let db = state.db()?;
let context = require_permission(db, &headers, "quotes.write").await?;
ensure_quote_editable(db, &context.schema_name, quote_id).await?;
let mut payload = payload;
apply_customer_terms_to_quote(db, &context.schema_name, &mut payload).await?;
validate_quote_request(&payload)?;
@@ -1974,6 +1990,7 @@ pub async fn delete_quote(
) -> Result<Json<serde_json::Value>, ApiError> {
let db = state.db()?;
let context = require_permission(db, &headers, "quotes.delete").await?;
ensure_quote_editable(db, &context.schema_name, quote_id).await?;
let sql = format!(
"update {schema}.quotes set status='cancelled', updated_at=now() where id=$1",
schema = context.schema_name
@@ -1992,6 +2009,11 @@ pub async fn convert_quote_to_outgoing_invoice(
let db = state.db()?;
let context = require_permission(db, &headers, "quotes.convert_to_invoice").await?;
let quote = load_quote_by_id(db, &state.crypto, &context.schema_name, quote_id).await?;
if quote.status != "accepted" {
return Err(ApiError::bad_request(
"Nur angenommene Angebote können in eine Rechnung umgewandelt werden",
));
}
let invoice_id = Uuid::new_v4();
let invoice = OutgoingInvoiceRequest {
invoice_number: next_number(db, &context.schema_name, "outgoing_invoices").await?,
@@ -2020,6 +2042,12 @@ pub async fn convert_quote_to_outgoing_invoice(
};
validate_outgoing_invoice_request(&invoice)?;
write_outgoing_invoice(db, &state.crypto, &context, invoice_id, &invoice, true).await?;
ensure_safe_schema_name(&context.schema_name)?;
let status_sql = format!(
"update {schema}.quotes set status='invoice_created', updated_at=now() where id=$1",
schema = context.schema_name
);
sqlx::query(&status_sql).bind(quote_id).execute(db).await?;
emit_change(&state, "Angebot in Rechnung umgewandelt");
Ok(Json(outgoing_invoice_response(invoice_id, invoice, None)))
}
@@ -2101,11 +2129,21 @@ pub async fn delete_outgoing_invoice(
let context = require_permission(db, &headers, "outgoing_invoices.delete").await?;
ensure_outgoing_invoice_editable(db, &context.schema_name, invoice_id).await?;
let sql = format!(
"update {schema}.outgoing_invoices set status='cancelled', updated_at=now() where id=$1",
"update {schema}.outgoing_invoices set status='cancelled', updated_at=now() where id=$1 returning source_quote_id",
schema = context.schema_name
);
let result = sqlx::query(&sql).bind(invoice_id).execute(db).await?;
ensure_changed(result.rows_affected(), "Ausgangsrechnung nicht gefunden")?;
let source_quote_id = sqlx::query_scalar::<_, Option<Uuid>>(&sql)
.bind(invoice_id)
.fetch_optional(db)
.await?
.ok_or_else(|| ApiError::not_found("Ausgangsrechnung nicht gefunden"))?;
if let Some(quote_id) = source_quote_id {
let quote_sql = format!(
"update {schema}.quotes set status='accepted', updated_at=now() where id=$1 and status='invoice_created'",
schema = context.schema_name
);
sqlx::query(&quote_sql).bind(quote_id).execute(db).await?;
}
emit_change(&state, "Ausgangsrechnung storniert");
Ok(Json(json!({ "deleted": true, "id": invoice_id })))
}
@@ -2192,6 +2230,7 @@ pub async fn update_incoming_invoice(
) -> Result<Json<IncomingInvoiceResponse>, ApiError> {
let db = state.db()?;
let context = require_permission(db, &headers, "incoming_invoices.write").await?;
ensure_incoming_invoice_editable(db, &context.schema_name, invoice_id).await?;
let mut payload = payload;
apply_supplier_terms_to_incoming_invoice(db, &context.schema_name, &mut payload).await?;
validate_incoming_invoice_request(&payload)?;
@@ -2207,6 +2246,7 @@ pub async fn delete_incoming_invoice(
) -> Result<Json<serde_json::Value>, ApiError> {
let db = state.db()?;
let context = require_permission(db, &headers, "incoming_invoices.delete").await?;
ensure_incoming_invoice_editable(db, &context.schema_name, invoice_id).await?;
let sql = format!(
"update {schema}.incoming_invoices set status='cancelled', updated_at=now() where id=$1",
schema = context.schema_name
@@ -2979,8 +3019,25 @@ pub async fn delete_item(
) -> Result<Json<serde_json::Value>, ApiError> {
let db = state.db()?;
let context = require_permission(db, &headers, "items.delete").await?;
set_record_inactive(db, &context.schema_name, "items", item_id).await?;
emit_change(&state, "Artikel deaktiviert");
ensure_item_unused(db, &context.schema_name, item_id).await?;
let delete_supplier_prices = format!(
"delete from {schema}.item_supplier_prices where item_id=$1",
schema = context.schema_name
);
sqlx::query(&delete_supplier_prices)
.bind(item_id)
.execute(db)
.await?;
let delete_price_history = format!(
"delete from {schema}.item_price_history where item_id=$1",
schema = context.schema_name
);
sqlx::query(&delete_price_history)
.bind(item_id)
.execute(db)
.await?;
delete_record(db, &context.schema_name, "items", item_id, "Artikel nicht gefunden").await?;
emit_change(&state, "Artikel gelöscht");
Ok(Json(json!({ "deleted": true, "id": item_id })))
}
@@ -2993,7 +3050,8 @@ pub async fn list_activities(
let sql = format!(
r#"
select id, activity_number, activity_type, title_ciphertext, title_nonce, title_key_id,
body_ciphertext, body_nonce, body_key_id, status, priority, due_at
body_ciphertext, body_nonce, body_key_id, status, price_category,
default_sales_price::text default_sales_price, priority, due_at
from {schema}.activities order by updated_at desc, due_at
"#,
schema = context.schema_name
@@ -3051,13 +3109,24 @@ pub async fn delete_activity(
) -> Result<Json<serde_json::Value>, ApiError> {
let db = state.db()?;
let context = require_permission(db, &headers, "activities.delete").await?;
let sql = format!(
"update {schema}.activities set status = 'inactive', updated_at = now() where id = $1",
ensure_activity_unused(db, &context.schema_name, activity_id).await?;
let delete_links = format!(
"delete from {schema}.activity_links where activity_id=$1",
schema = context.schema_name
);
let result = sqlx::query(&sql).bind(activity_id).execute(db).await?;
ensure_changed(result.rows_affected(), "Aktivität nicht gefunden")?;
emit_change(&state, "Aktivität deaktiviert");
sqlx::query(&delete_links)
.bind(activity_id)
.execute(db)
.await?;
delete_record(
db,
&context.schema_name,
"activities",
activity_id,
"Aktivität nicht gefunden",
)
.await?;
emit_change(&state, "Aktivität gelöscht");
Ok(Json(json!({ "deleted": true, "id": activity_id })))
}
@@ -3748,6 +3817,7 @@ fn validate_quote_request(payload: &QuoteRequest) -> Result<(), ApiError> {
"draft",
"sent",
"accepted",
"invoice_created",
"rejected",
"expired",
"cancelled",
@@ -3965,6 +4035,57 @@ async fn ensure_activity_exists(
}
}
async fn ensure_item_unused(db: &PgPool, schema_name: &str, item_id: Uuid) -> Result<(), ApiError> {
ensure_safe_schema_name(schema_name)?;
let sql = format!(
r#"
select
exists(select 1 from {schema}.quote_items where item_id=$1)
or exists(select 1 from {schema}.outgoing_invoice_items where item_id=$1)
or exists(select 1 from {schema}.incoming_invoice_items where item_id=$1)
"#,
schema = schema_name
);
if sqlx::query_scalar::<_, bool>(&sql)
.bind(item_id)
.fetch_one(db)
.await?
{
Err(ApiError::bad_request(
"Artikel wurde bereits verwendet und kann nur deaktiviert werden",
))
} else {
Ok(())
}
}
async fn ensure_activity_unused(
db: &PgPool,
schema_name: &str,
activity_id: Uuid,
) -> Result<(), ApiError> {
ensure_safe_schema_name(schema_name)?;
let sql = format!(
r#"
select
exists(select 1 from {schema}.quote_items where activity_id=$1)
or exists(select 1 from {schema}.outgoing_invoice_items where activity_id=$1)
"#,
schema = schema_name
);
if sqlx::query_scalar::<_, bool>(&sql)
.bind(activity_id)
.fetch_one(db)
.await?
{
Err(ApiError::bad_request(
"Aktivität wurde bereits verwendet und kann nur deaktiviert werden",
))
} else {
Ok(())
}
}
fn validate_line_source(
line_kind: &str,
item_id: Option<Uuid>,
@@ -4405,17 +4526,64 @@ async fn ensure_outgoing_invoice_editable(
) -> Result<(), ApiError> {
ensure_safe_schema_name(schema_name)?;
let sql = format!(
"select finalized_at from {schema}.outgoing_invoices where id=$1",
"select status, finalized_at from {schema}.outgoing_invoices where id=$1",
schema = schema_name
);
let finalized_at = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(&sql)
let row = sqlx::query(&sql)
.bind(invoice_id)
.fetch_optional(db)
.await?
.ok_or_else(|| ApiError::not_found("Ausgangsrechnung nicht gefunden"))?;
if finalized_at.is_some() {
let status: String = row.get("status");
let finalized_at: Option<DateTime<Utc>> = row.get("finalized_at");
if finalized_at.is_some() || ["finalized", "paid", "cancelled"].contains(&status.as_str()) {
Err(ApiError::bad_request(
"Abgeschlossene Rechnungen dürfen nicht geändert werden",
"Abgeschlossene, bezahlte oder stornierte Rechnungen dürfen nicht geändert werden",
))
} else {
Ok(())
}
}
async fn ensure_quote_editable(
db: &PgPool,
schema_name: &str,
quote_id: Uuid,
) -> Result<(), ApiError> {
ensure_safe_schema_name(schema_name)?;
let sql = format!("select status from {schema}.quotes where id=$1", schema = schema_name);
let status = sqlx::query_scalar::<_, String>(&sql)
.bind(quote_id)
.fetch_optional(db)
.await?
.ok_or_else(|| ApiError::not_found("Angebot nicht gefunden"))?;
if ["invoice_created", "cancelled"].contains(&status.as_str()) {
Err(ApiError::bad_request(
"Angebote mit Rechnung oder stornierte Angebote dürfen nicht geändert werden",
))
} else {
Ok(())
}
}
async fn ensure_incoming_invoice_editable(
db: &PgPool,
schema_name: &str,
invoice_id: Uuid,
) -> Result<(), ApiError> {
ensure_safe_schema_name(schema_name)?;
let sql = format!(
"select status from {schema}.incoming_invoices where id=$1",
schema = schema_name
);
let status = sqlx::query_scalar::<_, String>(&sql)
.bind(invoice_id)
.fetch_optional(db)
.await?
.ok_or_else(|| ApiError::not_found("Eingangsrechnung nicht gefunden"))?;
if ["paid", "cancelled"].contains(&status.as_str()) {
Err(ApiError::bad_request(
"Bezahlte oder stornierte Eingangsrechnungen dürfen nicht geändert werden",
))
} else {
Ok(())
@@ -5921,6 +6089,12 @@ fn validate_activity_request(payload: &ActivityRequest) -> Result<(), ApiError>
if !["active", "inactive"].contains(&payload.status.as_str()) {
return Err(ApiError::bad_request("Ungültiger Aktivitätsstatus"));
}
if !["h", "tag", "pauschal"].contains(&payload.price_category.as_str()) {
return Err(ApiError::bad_request("Ungültige Preiskategorie"));
}
if let Some(price) = &payload.default_sales_price {
validate_number(price, "Aktivitätspreis")?;
}
if !["low", "normal", "high", "critical"].contains(&payload.priority.as_str()) {
return Err(ApiError::bad_request("Ungültige Priorität"));
}
@@ -5943,13 +6117,21 @@ async fn write_activity(
};
let sql = if insert {
format!(
r#"insert into {schema}.activities (id,activity_number,activity_type,title_ciphertext,title_nonce,title_key_id,body_ciphertext,body_nonce,body_key_id,status,priority,due_at,created_by_user_id)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)"#,
r#"insert into {schema}.activities (
id, activity_number, activity_type, title_ciphertext, title_nonce, title_key_id,
body_ciphertext, body_nonce, body_key_id, status, price_category,
default_sales_price, priority, due_at, created_by_user_id
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::numeric,$13,$14,$15)"#,
schema = context.schema_name
)
} else {
format!(
r#"update {schema}.activities set activity_number=activity_number,activity_type=$3,title_ciphertext=$4,title_nonce=$5,title_key_id=$6,body_ciphertext=$7,body_nonce=$8,body_key_id=$9,status=$10,priority=$11,due_at=$12,created_by_user_id=coalesce(created_by_user_id,$13),updated_at=now() where id=$1"#,
r#"update {schema}.activities set activity_number=activity_number,activity_type=$3,
title_ciphertext=$4,title_nonce=$5,title_key_id=$6,
body_ciphertext=$7,body_nonce=$8,body_key_id=$9,status=$10,
price_category=$11,default_sales_price=$12::numeric,priority=$13,
due_at=$14,created_by_user_id=coalesce(created_by_user_id,$15),
updated_at=now() where id=$1"#,
schema = context.schema_name
)
};
@@ -5970,6 +6152,10 @@ async fn write_activity(
.bind(body.as_ref().map(|field| field.nonce.clone()))
.bind(body.as_ref().map(|field| field.key_id.clone()))
.bind(&payload.status)
.bind(&payload.price_category)
.bind(normalize_optional_number(
payload.default_sales_price.as_deref(),
))
.bind(&payload.priority)
.bind(payload.due_at)
.bind(context.user_id)
@@ -5989,6 +6175,8 @@ fn activity_response(id: Uuid, payload: ActivityRequest) -> ActivityResponse {
title: payload.title,
body: payload.body,
status: payload.status,
price_category: payload.price_category,
default_sales_price: payload.default_sales_price,
priority: payload.priority,
due_at: payload.due_at,
}
@@ -6017,6 +6205,8 @@ fn activity_from_row(
)?,
body,
status: row.get("status"),
price_category: row.get("price_category"),
default_sales_price: row.get("default_sales_price"),
priority: row.get("priority"),
due_at: row.get("due_at"),
})
@@ -6034,6 +6224,18 @@ async fn set_record_inactive(
ensure_changed(result.rows_affected(), "Datensatz nicht gefunden")
}
async fn delete_record(
db: &PgPool,
schema_name: &str,
table: &str,
id: Uuid,
not_found_message: &str,
) -> Result<(), ApiError> {
let sql = format!("delete from {schema_name}.{table} where id=$1");
let result = sqlx::query(&sql).bind(id).execute(db).await?;
ensure_changed(result.rows_affected(), not_found_message)
}
fn ensure_changed(rows_affected: u64, message: &str) -> Result<(), ApiError> {
if rows_affected == 0 {
Err(ApiError::not_found(message))
@@ -6071,10 +6273,15 @@ fn validate_number(value: &str, field: &str) -> Result<(), ApiError> {
}
fn validate_tax_rate(value: &str) -> Result<(), ApiError> {
let normalized = normalize_number_string(value);
match normalized.as_str() {
"0" | "0.0" | "0.00" | "7" | "7.0" | "7.00" | "19" | "19.0" | "19.00" => Ok(()),
_ => Err(ApiError::bad_request("Steuersatz muss 0, 7 oder 19 % sein")),
let parsed = parse_number(value)
.map_err(|_| ApiError::bad_request("Steuersatz muss 0, 7 oder 19 % sein"))?;
if [0.0, 7.0, 19.0]
.iter()
.any(|allowed| (parsed - allowed).abs() < f64::EPSILON)
{
Ok(())
} else {
Err(ApiError::bad_request("Steuersatz muss 0, 7 oder 19 % sein"))
}
}
@@ -6636,6 +6843,8 @@ async fn provision_company_schema_tx(
include_str!("../company-migrations/0013_item_unit_default.sql"),
include_str!("../company-migrations/0014_activity_line_sources.sql"),
include_str!("../company-migrations/0015_activity_status_active_inactive.sql"),
include_str!("../company-migrations/0016_activity_price_fields.sql"),
include_str!("../company-migrations/0017_quote_invoice_created_status.sql"),
] {
let sql = template
.lines()

View File

@@ -761,6 +761,15 @@ fn apply_quote_item_defaults(line: &mut QuoteItemForm, items: &[Item]) {
}
}
fn item_unit_label(item_id: &str, items: &[Item]) -> String {
items
.iter()
.find(|item| item.id == item_id)
.map(|item| item.unit.clone())
.filter(|unit| !unit.trim().is_empty())
.unwrap_or_else(|| "-".to_string())
}
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();
@@ -817,6 +826,9 @@ fn invoice_items_editor(
ui.label("Menge");
ui.text_edit_singleline(&mut line.quantity);
ui.end_row();
ui.label("Einheit");
ui.label(item_unit_label(&line.item_id, items));
ui.end_row();
ui.label("Preis");
ui.text_edit_singleline(&mut line.unit_price);
ui.end_row();
@@ -873,6 +885,9 @@ fn incoming_invoice_items_editor(
ui.label("Menge");
ui.text_edit_singleline(&mut line.quantity);
ui.end_row();
ui.label("Einheit");
ui.label(item_unit_label(line.item_id.as_deref().unwrap_or_default(), items));
ui.end_row();
ui.label("Preis");
ui.text_edit_singleline(&mut line.unit_price);
ui.end_row();
@@ -1617,7 +1632,7 @@ impl CompanyToolApp {
},
AdminEvent::ItemDeleted(result) => {
self.items_status = result
.map(|_| "Artikel deaktiviert.".to_string())
.map(|_| "Artikel gelöscht.".to_string())
.unwrap_or_else(|message| message);
self.load_items();
}
@@ -1711,7 +1726,7 @@ impl CompanyToolApp {
},
AdminEvent::ActivityDeleted(result) => {
self.activities_status = result
.map(|_| "Aktivität deaktiviert.".to_string())
.map(|_| "Aktivität gelöscht.".to_string())
.unwrap_or_else(|message| message);
self.load_activities();
}
@@ -4305,7 +4320,7 @@ impl CompanyToolApp {
if ui
.add_enabled(
self.selected_item_id.is_some(),
egui::Button::new("Deaktivieren"),
egui::Button::new("Löschen"),
)
.clicked()
{
@@ -4869,6 +4884,9 @@ impl CompanyToolApp {
ui.label("Menge");
ui.text_edit_singleline(&mut line.quantity);
ui.end_row();
ui.label("Einheit");
ui.label(item_unit_label(&line.item_id, &items));
ui.end_row();
ui.label("Preis");
ui.text_edit_singleline(&mut line.unit_price);
ui.end_row();
@@ -5245,9 +5263,6 @@ impl CompanyToolApp {
self.activity_form.activity_number.as_deref().unwrap_or(""),
));
});
form_row(ui, "Typ", |ui| {
ui.text_edit_singleline(&mut self.activity_form.activity_type);
});
form_row(ui, "Status", |ui| {
egui::ComboBox::from_id_salt("activity_status")
.selected_text(match self.activity_form.status.as_str() {
@@ -5270,6 +5285,34 @@ impl CompanyToolApp {
form_row(ui, "Priorität", |ui| {
ui.text_edit_singleline(&mut self.activity_form.priority);
});
form_row(ui, "Preiskategorie", |ui| {
egui::ComboBox::from_id_salt("activity_price_category")
.selected_text(match self.activity_form.price_category.as_str() {
"tag" => "Tag",
"pauschal" => "Pauschal",
_ => "h",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.activity_form.price_category,
"h".to_string(),
"h",
);
ui.selectable_value(
&mut self.activity_form.price_category,
"tag".to_string(),
"Tag",
);
ui.selectable_value(
&mut self.activity_form.price_category,
"pauschal".to_string(),
"Pauschal",
);
});
});
form_row(ui, "Verkaufspreis", |ui| {
ui.text_edit_singleline(&mut self.activity_form.default_sales_price);
});
form_row(ui, "Beschreibung", |ui| {
ui.text_edit_multiline(&mut self.activity_form.body);
});
@@ -5280,7 +5323,7 @@ impl CompanyToolApp {
if ui
.add_enabled(
self.selected_activity_id.is_some(),
egui::Button::new("Deaktivieren"),
egui::Button::new("Löschen"),
)
.clicked()
{
@@ -6182,6 +6225,8 @@ struct Activity {
title: String,
body: String,
status: String,
price_category: String,
default_sales_price: Option<String>,
priority: String,
due_at: Option<String>,
}
@@ -6192,6 +6237,8 @@ struct ActivityForm {
title: String,
body: String,
status: String,
price_category: String,
default_sales_price: String,
priority: String,
due_at: Option<String>,
}
@@ -6199,10 +6246,12 @@ impl Default for ActivityForm {
fn default() -> Self {
Self {
activity_number: None,
activity_type: "task".to_string(),
activity_type: "work_step".to_string(),
title: String::new(),
body: String::new(),
status: "active".to_string(),
price_category: "h".to_string(),
default_sales_price: "0".to_string(),
priority: "normal".to_string(),
due_at: None,
}
@@ -6216,6 +6265,8 @@ impl From<&Activity> for ActivityForm {
title: value.title.clone(),
body: value.body.clone(),
status: value.status.clone(),
price_category: value.price_category.clone(),
default_sales_price: value.default_sales_price.clone().unwrap_or_default(),
priority: value.priority.clone(),
due_at: value.due_at.clone(),
}

View File

@@ -38,6 +38,14 @@ button.secondary {
color: #26343b;
}
button.add-button {
font-size: 20px;
line-height: 1;
min-height: 34px;
min-width: 38px;
padding: 4px 10px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
@@ -422,7 +430,7 @@ code {
border-radius: 8px;
display: grid;
gap: 12px;
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px;
padding: 12px;
}
@@ -438,13 +446,13 @@ code {
font-size: 12px;
font-weight: 700;
gap: 10px;
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px;
padding: 0 12px;
text-transform: uppercase;
}
.position-table-header.compact {
grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(76px, 0.5fr)) 130px;
grid-template-columns: minmax(140px, 1fr) 64px 70px 80px 52px 58px;
}
.position-summary {
@@ -474,6 +482,38 @@ code {
grid-column: 1 / -1;
}
.position-add-row {
display: flex;
justify-content: stretch;
margin-top: 2px;
}
.position-add-row button {
min-height: 34px;
width: 100%;
}
.position-add-row .add-button {
width: 100%;
}
.position-row-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.position-row-actions button {
min-height: 28px;
padding: 2px 7px;
}
.position-row-actions .icon-button {
font-size: 16px;
line-height: 1;
min-width: 30px;
}
.form-panel {
max-width: 720px;
}
@@ -541,9 +581,14 @@ code {
}
.section-title {
flex-wrap: wrap;
margin-bottom: 14px;
}
.section-title button {
flex-shrink: 0;
}
.section-title h2,
.split-row h2 {
font-size: 18px;
@@ -555,6 +600,100 @@ form {
gap: 14px;
}
.accordion-section {
border: 1px solid #dbe3e6;
border-radius: 8px;
overflow: hidden;
}
.accordion-section.active {
border-color: #b9d9d4;
}
.accordion-header {
align-items: center;
background: #f6faf9;
border: 0;
border-radius: 0;
color: #26343b;
display: flex;
justify-content: space-between;
min-height: 44px;
padding: 10px 14px;
text-align: left;
width: 100%;
}
.accordion-header:hover,
.accordion-section.active .accordion-header {
background: #def4f0;
color: #10545c;
filter: none;
}
.accordion-header span {
font-size: 16px;
font-weight: 800;
}
.accordion-header strong {
color: #65757b;
font-size: 12px;
}
.accordion-body {
padding: 16px;
}
.accordion-body.sub-panel {
border-top: 1px solid #dbe3e6;
margin-top: 0;
}
.document-form {
min-height: 100%;
padding-bottom: 84px;
position: relative;
}
.totals-panel {
align-items: center;
background: #eef8f5;
border: 1px solid #c8e1dc;
border-radius: 8px;
bottom: 0;
box-shadow: 0 -10px 24px rgb(28 43 48 / 8%);
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(150px, 1fr));
margin-top: 6px;
padding: 12px 14px;
position: sticky;
z-index: 30;
}
.totals-panel div {
display: grid;
gap: 3px;
}
.totals-panel span {
color: #65757b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.totals-panel strong {
color: #172026;
font-size: 18px;
}
.totals-panel .grand-total strong {
color: #0b6f68;
font-size: 21px;
}
.form-grid {
display: grid;
gap: 14px;
@@ -596,6 +735,15 @@ textarea {
padding: 8px 10px;
}
.readonly-notice {
background: #fff7e6;
border: 1px solid #efd49a;
border-radius: 8px;
color: #6b4a08;
font-weight: 750;
padding: 10px 12px;
}
.list-search {
margin-bottom: 10px;
}

View File

@@ -107,6 +107,8 @@ export type Activity = {
title: string;
body: string;
status: string;
price_category: string;
default_sales_price?: string | null;
priority: string;
due_at?: string | null;
};

View File

@@ -3,25 +3,33 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { formatDecimal, formatDecimalInput, normalizeDecimal } from "../format";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Activity } from "../types";
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "task", title: "", body: "", status: "active", priority: "normal", due_at: null as string | null });
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "work_step", title: "", body: "", status: "active", price_category: "h", default_sales_price: null as string | null, priority: "normal", due_at: null as string | null });
const records = ref<Activity[]>([]); const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
const filteredRecords = computed(() =>
records.value.filter((record) => matchesObjectSearch(record.activity_number, record.title, search.value))
);
async function load() { const r = await apiGet<Activity[]>("/api/v1/activities"); if (r.ok) records.value = r.data; else { status.value = r.message; kind.value = "error"; } }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.activity_number = await reserveNextNumber("activities"); }
function select(record: Activity) { selectedId.value = record.id; Object.assign(form, record); }
async function save() { const r = selectedId.value ? await apiPut<Activity>(`/api/v1/activities/${selectedId.value}`, form) : await apiPost<Activity>("/api/v1/activities", form); status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await load(); } }
async function 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(); } }
function select(record: Activity) { selectedId.value = record.id; Object.assign(form, { ...record, default_sales_price: formatDecimal(record.default_sales_price) }); }
async function save() {
const payload = { ...form, default_sales_price: normalizeDecimal(form.default_sales_price) };
const r = selectedId.value ? await apiPut<Activity>(`/api/v1/activities/${selectedId.value}`, payload) : await apiPost<Activity>("/api/v1/activities", payload);
status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error";
if (r.ok) { selectedId.value = r.data.id; Object.assign(form, { ...r.data, default_sales_price: formatDecimal(r.data.default_sales_price) }); await load(); }
return r.ok;
}
async function deactivate() { if (!selectedId.value) return; form.status = "inactive"; if (await save()) status.value = "Aktivität deaktiviert."; }
async function remove() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität gelöscht." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = null; Object.assign(form, emptyForm()); await load(); } }
onMounted(load); watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Aktivitäten" description="Aufgaben, Wiedervorlagen und Gesprächsnotizen." />
<div class="workspace-split">
<section class="panel list-panel"><div class="section-title"><h2>Aktivitäten</h2><button type="button" @click="createNew">Neu</button></div>
<section class="panel list-panel"><div class="section-title"><h2>Aktivitäten</h2><button type="button" class="add-button" title="Neue Aktivität" @click="createNew">+</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Aktivitätsnummer oder Titel" /></label>
<button v-for="record in filteredRecords" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)"><strong>{{ record.activity_number ?? record.activity_type }}</strong><span>{{ record.title }}</span><small>{{ record.status }}</small></button>
<p v-if="records.length === 0" class="empty">Keine Aktivitäten vorhanden.</p>
@@ -29,11 +37,12 @@ onMounted(load); watch(() => liveUpdateState.revision, load);
</section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
<label class="field"><span>Aktivitätsnummer</span><div class="readonly-value">{{ form.activity_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Typ</span><select v-model="form.activity_type"><option value="task">Aufgabe</option><option value="follow_up">Wiedervorlage</option><option value="phone_note">Telefonnotiz</option><option value="internal_note">Interne Notiz</option></select></label>
<label class="field"><span>Titel</span><input v-model="form.title" required /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Nicht aktiv</option></select></label>
<label class="field"><span>Preiskategorie</span><select v-model="form.price_category"><option value="h">h</option><option value="tag">Tag</option><option value="pauschal">Pauschal</option></select></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>Priorität</span><select v-model="form.priority"><option value="low">Niedrig</option><option value="normal">Normal</option><option value="high">Hoch</option><option value="critical">Kritisch</option></select></label>
<label class="field full-width"><span>Beschreibung</span><textarea v-model="form.body" rows="5" /></label>
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</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><button v-if="selectedId" type="button" class="secondary" @click="remove">Löschen</button></div><FormStatus :message="status" :kind="kind" /></form></section>
</div>
</template>

View File

@@ -26,6 +26,6 @@ onMounted(load);
</script>
<template>
<PageHeader title="Preis-APIs" description="Externe Preisquellen konfigurieren und manuell abgleichen." />
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Connectoren</h2><button type="button" @click="createNew">Neu</button></div><button v-for="connector in connectors" :key="connector.id" type="button" class="list-row" :class="{ selected: selectedId === connector.id }" @click="select(connector)"><strong>{{ connector.code }}</strong><span>{{ connector.name }}</span><small>{{ connector.last_sync_at ?? "kein Abgleich" }}</small></button></section>
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Connectoren</h2><button type="button" class="add-button" title="Neuer Connector" @click="createNew">+</button></div><button v-for="connector in connectors" :key="connector.id" type="button" class="list-row" :class="{ selected: selectedId === connector.id }" @click="select(connector)"><strong>{{ connector.code }}</strong><span>{{ connector.name }}</span><small>{{ connector.last_sync_at ?? "kein Abgleich" }}</small></button></section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"><label class="field"><span>Code</span><input v-model="form.code" required /></label><label class="field"><span>Name</span><input v-model="form.name" required /></label><label class="field"><span>Typ</span><input v-model="form.connector_type" required /></label><label class="field"><span>Intervall Minuten</span><input v-model="form.sync_interval_minutes" type="number" min="1" /></label><label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label><label class="field full-width"><span>Konfiguration JSON</span><textarea v-model="form.config" rows="8" /></label></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="sync">Abgleichen</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form></section></div>
</template>

View File

@@ -82,7 +82,7 @@ watch(() => liveUpdateState.revision, load);
<PageHeader title="Skonto-Regeln" description="Zahlungsbedingungen für Kunden, Lieferanten und Belege." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
<div class="section-title"><h2>Regeln</h2><button type="button" class="add-button" title="Neue Regel" @click="createNew">+</button></div>
<button v-for="term in terms" :key="term.id" type="button" class="list-row" :class="{ selected: selectedId === term.id }" @click="select(term)">
<strong>{{ term.code }}</strong>
<span>{{ term.name }}</span>

View File

@@ -75,7 +75,7 @@ watch(() => liveUpdateState.revision, load);
<PageHeader title="Kommunikation" description="E-Mails, Telefonate, Briefe, Besprechungen und interne Notizen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Verlauf</h2><button type="button" @click="createNew">Neu</button></div>
<div class="section-title"><h2>Verlauf</h2><button type="button" class="add-button" title="Neue Kommunikation" @click="createNew">+</button></div>
<button v-for="record in records" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)">
<strong>{{ record.subject }}</strong>
<span>{{ record.communication_type }} · {{ record.direction }}</span>
@@ -99,7 +99,7 @@ watch(() => liveUpdateState.revision, load);
<label class="field"><span>Typ</span><select v-model="linkType"><option value="customer">Kunde</option><option value="supplier">Lieferant</option><option value="activity">Aktivität</option><option value="quote">Angebot</option><option value="outgoing_invoice">Ausgangsrechnung</option><option value="incoming_invoice">Eingangsrechnung</option><option value="item">Artikel</option><option value="document">Dokument</option></select></label>
<label class="field"><span>ID</span><input v-model="linkId" /></label>
</div>
<button type="button" class="secondary" @click="addLink">Bezug hinzufügen</button>
<button type="button" class="secondary add-button" title="Bezug hinzufügen" @click="addLink">+</button>
<div v-for="(link, index) in form.links" :key="`${link.entity_type}-${link.entity_id}`" class="data-row">
<strong>{{ link.entity_type }}</strong><span>{{ link.entity_id }}</span><button type="button" class="secondary" @click="removeLink(index)">Entfernen</button>
</div>

View File

@@ -119,7 +119,7 @@ watch(
<section class="panel list-panel">
<div class="section-title">
<h2>Kundenliste</h2>
<button type="button" @click="newCustomer">Neu</button>
<button type="button" class="add-button" title="Neuer Kunde" @click="newCustomer">+</button>
</div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Kundennummer oder Name" /></label>
<button

View File

@@ -107,7 +107,7 @@ watch(() => liveUpdateState.revision, load);
<PageHeader title="Dokumente" description="Dokumente verschlüsselt ablegen, zuordnen und herunterladen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Dokumente</h2><button type="button" @click="createNew">Neu</button></div>
<div class="section-title"><h2>Dokumente</h2><button type="button" class="add-button" title="Neues Dokument" @click="createNew">+</button></div>
<button v-for="record in documents" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)">
<strong>{{ record.title }}</strong>
<span>{{ record.latest_version?.file_name ?? "ohne Datei" }}</span>
@@ -130,7 +130,7 @@ watch(() => liveUpdateState.revision, load);
<label class="field"><span>Typ</span><select v-model="linkType"><option value="customer">Kunde</option><option value="supplier">Lieferant</option><option value="activity">Aktivität</option><option value="communication">Kommunikation</option><option value="quote">Angebot</option><option value="outgoing_invoice">Ausgangsrechnung</option><option value="incoming_invoice">Eingangsrechnung</option><option value="item">Artikel</option></select></label>
<label class="field"><span>ID</span><input v-model="linkId" /></label>
</div>
<button type="button" class="secondary" @click="addLink">Bezug hinzufügen</button>
<button type="button" class="secondary add-button" title="Bezug hinzufügen" @click="addLink">+</button>
<div v-for="(link, index) in form.links" :key="`${link.entity_type}-${link.entity_id}`" class="data-row">
<strong>{{ link.entity_type }}</strong><span>{{ link.entity_id }}</span><button type="button" class="secondary" @click="removeLink(index)">Entfernen</button>
</div>

View File

@@ -14,6 +14,9 @@ const emptyForm = () => ({ invoice_number: "", supplier_id: "", status: "receive
const invoices = ref<IncomingInvoice[]>([]); const suppliers = ref<Supplier[]>([]); const items = ref<Item[]>([]);
const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
const editingLineIndex = ref(0);
const activeSection = ref<"party" | "positions">("party");
const readOnly = computed(() => ["paid", "cancelled"].includes(form.status));
const readOnlyText = computed(() => form.status === "paid" ? "Bezahlt" : form.status === "cancelled" ? "Storniert" : "");
const supplierName = (supplierId: string) => suppliers.value.find((supplier) => supplier.id === supplierId)?.name ?? supplierId;
const filteredInvoices = computed(() => {
const needle = search.value.trim().toLowerCase();
@@ -23,26 +26,86 @@ const filteredInvoices = computed(() => {
);
});
async function load() { const [ir, sr, itemr] = await Promise.all([apiGet<IncomingInvoice[]>("/api/v1/incoming-invoices"), apiGet<Supplier[]>("/api/v1/suppliers"), apiGet<Item[]>("/api/v1/items")]); if (ir.ok) invoices.value = ir.data; else { status.value = ir.message; kind.value = "error"; } if (sr.ok) suppliers.value = sr.data.filter((supplier) => supplier.status === "active"); if (itemr.ok) items.value = itemr.data.filter((item) => item.status === "active"); }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; form.invoice_number = (await reserveNextNumber("incoming_invoices")) ?? ""; }
function select(invoice: IncomingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item, tax_rate: normalizeTaxRate(item.tax_rate) })) }); editingLineIndex.value = 0; }
function addLine() { form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1); }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; activeSection.value = "party"; form.invoice_number = (await reserveNextNumber("incoming_invoices")) ?? ""; }
function select(invoice: IncomingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item, tax_rate: normalizeTaxRate(item.tax_rate) })) }); editingLineIndex.value = -1; activeSection.value = "party"; }
function addLine() { if (readOnly.value) return; activeSection.value = "positions"; const draftIndex = form.items.findIndex((line) => !shouldSendLine(line)); if (draftIndex >= 0) { editingLineIndex.value = draftIndex; return; } form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
function removeLine(index: number) { if (readOnly.value) return; form.items.splice(index, 1); if (form.items.length === 0) { form.items.push(emptyItem()); editingLineIndex.value = 0; return; } editingLineIndex.value = -1; }
function itemName(itemId: string | null | undefined) {
if (!itemId) return "Kein Artikel gewählt";
const item = items.value.find((record) => record.id === itemId);
return item ? `${item.item_number} - ${item.name}` : itemId;
}
function itemUnit(itemId: string | null | undefined) {
return items.value.find((record) => record.id === itemId)?.unit ?? "";
}
function quantityWithUnit(line: IncomingInvoiceItem) {
const unit = itemUnit(line.item_id);
return unit ? `${line.quantity} ${unit}` : line.quantity;
}
function optionalId(value: string | null | undefined) {
return value && value.trim() ? value : null;
}
function shouldSendLine(line: IncomingInvoiceItem) {
if (optionalId(line.item_id)) return true;
return Boolean(line.description.trim()) || decimalString(line.unit_price) !== "0";
}
function lineIdentity(line: IncomingInvoiceItem) {
return [
`item:${optionalId(line.item_id) ?? ""}`,
line.description.trim(),
decimalString(line.unit_price),
normalizeTaxRate(line.tax_rate)
].join("|");
}
function ensureUniqueLines() {
const seen = new Set<string>();
for (const line of form.items.filter(shouldSendLine)) {
const key = lineIdentity(line);
if (seen.has(key)) {
status.value = "Diese Position ist mit gleichen Bedingungen bereits vorhanden. Bitte die vorhandene Position bearbeiten.";
kind.value = "error";
activeSection.value = "positions";
return false;
}
seen.add(key);
}
return true;
}
function closeLineEditor() { if (ensureUniqueLines()) editingLineIndex.value = -1; }
function isNewLine(line: IncomingInvoiceItem) { return !line.id; }
function activateSection(section: "party" | "positions") { activeSection.value = section; }
function lineTotal(line: IncomingInvoiceItem) {
const quantity = Number(decimalString(line.quantity, "0"));
const unitPrice = Number(decimalString(line.unit_price, "0"));
const total = quantity * unitPrice;
const total = lineNetTotal(line);
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
}
function lineNetTotal(line: IncomingInvoiceItem) {
const quantity = Number(decimalString(line.quantity, "0"));
const unitPrice = Number(decimalString(line.unit_price, "0"));
return quantity * unitPrice;
}
function lineTaxTotal(line: IncomingInvoiceItem) {
const taxRate = Number(decimalString(line.tax_rate, "0"));
return lineNetTotal(line) * taxRate / 100;
}
const totals = computed(() => {
const sendableLines = form.items.filter(shouldSendLine);
const net = sendableLines.reduce((sum, line) => sum + lineNetTotal(line), 0);
const tax = sendableLines.reduce((sum, line) => sum + lineTaxTotal(line), 0);
return { net, tax, gross: net + tax };
});
function money(value: number) {
return value.toLocaleString("de-DE", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function save() {
if (readOnly.value) return;
if (!ensureUniqueLines()) return;
const payload = {
...form,
items: form.items.map((item) => ({
items: form.items
.filter(shouldSendLine)
.map((item) => ({
...item,
item_id: optionalId(item.item_id),
quantity: decimalString(item.quantity, "1"),
unit_price: decimalString(item.unit_price),
tax_rate: normalizeTaxRate(item.tax_rate)
@@ -51,13 +114,35 @@ async function save() {
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 (readOnly.value || !selectedId.value) return; const result = await apiDelete(`/api/v1/incoming-invoices/${selectedId.value}`); status.value = result.ok ? "Eingangsrechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
onMounted(load); watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Eingangsrechnungen" description="Lieferantenrechnungen mit Skonto-Bezug und Positionen." />
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Eingangsrechnungen</h2><button type="button" @click="createNew">Neu</button></div><label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Lieferant oder Status" /></label><button v-for="invoice in filteredInvoices" :key="invoice.id" type="button" class="list-row" :class="{ selected: selectedId === invoice.id }" @click="select(invoice)"><strong>{{ invoice.invoice_number }}</strong><span>{{ supplierName(invoice.supplier_id) }}</span><small>{{ invoice.status }}</small></button><p v-if="invoices.length === 0" class="empty">Keine Eingangsrechnungen vorhanden.</p><p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p></section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"><label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label><label class="field"><span>Lieferant</span><SearchSelect v-model="form.supplier_id" :options="suppliers.map((supplier) => ({ id: supplier.id, number: supplier.supplier_number, name: supplier.name }))" placeholder="Lieferanten-Nr. oder Name suchen" required /></label><label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="received">Erhalten</option><option value="approved">Freigegeben</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label><label class="field"><span>Datum</span><input v-model="form.invoice_date" type="date" /></label><label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label></div>
<div class="sub-panel"><div class="section-title"><h2>Positionen</h2></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="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Eingangsrechnungen</h2><button type="button" class="add-button" title="Neue Eingangsrechnung" @click="createNew">+</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 class="document-form" @submit.prevent="save">
<div v-if="readOnly" class="readonly-notice">Diese Eingangsrechnung ist schreibgeschützt: {{ readOnlyText }}.</div>
<div class="accordion-section" :class="{ active: activeSection === 'party' }">
<button type="button" class="accordion-header" @click="activateSection('party')"><span>Lieferant</span><strong>{{ activeSection === 'party' ? "▾" : "▸" }}</strong></button>
<div v-if="activeSection === 'party'" class="accordion-body 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><div v-if="readOnly" class="readonly-value">{{ supplierName(form.supplier_id) }}</div><SearchSelect v-else 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><div v-if="readOnly" class="readonly-value">{{ readOnlyText }}</div><select v-else v-model="form.status"><option value="draft">Entwurf</option><option value="received">Erhalten</option><option value="approved">Freigegeben</option><option value="overdue">Überfällig</option></select></label>
<label class="field"><span>Datum</span><input v-model="form.invoice_date" type="date" :disabled="readOnly" /></label>
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" :disabled="readOnly" /></label>
</div>
</div>
<div class="accordion-section" :class="{ active: activeSection === 'positions' }">
<button type="button" class="accordion-header" @click="activateSection('positions')"><span>Positionen</span><strong>{{ activeSection === 'positions' ? "▾" : "▸" }}</strong></button>
<div v-if="activeSection === 'positions'" class="accordion-body sub-panel">
<div class="section-title"><h2>Positionen</h2><button v-if="!readOnly" type="button" class="add-button" title="Neue Position" @click="addLine">+</button></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 && shouldSendLine(line)"><div class="position-summary compact"><strong>{{ itemName(line.item_id) }}</strong><span>{{ quantityWithUnit(line) }}</span><span>{{ line.unit_price }}</span><span>{{ lineTotal(line) }}</span><span>{{ line.tax_rate }} %</span></div><div class="position-row-actions"><button v-if="!readOnly" type="button" class="secondary icon-button" title="Bearbeiten" @click="editingLineIndex = index">✎</button><button v-if="!readOnly" type="button" class="secondary icon-button" title="Entfernen" @click="removeLine(index)">×</button></div></template><template v-else><label class="field full-width"><span>Artikel</span><div v-if="readOnly" class="readonly-value">{{ itemName(line.item_id) }}</div><SearchSelect v-else 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" :disabled="readOnly" /></label><label class="field"><span>Einheit</span><div class="readonly-value">{{ itemUnit(line.item_id) || "-" }}</div></label><label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" :disabled="readOnly" /></label><label class="field"><span>Steuer %</span><select v-model="line.tax_rate" :disabled="readOnly"><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" :disabled="readOnly" /></label><div v-if="!readOnly" class="position-actions"><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><button type="button" @click="closeLineEditor">{{ isNewLine(line) ? "+" : "Änderung übernehmen" }}</button></div></template></div>
<div v-if="!readOnly" class="position-add-row"><button type="button" class="add-button" title="Neue Position" @click="addLine">+</button></div>
</div>
</div>
<div v-if="!readOnly" 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" /><div class="totals-panel"><div><span>Netto-Summe</span><strong>{{ money(totals.net) }}</strong></div><div><span>Steuer</span><strong>{{ money(totals.tax) }}</strong></div><div class="grand-total"><span>Gesamtsumme</span><strong>{{ money(totals.gross) }}</strong></div></div>
</form>
</section>
</div>
</template>

View File

@@ -49,8 +49,10 @@ async function save() {
const payload = apiItemPayload();
const r = selectedId.value ? await apiPut<Item>(`/api/v1/items/${selectedId.value}`, payload) : await apiPost<Item>("/api/v1/items", payload);
status.value = r.ok ? "Artikel gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; Object.assign(form, displayItem(r.data)); await Promise.all([load(), loadPriceHistory(r.data.id)]); }
return r.ok;
}
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; form.status = "inactive"; if (await save()) status.value = "Artikel deaktiviert."; }
async function remove() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/items/${selectedId.value}`); status.value = r.ok ? "Artikel gelöscht." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = null; Object.assign(form, emptyForm()); priceHistory.value = []; await load(); } }
function addSupplierPrice() { form.supplier_prices.push(emptySupplierPrice()); }
function removeSupplierPrice(index: number) { form.supplier_prices.splice(index, 1); }
function displayItem(item: Item) {
@@ -92,7 +94,7 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
<PageHeader title="Artikel" description="Artikelstamm und aktuelle Standardpreise." />
<div class="workspace-split">
<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" class="add-button" title="Neuer Artikel" @click="createNew">+</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Artikelnummer oder Bezeichnung" /></label>
<label class="field list-search"><span>Sortierung</span><select v-model="sortMode"><option value="name_desc">Artikelname absteigend</option><option value="number_desc">Artikelnummer absteigend</option></select></label>
<button v-for="item in filteredItems" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)"><strong>{{ item.item_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small></button>
@@ -109,10 +111,10 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
<label class="field"><span>Steuersatz %</span><select v-model="form.tax_rate" required><option v-for="rate in taxRateOptions" :key="rate" :value="rate">{{ rate }} %</option></select></label>
<label class="field"><span>Einkaufspreis</span><input v-model="form.default_purchase_price" inputmode="decimal" @blur="formatDecimalInput(form, 'default_purchase_price')" /></label>
<label class="field"><span>Verkaufspreis</span><input v-model="form.default_sales_price" inputmode="decimal" @blur="formatDecimalInput(form, 'default_sales_price')" /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Nicht aktiv</option><option value="blocked">Gesperrt</option></select></label>
</div>
<div class="sub-panel">
<div class="section-title"><h2>Lieferantenpreise</h2><button type="button" @click="addSupplierPrice">Lieferant</button></div>
<div class="section-title"><h2>Lieferantenpreise</h2><button type="button" class="add-button" title="Lieferantenpreis hinzufügen" @click="addSupplierPrice">+</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>
@@ -123,7 +125,7 @@ onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(
</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 class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button><button v-if="selectedId" type="button" class="secondary" @click="remove">Löschen</button></div><FormStatus :message="status" :kind="kind" /></form>
<div v-if="selectedId" class="sub-panel">
<h2>Preishistorie</h2>
<div v-for="entry in priceHistory" :key="entry.id" class="data-row">

View File

@@ -4,7 +4,7 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
import { decimalString, formatDecimal, formatDecimalInput, normalizeTaxRate, taxRateOptions } from "../format";
import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Activity, Customer, Item, OutgoingInvoice, OutgoingInvoiceItem, Quote } from "../types";
@@ -22,6 +22,9 @@ const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
const search = ref("");
const editingLineIndex = ref(0);
const activeSection = ref<"party" | "positions">("party");
const readOnly = computed(() => ["finalized", "paid", "cancelled"].includes(form.status));
const readOnlyText = computed(() => form.status === "finalized" ? "Abgeschlossen" : form.status === "paid" ? "Bezahlt" : form.status === "cancelled" ? "Storniert" : "");
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
const filteredInvoices = computed(() => {
const needle = search.value.trim().toLowerCase();
@@ -38,17 +41,35 @@ async function load() {
if (invoiceResult.ok) invoices.value = invoiceResult.data; else { status.value = invoiceResult.message; kind.value = "error"; }
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
if (quoteResult.ok) quotes.value = quoteResult.data;
if (activityResult.ok) activities.value = activityResult.data.filter((activity) => activity.status === "active");
if (quoteResult.ok) quotes.value = quoteResult.data.filter((quote) => quote.status === "accepted");
if (activityResult.ok) {
activities.value = activityResult.data.filter((activity) => activity.status === "active");
refreshActivityLineDefaults();
}
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; form.invoice_number = (await reserveNextNumber("outgoing_invoices")) ?? ""; }
function select(invoice: OutgoingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item, line_kind: item.line_kind ?? "item", tax_rate: normalizeTaxRate(item.tax_rate) })) }); editingLineIndex.value = 0; }
function addLine() { form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1); }
}
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); editingLineIndex.value = 0; activeSection.value = "party"; form.invoice_number = (await reserveNextNumber("outgoing_invoices")) ?? ""; }
function select(invoice: OutgoingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item, line_kind: item.line_kind ?? "item", tax_rate: normalizeTaxRate(item.tax_rate) })) }); editingLineIndex.value = -1; activeSection.value = "party"; }
function addLine() { if (readOnly.value) return; activeSection.value = "positions"; const draftIndex = form.items.findIndex((line) => !shouldSendLine(line)); if (draftIndex >= 0) { editingLineIndex.value = draftIndex; return; } form.items.push(emptyItem()); editingLineIndex.value = form.items.length - 1; }
function removeLine(index: number) { if (readOnly.value) return; form.items.splice(index, 1); if (form.items.length === 0) { form.items.push(emptyItem()); editingLineIndex.value = 0; return; } editingLineIndex.value = -1; }
function applyItemDefaults(line: OutgoingInvoiceItem) {
const item = items.value.find((record) => record.id === line.item_id);
if (!item) return;
line.description = item.name; line.unit_price = item.default_sales_price ?? "0"; line.original_unit_price = item.default_sales_price ?? null; line.tax_rate = normalizeTaxRate(item.tax_rate);
line.description = item.name; applyLinePrice(line, item.default_sales_price); line.tax_rate = normalizeTaxRate(item.tax_rate);
}
function applyLinePrice(line: OutgoingInvoiceItem, price: string | number | null | undefined) {
const normalized = decimalString(price);
line.unit_price = formatDecimal(normalized);
line.original_unit_price = normalized;
}
function refreshActivityLineDefaults() {
for (const line of form.items) {
if (line.line_kind !== "activity" || !line.activity_id) continue;
const activity = activities.value.find((record) => record.id === line.activity_id);
if (!activity?.default_sales_price) continue;
const current = decimalString(line.unit_price);
const original = line.original_unit_price === null ? "0" : decimalString(line.original_unit_price);
if (current === original || current === "0") applyLinePrice(line, activity.default_sales_price);
}
}
const lineSourceOptions = computed(() => [
...items.value.map((item) => ({ id: `item:${item.id}`, number: item.item_number, name: item.name })),
@@ -67,8 +88,7 @@ function selectLineSource(line: OutgoingInvoiceItem, value: string | null) {
line.activity_id = id;
line.item_id = null;
line.description = activity.title;
line.unit_price = "0";
line.original_unit_price = null;
applyLinePrice(line, activity.default_sales_price);
line.tax_rate = "19";
return;
}
@@ -95,22 +115,90 @@ function lineName(line: OutgoingInvoiceItem) {
const item = items.value.find((record) => record.id === line.item_id);
return item ? `${item.item_number} - ${item.name}` : line.item_id;
}
function lineUnit(line: OutgoingInvoiceItem) {
if (line.line_kind === "activity") {
const category = activities.value.find((record) => record.id === line.activity_id)?.price_category;
if (category === "tag") return "Tag";
if (category === "pauschal") return "Pauschal";
return "h";
}
return items.value.find((record) => record.id === line.item_id)?.unit ?? "";
}
function quantityWithUnit(line: OutgoingInvoiceItem) {
const unit = lineUnit(line);
return unit ? `${line.quantity} ${unit}` : line.quantity;
}
function optionalId(value: string | null | undefined) {
return value && value.trim() ? value : null;
}
function shouldSendLine(line: OutgoingInvoiceItem) {
const hasSource = line.line_kind === "activity" ? optionalId(line.activity_id) !== null : optionalId(line.item_id) !== null;
if (hasSource) return true;
return Boolean(line.description.trim()) || decimalString(line.unit_price) !== "0";
}
function lineIdentity(line: OutgoingInvoiceItem) {
const source = line.line_kind === "activity" ? `activity:${optionalId(line.activity_id) ?? ""}` : `item:${optionalId(line.item_id) ?? ""}`;
return [
source,
line.description.trim(),
decimalString(line.unit_price),
decimalString(line.discount_percent),
normalizeTaxRate(line.tax_rate)
].join("|");
}
function ensureUniqueLines() {
const seen = new Set<string>();
for (const line of form.items.filter(shouldSendLine)) {
const key = lineIdentity(line);
if (seen.has(key)) {
status.value = "Diese Position ist mit gleichen Bedingungen bereits vorhanden. Bitte die vorhandene Position bearbeiten.";
kind.value = "error";
activeSection.value = "positions";
return false;
}
seen.add(key);
}
return true;
}
function closeLineEditor() { if (ensureUniqueLines()) editingLineIndex.value = -1; }
function isNewLine(line: OutgoingInvoiceItem) { return !line.id; }
function activateSection(section: "party" | "positions") { activeSection.value = section; }
function lineTotal(line: OutgoingInvoiceItem) {
const total = lineNetTotal(line);
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
}
function lineNetTotal(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 }) : "-";
return quantity * unitPrice * (1 - discount / 100);
}
function lineTaxTotal(line: OutgoingInvoiceItem) {
const taxRate = Number(decimalString(line.tax_rate, "0"));
return lineNetTotal(line) * taxRate / 100;
}
const totals = computed(() => {
const sendableLines = form.items.filter(shouldSendLine);
const net = sendableLines.reduce((sum, line) => sum + lineNetTotal(line), 0);
const tax = sendableLines.reduce((sum, line) => sum + lineTaxTotal(line), 0);
return { net, tax, gross: net + tax };
});
function money(value: number) {
return value.toLocaleString("de-DE", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function save() {
if (readOnly.value) return;
if (!ensureUniqueLines()) return;
const payload = {
...form,
customer_discount_percent: decimalString(form.customer_discount_percent),
items: form.items.map((item) => ({
items: form.items
.filter(shouldSendLine)
.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,
item_id: item.line_kind === "activity" ? null : optionalId(item.item_id),
activity_id: item.line_kind === "activity" ? optionalId(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),
@@ -121,46 +209,71 @@ async function save() {
const result = selectedId.value ? await apiPut<OutgoingInvoice>(`/api/v1/outgoing-invoices/${selectedId.value}`, payload) : await apiPost<OutgoingInvoice>("/api/v1/outgoing-invoices", payload);
status.value = result.ok ? "Rechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function finalize() { if (!selectedId.value) return; const result = await apiPost(`/api/v1/outgoing-invoices/${selectedId.value}/finalize`, {}); status.value = result.ok ? "Rechnung abgeschlossen." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
async function cancelInvoice() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/outgoing-invoices/${selectedId.value}`); status.value = result.ok ? "Rechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
async function convertQuote(quoteId: string) { const result = await apiPost<OutgoingInvoice>(`/api/v1/quotes/${quoteId}/convert-to-invoice`, {}); status.value = result.ok ? "Angebot umgewandelt." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); } }
async function finalize() { if (readOnly.value || !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 cancelInvoice() { if (readOnly.value || !selectedId.value) return; const result = await apiDelete(`/api/v1/outgoing-invoices/${selectedId.value}`); status.value = result.ok ? "Rechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
async function convertQuote(quoteId: string) {
const result = await apiPost<OutgoingInvoice>(`/api/v1/quotes/${quoteId}/convert-to-invoice`, {});
status.value = result.ok ? "Angebot umgewandelt." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) {
select(result.data);
await load();
select(result.data);
}
}
onMounted(load); watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Ausgangsrechnungen" description="Rechnungen erstellen, aus Angeboten übernehmen und abschließen." />
<div class="workspace-split">
<section class="panel list-panel"><div class="section-title"><h2>Rechnungen</h2><button type="button" @click="createNew">Neu</button></div>
<section class="panel list-panel"><div class="section-title"><h2>Rechnungen</h2><button type="button" class="add-button" title="Neue Rechnung" @click="createNew">+</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Kunde 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>{{ customerName(invoice.customer_id) }}</span><small>{{ invoice.status }}</small></button>
<p v-if="invoices.length === 0" class="empty">Keine Rechnungen vorhanden.</p>
<p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p>
<div class="sub-panel"><h2>Aus Angebot</h2><button v-for="quote in quotes" :key="quote.id" type="button" class="secondary" @click="convertQuote(quote.id)">{{ quote.quote_number }}</button></div>
</section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
<section class="panel detail-panel">
<form class="document-form" @submit.prevent="save">
<div v-if="readOnly" class="readonly-notice">Diese Rechnung ist schreibgeschützt: {{ readOnlyText }}.</div>
<div class="accordion-section" :class="{ active: activeSection === 'party' }">
<button type="button" class="accordion-header" @click="activateSection('party')"><span>Kunde</span><strong>{{ activeSection === 'party' ? "▾" : "▸" }}</strong></button>
<div v-if="activeSection === 'party'" class="accordion-body form-grid">
<label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required @change="applyCustomerDefaults" /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="finalized">Abgeschlossen</option><option value="sent">Gesendet</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label>
<label class="field"><span>Ausgestellt</span><input v-model="form.issued_at" type="date" /></label>
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" /></label>
</div><div class="sub-panel"><div class="section-title"><h2>Positionen</h2></div><div class="position-table-header"><span>Position</span><span>Menge</span><span>Einzelpreis</span><span>Gesamtpreis</span><span>Steuer</span><span>Aktion</span></div>
<label class="field"><span>Kunde</span><div v-if="readOnly" class="readonly-value">{{ customerName(form.customer_id) }}</div><SearchSelect v-else 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><div v-if="readOnly" class="readonly-value">{{ readOnlyText }}</div><select v-else v-model="form.status"><option value="draft">Entwurf</option><option value="sent">Gesendet</option><option value="overdue">Überfällig</option></select></label>
<label class="field"><span>Ausgestellt</span><input v-model="form.issued_at" type="date" :disabled="readOnly" /></label>
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" :disabled="readOnly" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" :disabled="readOnly" /></label>
</div>
</div>
<div class="accordion-section" :class="{ active: activeSection === 'positions' }">
<button type="button" class="accordion-header" @click="activateSection('positions')"><span>Positionen</span><strong>{{ activeSection === 'positions' ? "▾" : "▸" }}</strong></button>
<div v-if="activeSection === 'positions'" class="accordion-body sub-panel">
<div class="section-title"><h2>Positionen</h2><button v-if="!readOnly" type="button" class="add-button" title="Neue Position" @click="addLine">+</button></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">
<template v-if="editingLineIndex !== index">
<template v-if="editingLineIndex !== index && shouldSendLine(line)">
<div class="position-summary">
<strong>{{ lineName(line) }}</strong>
<span>{{ line.quantity }}</span>
<span>{{ quantityWithUnit(line) }}</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>
<div class="position-row-actions"><button v-if="!readOnly" type="button" class="secondary icon-button" title="Bearbeiten" @click="editingLineIndex = index">✎</button><button v-if="!readOnly" type="button" class="secondary icon-button" title="Entfernen" @click="removeLine(index)">×</button></div>
</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>
<label class="field full-width"><span>Artikel oder Aktivität</span><div v-if="readOnly" class="readonly-value">{{ lineName(line) }}</div><SearchSelect v-else :model-value="lineSourceValue(line)" :options="lineSourceOptions" placeholder="Artikel-/Aktivitäts-Nr. oder Name suchen" required @update:model-value="selectLineSource(line, $event)" /></label>
<label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" :disabled="readOnly" @blur="formatDecimalInput(line, 'quantity')" /></label><label class="field"><span>Einheit</span><div class="readonly-value">{{ lineUnit(line) || "-" }}</div></label><label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" :disabled="readOnly" @blur="formatDecimalInput(line, 'unit_price')" /></label><label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" inputmode="decimal" :disabled="readOnly" @blur="formatDecimalInput(line, 'discount_percent')" /></label><label class="field"><span>Steuer %</span><select v-model="line.tax_rate" :disabled="readOnly"><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" :disabled="readOnly" /></label>
<div v-if="!readOnly" class="position-actions"><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><button type="button" @click="closeLineEditor">{{ isNewLine(line) ? "+" : "Änderung übernehmen" }}</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 v-if="!readOnly" class="position-add-row"><button type="button" class="add-button" title="Neue Position" @click="addLine">+</button></div>
</div>
</div>
<div v-if="!readOnly" 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" /><div class="totals-panel"><div><span>Netto-Summe</span><strong>{{ money(totals.net) }}</strong></div><div><span>Steuer</span><strong>{{ money(totals.tax) }}</strong></div><div class="grand-total"><span>Gesamtsumme</span><strong>{{ money(totals.gross) }}</strong></div></div>
</form>
</section>
</div>
</template>

View File

@@ -76,7 +76,7 @@ onMounted(load);
<PageHeader title="Preisregeln" description="Aufschläge und Rundung je Preisquelle festlegen." />
<div class="workspace-split">
<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" class="add-button" title="Neue Regel" @click="createNew">+</button></div>
<button v-for="rule in rules" :key="rule.id" type="button" class="list-row" :class="{ selected: selectedId === rule.id }" @click="select(rule)">
<strong>{{ rule.code }}</strong>
<span>{{ rule.name }}</span>

View File

@@ -4,7 +4,7 @@ import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { decimalString, normalizeTaxRate, taxRateOptions } from "../format";
import { decimalString, formatDecimal, formatDecimalInput, normalizeTaxRate, taxRateOptions } from "../format";
import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Activity, Customer, Item, Quote, QuoteItem } from "../types";
@@ -23,6 +23,9 @@ const kind = ref<"info" | "success" | "error">("info");
const selectedQuote = computed(() => quotes.value.find((quote) => quote.id === selectedId.value));
const search = ref("");
const editingLineIndex = ref(0);
const activeSection = ref<"party" | "positions">("party");
const readOnly = computed(() => ["invoice_created", "cancelled"].includes(form.status));
const readOnlyText = computed(() => form.status === "invoice_created" ? "Rechnung erstellt" : form.status === "cancelled" ? "Storniert" : "");
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
const filteredQuotes = computed(() => {
const needle = search.value.trim().toLowerCase();
@@ -42,41 +45,75 @@ async function load() {
if (quoteResult.ok) quotes.value = quoteResult.data; else { status.value = quoteResult.message; kind.value = "error"; }
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
if (activityResult.ok) activities.value = activityResult.data.filter((activity) => activity.status === "active");
if (activityResult.ok) {
activities.value = activityResult.data.filter((activity) => activity.status === "active");
refreshActivityLineDefaults();
}
}
async function createNew() {
selectedId.value = null;
Object.assign(form, emptyForm());
editingLineIndex.value = 0;
activeSection.value = "party";
form.quote_number = (await reserveNextNumber("quotes")) ?? "";
}
function select(quote: Quote) {
selectedId.value = quote.id;
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;
editingLineIndex.value = -1;
activeSection.value = "party";
}
function addLine() {
if (readOnly.value) return;
activeSection.value = "positions";
const draftIndex = form.items.findIndex((line) => !shouldSendLine(line));
if (draftIndex >= 0) {
editingLineIndex.value = draftIndex;
return;
}
form.items.push(emptyItem());
editingLineIndex.value = form.items.length - 1;
}
function removeLine(index: number) {
if (form.items.length > 1) form.items.splice(index, 1);
editingLineIndex.value = Math.min(editingLineIndex.value, form.items.length - 1);
if (readOnly.value) return;
form.items.splice(index, 1);
if (form.items.length === 0) {
form.items.push(emptyItem());
editingLineIndex.value = 0;
return;
}
editingLineIndex.value = -1;
}
function applyItemDefaults(line: QuoteItem) {
const item = items.value.find((record) => record.id === line.item_id);
if (!item) return;
line.description = item.name;
line.unit_price = item.default_sales_price ?? "0";
line.original_unit_price = item.default_sales_price ?? null;
applyLinePrice(line, item.default_sales_price);
line.tax_rate = normalizeTaxRate(item.tax_rate);
}
function applyLinePrice(line: QuoteItem, price: string | number | null | undefined) {
const normalized = decimalString(price);
line.unit_price = formatDecimal(normalized);
line.original_unit_price = normalized;
}
function refreshActivityLineDefaults() {
for (const line of form.items) {
if (line.line_kind !== "activity" || !line.activity_id) continue;
const activity = activities.value.find((record) => record.id === line.activity_id);
if (!activity?.default_sales_price) continue;
const current = decimalString(line.unit_price);
const original = line.original_unit_price === null ? "0" : decimalString(line.original_unit_price);
if (current === original || current === "0") applyLinePrice(line, activity.default_sales_price);
}
}
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 }))
@@ -96,8 +133,7 @@ function selectLineSource(line: QuoteItem, value: string | null) {
line.activity_id = id;
line.item_id = null;
line.description = activity.title;
line.unit_price = "0";
line.original_unit_price = null;
applyLinePrice(line, activity.default_sales_price);
line.tax_rate = "19";
return;
}
@@ -127,23 +163,111 @@ function lineName(line: QuoteItem) {
return item ? `${item.item_number} - ${item.name}` : line.item_id;
}
function lineUnit(line: QuoteItem) {
if (line.line_kind === "activity") {
const category = activities.value.find((record) => record.id === line.activity_id)?.price_category;
if (category === "tag") return "Tag";
if (category === "pauschal") return "Pauschal";
return "h";
}
return items.value.find((record) => record.id === line.item_id)?.unit ?? "";
}
function quantityWithUnit(line: QuoteItem) {
const unit = lineUnit(line);
return unit ? `${line.quantity} ${unit}` : line.quantity;
}
function optionalId(value: string | null | undefined) {
return value && value.trim() ? value : null;
}
function shouldSendLine(line: QuoteItem) {
const hasSource = line.line_kind === "activity" ? optionalId(line.activity_id) !== null : optionalId(line.item_id) !== null;
if (hasSource) return true;
return Boolean(line.description.trim()) || decimalString(line.unit_price) !== "0";
}
function lineIdentity(line: QuoteItem) {
const source = line.line_kind === "activity" ? `activity:${optionalId(line.activity_id) ?? ""}` : `item:${optionalId(line.item_id) ?? ""}`;
return [
source,
line.description.trim(),
decimalString(line.unit_price),
decimalString(line.discount_percent),
normalizeTaxRate(line.tax_rate)
].join("|");
}
function ensureUniqueLines() {
const seen = new Set<string>();
for (const line of form.items.filter(shouldSendLine)) {
const key = lineIdentity(line);
if (seen.has(key)) {
status.value = "Diese Position ist mit gleichen Bedingungen bereits vorhanden. Bitte die vorhandene Position bearbeiten.";
kind.value = "error";
activeSection.value = "positions";
return false;
}
seen.add(key);
}
return true;
}
function closeLineEditor() {
if (!ensureUniqueLines()) return;
editingLineIndex.value = -1;
}
function isNewLine(line: QuoteItem) {
return !line.id;
}
function activateSection(section: "party" | "positions") {
activeSection.value = section;
}
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);
const total = lineNetTotal(line);
return Number.isFinite(total) ? total.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) : "-";
}
function lineNetTotal(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"));
return quantity * unitPrice * (1 - discount / 100);
}
function lineTaxTotal(line: QuoteItem) {
const taxRate = Number(decimalString(line.tax_rate, "0"));
return lineNetTotal(line) * taxRate / 100;
}
const totals = computed(() => {
const sendableLines = form.items.filter(shouldSendLine);
const net = sendableLines.reduce((sum, line) => sum + lineNetTotal(line), 0);
const tax = sendableLines.reduce((sum, line) => sum + lineTaxTotal(line), 0);
return { net, tax, gross: net + tax };
});
function money(value: number) {
return value.toLocaleString("de-DE", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function save() {
if (readOnly.value) return;
if (!ensureUniqueLines()) return;
const payload = {
...form,
customer_discount_percent: decimalString(form.customer_discount_percent),
items: form.items.map((item) => ({
items: form.items
.filter(shouldSendLine)
.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,
item_id: item.line_kind === "activity" ? null : optionalId(item.item_id),
activity_id: item.line_kind === "activity" ? optionalId(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),
@@ -160,6 +284,7 @@ async function save() {
}
async function cancelQuote() {
if (readOnly.value) return;
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/quotes/${selectedId.value}`);
status.value = result.ok ? "Angebot storniert." : result.message;
@@ -175,7 +300,7 @@ watch(() => liveUpdateState.revision, load);
<PageHeader title="Angebote" description="Angebote mit festen Artikelpositionen und individuellen Preisen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Angebote</h2><button type="button" @click="createNew">Neu</button></div>
<div class="section-title"><h2>Angebote</h2><button type="button" class="add-button" title="Neues Angebot" @click="createNew">+</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Angebotsnummer, Kunde oder Status" /></label>
<button v-for="quote in filteredQuotes" :key="quote.id" type="button" class="list-row" :class="{ selected: selectedId === quote.id }" @click="select(quote)">
<strong>{{ quote.quote_number }}</strong>
@@ -186,45 +311,62 @@ watch(() => liveUpdateState.revision, load);
<p v-else-if="filteredQuotes.length === 0" class="empty">Keine Treffer.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save">
<div class="form-grid">
<form class="document-form" @submit.prevent="save">
<div v-if="readOnly" class="readonly-notice">Dieses Angebot ist schreibgeschützt: {{ readOnlyText }}.</div>
<div class="accordion-section" :class="{ active: activeSection === 'party' }">
<button type="button" class="accordion-header" @click="activateSection('party')"><span>Kunde</span><strong>{{ activeSection === 'party' ? "▾" : "▸" }}</strong></button>
<div v-if="activeSection === 'party'" class="accordion-body form-grid">
<label class="field"><span>Angebotsnummer</span><div class="readonly-value">{{ form.quote_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required @change="applyCustomerDefaults" /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="sent">Gesendet</option><option value="accepted">Angenommen</option><option value="rejected">Abgelehnt</option><option value="expired">Abgelaufen</option><option value="cancelled">Storniert</option></select></label>
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" /></label>
<label class="field full-width"><span>Notizen</span><textarea v-model="form.notes" rows="3" /></label>
<label class="field"><span>Kunde</span><div v-if="readOnly" class="readonly-value">{{ customerName(form.customer_id) }}</div><SearchSelect v-else 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><div v-if="readOnly" class="readonly-value">{{ readOnlyText }}</div><select v-else 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></select></label>
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" :disabled="readOnly" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" inputmode="decimal" :disabled="readOnly" /></label>
<label class="field full-width"><span>Notizen</span><textarea v-model="form.notes" rows="3" :disabled="readOnly" /></label>
</div>
<div class="sub-panel">
<div class="section-title"><h2>Positionen</h2></div>
</div>
<div class="accordion-section" :class="{ active: activeSection === 'positions' }">
<button type="button" class="accordion-header" @click="activateSection('positions')"><span>Positionen</span><strong>{{ activeSection === 'positions' ? "▾" : "▸" }}</strong></button>
<div v-if="activeSection === 'positions'" class="accordion-body sub-panel">
<div class="section-title"><h2>Positionen</h2><button v-if="!readOnly" type="button" class="add-button" title="Neue Position" @click="addLine">+</button></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">
<template v-if="editingLineIndex !== index">
<template v-if="editingLineIndex !== index && shouldSendLine(line)">
<div class="position-summary">
<strong>{{ lineName(line) }}</strong>
<span>{{ line.quantity }}</span>
<span>{{ quantityWithUnit(line) }}</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>
<div class="position-row-actions">
<button v-if="!readOnly" type="button" class="secondary icon-button" title="Bearbeiten" @click="editingLineIndex = index"></button>
<button v-if="!readOnly" type="button" class="secondary icon-button" title="Entfernen" @click="removeLine(index)">×</button>
</div>
</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">
<label class="field full-width"><span>Artikel oder Aktivität</span><div v-if="readOnly" class="readonly-value">{{ lineName(line) }}</div><SearchSelect v-else :model-value="lineSourceValue(line)" :options="lineSourceOptions" placeholder="Artikel-/Aktivitäts-Nr. oder Name suchen" required @update:model-value="selectLineSource(line, $event)" /></label>
<label class="field"><span>Menge</span><input v-model="line.quantity" inputmode="decimal" required :disabled="readOnly" @blur="formatDecimalInput(line, 'quantity')" /></label>
<label class="field"><span>Einheit</span><div class="readonly-value">{{ lineUnit(line) || "-" }}</div></label>
<label class="field"><span>Preis</span><input v-model="line.unit_price" inputmode="decimal" required :disabled="readOnly" @blur="formatDecimalInput(line, 'unit_price')" /></label>
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" inputmode="decimal" :disabled="readOnly" @blur="formatDecimalInput(line, 'discount_percent')" /></label>
<label class="field"><span>Steuer %</span><select v-model="line.tax_rate" :disabled="readOnly"><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" :disabled="readOnly" /></label>
<div v-if="!readOnly" class="position-actions">
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
<button type="button" @click="addLine">Position hinzufügen</button>
<button type="button" @click="closeLineEditor">{{ isNewLine(line) ? "+" : "Änderung übernehmen" }}</button>
</div>
</template>
</div>
<div v-if="!readOnly" class="position-add-row"><button type="button" class="add-button" title="Neue Position" @click="addLine">+</button></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>
<div v-if="!readOnly" class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div>
<FormStatus :message="status" :kind="kind" />
<div class="totals-panel">
<div><span>Netto-Summe</span><strong>{{ money(totals.net) }}</strong></div>
<div><span>Steuer</span><strong>{{ money(totals.tax) }}</strong></div>
<div class="grand-total"><span>Gesamtsumme</span><strong>{{ money(totals.gross) }}</strong></div>
</div>
</form>
</section>
</div>

View File

@@ -66,7 +66,7 @@ watch(() => liveUpdateState.revision, () => Promise.all([load(), loadCashDiscoun
<PageHeader title="Lieferanten" description="Lieferantenstamm und Zahlungskonditionen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Lieferanten</h2><button type="button" @click="createNew">Neu</button></div>
<div class="section-title"><h2>Lieferanten</h2><button type="button" class="add-button" title="Neuer Lieferant" @click="createNew">+</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Lieferantennummer oder Name" /></label>
<button v-for="item in filteredSuppliers" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)">
<strong>{{ item.supplier_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small>