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()