feat(quotes): invoice-style line items, costs, and extra goods (#585)

Add quote_items.line_kind (item | expense | good) plus display_name,
category, line_date, and sku so one table still drives totals, PDFs,
and acceptance stock logic.

- Migration 147: new columns with backfill line_kind=item
- Quote create/edit: three sections; stock and warehouse only when a
  line item is sourced from inventory; shared JS partial
- POST parsing, positions, and duplicate-quote copying for all fields
- API v1 quote items accept the new fields with defaults for old clients
- View, client portal, and PDF/fallback rendering use display_name and
  line metadata where relevant
- Integration tests: stock POST shape; expense and good line creation

Docs: extend INVENTORY_MANAGEMENT_PLAN QuoteItem and migration notes.
This commit is contained in:
Dries Peeters
2026-04-12 14:00:04 +02:00
parent c134e07e2a
commit 974f5cdd50
13 changed files with 1250 additions and 658 deletions
+48 -5
View File
@@ -425,7 +425,14 @@ class QuoteItem(db.Model):
# Optional fields
unit = db.Column(db.String(20), nullable=True) # 'hours', 'days', 'items', etc.
# Inventory integration
# Line classification (issue #585): item | expense | good
line_kind = db.Column(db.String(20), nullable=False, default="item")
display_name = db.Column(db.String(200), nullable=True)
category = db.Column(db.String(50), nullable=True)
line_date = db.Column(db.Date, nullable=True)
sku = db.Column(db.String(100), nullable=True)
# Inventory integration (only for line_kind == "item")
stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=True, index=True)
warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=True)
is_stock_item = db.Column(db.Boolean, default=False, nullable=False)
@@ -448,16 +455,47 @@ class QuoteItem(db.Model):
stock_item_id=None,
warehouse_id=None,
position=0,
line_kind="item",
display_name=None,
category=None,
line_date=None,
sku=None,
):
self.quote_id = quote_id
self.description = description.strip()
kind = (line_kind or "item").strip() or "item"
if kind not in ("item", "expense", "good"):
kind = "item"
self.line_kind = kind
dn = display_name.strip() if display_name else None
cat = category.strip() if category else None
sk = sku.strip() if sku else None
self.display_name = dn if kind != "item" else None
self.category = cat if kind != "item" else None
self.line_date = line_date if kind == "expense" else None
self.sku = sk if kind == "good" else None
desc = (description or "").strip()
if kind == "item":
self.description = desc or "-"
else:
self.description = desc if desc else (dn or "-")
self.quantity = Decimal(str(quantity))
self.unit_price = Decimal(str(unit_price))
self.total_amount = self.quantity * self.unit_price
self.unit = unit.strip() if unit else None
self.stock_item_id = stock_item_id
self.warehouse_id = warehouse_id
self.is_stock_item = stock_item_id is not None
if kind != "item":
self.stock_item_id = None
self.warehouse_id = None
self.is_stock_item = False
else:
self.stock_item_id = stock_item_id
self.warehouse_id = warehouse_id
self.is_stock_item = stock_item_id is not None
self.position = int(position) if position is not None else 0
def __repr__(self):
@@ -468,6 +506,11 @@ class QuoteItem(db.Model):
return {
"id": self.id,
"quote_id": self.quote_id,
"line_kind": self.line_kind,
"display_name": self.display_name,
"category": self.category,
"line_date": self.line_date.isoformat() if self.line_date else None,
"sku": self.sku,
"description": self.description,
"quantity": float(self.quantity),
"unit_price": float(self.unit_price),
+42 -1
View File
@@ -1359,7 +1359,6 @@ def create_quote():
tags:
- Quotes
"""
from datetime import date
from decimal import Decimal
from app.models import QuoteItem
@@ -1400,13 +1399,34 @@ def create_quote():
# Add items
items = data.get("items", [])
for position, item_data in enumerate(items):
kind = (item_data.get("line_kind") or "item").strip() or "item"
if kind not in ("item", "expense", "good"):
kind = "item"
sid = item_data.get("stock_item_id")
wid = item_data.get("warehouse_id")
try:
stock_item_id = int(sid) if sid is not None and str(sid).strip() != "" else None
except (TypeError, ValueError):
stock_item_id = None
try:
warehouse_id = int(wid) if wid is not None and str(wid).strip() != "" else None
except (TypeError, ValueError):
warehouse_id = None
line_dt = _parse_date(item_data.get("line_date")) if item_data.get("line_date") else None
item = QuoteItem(
quote_id=quote.id,
description=item_data.get("description", ""),
quantity=Decimal(str(item_data.get("quantity", 1))),
unit_price=Decimal(str(item_data.get("unit_price", 0))),
unit=item_data.get("unit"),
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
position=position,
line_kind=kind,
display_name=item_data.get("display_name"),
category=item_data.get("category"),
line_date=line_dt,
sku=item_data.get("sku"),
)
db.session.add(item)
@@ -1470,13 +1490,34 @@ def update_quote(quote_id):
# Add new items
for position, item_data in enumerate(data["items"]):
kind = (item_data.get("line_kind") or "item").strip() or "item"
if kind not in ("item", "expense", "good"):
kind = "item"
sid = item_data.get("stock_item_id")
wid = item_data.get("warehouse_id")
try:
stock_item_id = int(sid) if sid is not None and str(sid).strip() != "" else None
except (TypeError, ValueError):
stock_item_id = None
try:
warehouse_id = int(wid) if wid is not None and str(wid).strip() != "" else None
except (TypeError, ValueError):
warehouse_id = None
line_dt = _parse_date(item_data.get("line_date")) if item_data.get("line_date") else None
item = QuoteItem(
quote_id=quote.id,
description=item_data.get("description", ""),
quantity=Decimal(str(item_data.get("quantity", 1))),
unit_price=Decimal(str(item_data.get("unit_price", 0))),
unit=item_data.get("unit"),
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
position=position,
line_kind=kind,
display_name=item_data.get("display_name"),
category=item_data.get("category"),
line_date=line_dt,
sku=item_data.get("sku"),
)
db.session.add(item)
+473 -134
View File
@@ -14,6 +14,50 @@ from app.utils.permissions import admin_or_permission_required, permission_requi
quotes_bp = Blueprint("quotes", __name__)
def _parse_quote_form_date(value):
if not value or not str(value).strip():
return None
try:
return datetime.strptime(str(value).strip()[:10], "%Y-%m-%d").date()
except ValueError:
return None
def _pad_form_list(values, length):
out = list(values)
while len(out) < length:
out.append("")
return out
def _quote_form_inventory_context():
"""Stock + warehouse lists and JSON for quote create/edit forms."""
import json
from app.models import StockItem, Warehouse
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
return {
"stock_items": stock_items,
"warehouses": warehouses,
"stock_items_json": json.dumps(
[
{
"id": item.id,
"sku": item.sku,
"name": item.name,
"default_price": float(item.default_price) if item.default_price else None,
"unit": item.unit or "pcs",
"description": item.name,
}
for item in stock_items
]
),
"warehouses_json": json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses]),
}
@quotes_bp.route("/quotes")
@login_required
def list_quotes():
@@ -110,7 +154,11 @@ def create_quote():
if not title or not client_id:
flash(_("Quote title and client are required"), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
# Get client and validate
@@ -118,7 +166,11 @@ def create_quote():
if not client:
flash(_("Selected client not found"), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
# Validate amounts
@@ -129,7 +181,11 @@ def create_quote():
except (InvalidOperation, ValueError):
flash(_("Invalid total amount format"), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
try:
@@ -139,7 +195,11 @@ def create_quote():
except (InvalidOperation, ValueError):
flash(_("Invalid hourly rate format"), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
try:
@@ -149,7 +209,11 @@ def create_quote():
except ValueError:
flash(_("Invalid estimated hours format"), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
try:
@@ -159,7 +223,11 @@ def create_quote():
except (InvalidOperation, ValueError):
flash(_("Invalid tax rate format"), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
# Validate discount fields
@@ -178,7 +246,11 @@ def create_quote():
except (InvalidOperation, ValueError):
flash(_("Invalid discount amount format"), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
# Parse valid_until date
@@ -189,7 +261,11 @@ def create_quote():
except ValueError:
flash(_("Invalid date format for valid until"), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
# Generate quote number
@@ -218,44 +294,165 @@ def create_quote():
db.session.add(quote)
db.session.flush() # Get quote ID for items
# Process line items if provided
# Process line items (items + expenses + goods — issue #585)
item_descriptions = request.form.getlist("item_description[]")
item_quantities = request.form.getlist("item_quantity[]")
item_prices = request.form.getlist("item_price[]")
item_units = request.form.getlist("item_unit[]")
item_line_sources = request.form.getlist("item_line_source[]")
item_stock_ids = request.form.getlist("item_stock_item_id[]")
item_warehouse_ids = request.form.getlist("item_warehouse_id[]")
line_position = 0
for desc, qty, price, unit, stock_id, wh_id in zip(
item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids
):
if desc.strip():
try:
stock_item_id = int(stock_id) if stock_id and stock_id.strip() else None
warehouse_id = int(wh_id) if wh_id and wh_id.strip() else None
qe_titles = request.form.getlist("qe_title[]")
qe_descriptions = request.form.getlist("qe_description[]")
qe_categories = request.form.getlist("qe_category[]")
qe_amounts = request.form.getlist("qe_amount[]")
qe_dates = request.form.getlist("qe_date[]")
item = QuoteItem(
quote_id=quote.id,
description=desc.strip(),
quantity=Decimal(qty) if qty else Decimal("1"),
unit_price=Decimal(price) if price else Decimal("0"),
unit=unit.strip() if unit else None,
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
position=line_position,
)
db.session.add(item)
line_position += 1
except (ValueError, InvalidOperation):
pass # Skip invalid items
qg_names = request.form.getlist("qg_name[]")
qg_descriptions = request.form.getlist("qg_description[]")
qg_categories = request.form.getlist("qg_category[]")
qg_quantities = request.form.getlist("qg_quantity[]")
qg_prices = request.form.getlist("qg_unit_price[]")
qg_skus = request.form.getlist("qg_sku[]")
n_items = len(item_descriptions)
item_line_sources = _pad_form_list(item_line_sources, n_items)
item_quantities = _pad_form_list(item_quantities, n_items)
item_prices = _pad_form_list(item_prices, n_items)
item_units = _pad_form_list(item_units, n_items)
item_stock_ids = _pad_form_list(item_stock_ids, n_items)
item_warehouse_ids = _pad_form_list(item_warehouse_ids, n_items)
n_qe = len(qe_titles)
qe_descriptions = _pad_form_list(qe_descriptions, n_qe)
qe_categories = _pad_form_list(qe_categories, n_qe)
qe_amounts = _pad_form_list(qe_amounts, n_qe)
qe_dates = _pad_form_list(qe_dates, n_qe)
n_qg = len(qg_names)
qg_descriptions = _pad_form_list(qg_descriptions, n_qg)
qg_categories = _pad_form_list(qg_categories, n_qg)
qg_quantities = _pad_form_list(qg_quantities, n_qg)
qg_prices = _pad_form_list(qg_prices, n_qg)
qg_skus = _pad_form_list(qg_skus, n_qg)
line_position = 0
for desc, qty, price, unit, src, stock_id, wh_id in zip(
item_descriptions,
item_quantities,
item_prices,
item_units,
item_line_sources,
item_stock_ids,
item_warehouse_ids,
):
use_stock = (src or "").strip().lower() == "stock"
try:
stock_item_id = int(stock_id) if stock_id and str(stock_id).strip() and use_stock else None
warehouse_id = int(wh_id) if wh_id and str(wh_id).strip() and use_stock else None
except (TypeError, ValueError):
stock_item_id, warehouse_id = None, None
if not use_stock:
stock_item_id, warehouse_id = None, None
desc_s = (desc or "").strip()
if not desc_s and not stock_item_id:
continue
try:
q_dec = Decimal(qty) if qty and str(qty).strip() else Decimal("1")
p_dec = Decimal(price) if price and str(price).strip() else Decimal("0")
item = QuoteItem(
quote_id=quote.id,
description=desc_s or "-",
quantity=q_dec,
unit_price=p_dec,
unit=unit.strip() if unit and str(unit).strip() else None,
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
position=line_position,
line_kind="item",
)
db.session.add(item)
line_position += 1
except (ValueError, InvalidOperation):
pass
for title, qe_desc, cat, amount, qe_d in zip(
qe_titles, qe_descriptions, qe_categories, qe_amounts, qe_dates
):
title_s = (title or "").strip()
qe_desc_s = (qe_desc or "").strip()
if not title_s and not qe_desc_s and not (amount and str(amount).strip()):
continue
try:
amt = Decimal(amount) if amount and str(amount).strip() else Decimal("0")
except (InvalidOperation, ValueError):
continue
if amt <= 0 and not title_s and not qe_desc_s:
continue
ld = _parse_quote_form_date(qe_d)
cat_s = (cat or "").strip() or None
try:
item = QuoteItem(
quote_id=quote.id,
description=qe_desc_s if qe_desc_s else (title_s or "-"),
quantity=Decimal("1"),
unit_price=amt,
line_kind="expense",
display_name=title_s or None,
category=cat_s,
line_date=ld,
position=line_position,
)
db.session.add(item)
line_position += 1
except (InvalidOperation, ValueError):
pass
for name, g_desc, g_cat, g_qty, g_price, g_sku in zip(
qg_names, qg_descriptions, qg_categories, qg_quantities, qg_prices, qg_skus
):
name_s = (name or "").strip()
g_desc_s = (g_desc or "").strip()
if not name_s and not g_desc_s:
continue
try:
gq = Decimal(g_qty) if g_qty and str(g_qty).strip() else Decimal("1")
gp = Decimal(g_price) if g_price and str(g_price).strip() else Decimal("0")
except (InvalidOperation, ValueError):
continue
if gq <= 0 or gp < 0:
continue
g_cat_s = (g_cat or "").strip() or None
g_sku_s = (g_sku or "").strip() or None
try:
item = QuoteItem(
quote_id=quote.id,
description=g_desc_s if g_desc_s else (name_s or "-"),
quantity=gq,
unit_price=gp,
line_kind="good",
display_name=name_s or None,
category=g_cat_s,
sku=g_sku_s,
position=line_position,
)
db.session.add(item)
line_position += 1
except (InvalidOperation, ValueError):
pass
quote.calculate_totals()
if not safe_commit("create_quote", {"title": title, "client_id": client_id}):
flash(_("Could not create quote due to a database error. Please check server logs."), "error")
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
# Log event
@@ -268,7 +465,11 @@ def create_quote():
return redirect(url_for("quotes.view_quote", quote_id=quote.id))
return render_template(
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
"quotes/create.html",
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
**_quote_form_inventory_context(),
)
@@ -340,7 +541,8 @@ def edit_quote(quote_id):
raise InvalidOperation
except (InvalidOperation, ValueError):
flash(_("Invalid tax rate format"), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
inv = _quote_form_inventory_context()
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients(), **inv)
# Validate discount fields
discount_amount_decimal = None
@@ -357,7 +559,8 @@ def edit_quote(quote_id):
discount_type = None # Invalid type, ignore discount
except (InvalidOperation, ValueError):
flash(_("Invalid discount amount format"), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
inv = _quote_form_inventory_context()
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients(), **inv)
# Parse valid_until date
valid_until_date = None
@@ -366,7 +569,8 @@ def edit_quote(quote_id):
valid_until_date = datetime.strptime(valid_until, "%Y-%m-%d").date()
except ValueError:
flash(_("Invalid date format for valid until"), "error")
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
inv = _quote_form_inventory_context()
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients(), **inv)
# Update quote
quote.title = title
@@ -395,100 +599,243 @@ def edit_quote(quote_id):
quote.discount_reason = discount_reason.strip() if discount_reason else None
quote.coupon_code = coupon_code.upper().strip() if coupon_code else None
# Update line items
# Update line items (items + expenses + goods — issue #585)
item_ids = request.form.getlist("item_id[]")
item_descriptions = request.form.getlist("item_description[]")
item_quantities = request.form.getlist("item_quantity[]")
item_prices = request.form.getlist("item_price[]")
item_units = request.form.getlist("item_unit[]")
# Delete items not in the form
existing_item_ids = {int(id) for id in item_ids if id}
for item in quote.items:
if item.id not in existing_item_ids:
db.session.delete(item)
# Update or create items
item_line_sources = request.form.getlist("item_line_source[]")
item_stock_ids = request.form.getlist("item_stock_item_id[]")
item_warehouse_ids = request.form.getlist("item_warehouse_id[]")
# Pad lists to match length
while len(item_stock_ids) < len(item_ids):
item_stock_ids.append("")
while len(item_warehouse_ids) < len(item_ids):
item_warehouse_ids.append("")
qe_ids = request.form.getlist("qe_id[]")
qe_titles = request.form.getlist("qe_title[]")
qe_descriptions = request.form.getlist("qe_description[]")
qe_categories = request.form.getlist("qe_category[]")
qe_amounts = request.form.getlist("qe_amount[]")
qe_dates = request.form.getlist("qe_date[]")
qg_ids = request.form.getlist("qg_id[]")
qg_names = request.form.getlist("qg_name[]")
qg_descriptions = request.form.getlist("qg_description[]")
qg_categories = request.form.getlist("qg_category[]")
qg_quantities = request.form.getlist("qg_quantity[]")
qg_prices = request.form.getlist("qg_unit_price[]")
qg_skus = request.form.getlist("qg_sku[]")
n_items = len(item_descriptions)
item_ids = _pad_form_list(item_ids, n_items)
item_line_sources = _pad_form_list(item_line_sources, n_items)
item_quantities = _pad_form_list(item_quantities, n_items)
item_prices = _pad_form_list(item_prices, n_items)
item_units = _pad_form_list(item_units, n_items)
item_stock_ids = _pad_form_list(item_stock_ids, n_items)
item_warehouse_ids = _pad_form_list(item_warehouse_ids, n_items)
n_qe = len(qe_titles)
qe_ids = _pad_form_list(qe_ids, n_qe)
qe_descriptions = _pad_form_list(qe_descriptions, n_qe)
qe_categories = _pad_form_list(qe_categories, n_qe)
qe_amounts = _pad_form_list(qe_amounts, n_qe)
qe_dates = _pad_form_list(qe_dates, n_qe)
n_qg = len(qg_names)
qg_ids = _pad_form_list(qg_ids, n_qg)
qg_descriptions = _pad_form_list(qg_descriptions, n_qg)
qg_categories = _pad_form_list(qg_categories, n_qg)
qg_quantities = _pad_form_list(qg_quantities, n_qg)
qg_prices = _pad_form_list(qg_prices, n_qg)
qg_skus = _pad_form_list(qg_skus, n_qg)
existing_item_ids = set()
for raw in item_ids + qe_ids + qg_ids:
if raw and str(raw).strip():
try:
existing_item_ids.add(int(raw))
except (TypeError, ValueError):
pass
for row in list(quote.items):
if row.id not in existing_item_ids:
db.session.delete(row)
line_position = 0
for item_id, desc, qty, price, unit, stock_id, wh_id in zip(
item_ids, item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids
):
if desc.strip():
try:
stock_item_id = int(stock_id) if stock_id and stock_id.strip() else None
warehouse_id = int(wh_id) if wh_id and wh_id.strip() else None
if item_id:
# Update existing item
item = QuoteItem.query.get(item_id)
if item and item.quote_id == quote.id:
item.description = desc.strip()
item.quantity = Decimal(qty) if qty else Decimal("1")
item.unit_price = Decimal(price) if price else Decimal("0")
item.total_amount = item.quantity * item.unit_price
item.unit = unit.strip() if unit else None
item.stock_item_id = stock_item_id
item.warehouse_id = warehouse_id
item.is_stock_item = stock_item_id is not None
item.position = line_position
else:
# Create new item
item = QuoteItem(
quote_id=quote.id,
description=desc.strip(),
quantity=Decimal(qty) if qty else Decimal("1"),
unit_price=Decimal(price) if price else Decimal("0"),
unit=unit.strip() if unit else None,
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
position=line_position,
)
db.session.add(item)
line_position += 1
except (ValueError, InvalidOperation):
pass # Skip invalid items
for item_id, desc, qty, price, unit, src, stock_id, wh_id in zip(
item_ids,
item_descriptions,
item_quantities,
item_prices,
item_units,
item_line_sources,
item_stock_ids,
item_warehouse_ids,
):
use_stock = (src or "").strip().lower() == "stock"
try:
stock_item_id = int(stock_id) if stock_id and str(stock_id).strip() and use_stock else None
warehouse_id = int(wh_id) if wh_id and str(wh_id).strip() and use_stock else None
except (TypeError, ValueError):
stock_item_id, warehouse_id = None, None
if not use_stock:
stock_item_id, warehouse_id = None, None
desc_s = (desc or "").strip()
if not desc_s and not stock_item_id:
continue
try:
q_dec = Decimal(qty) if qty and str(qty).strip() else Decimal("1")
p_dec = Decimal(price) if price and str(price).strip() else Decimal("0")
except (InvalidOperation, ValueError):
continue
try:
if item_id and str(item_id).strip():
item = QuoteItem.query.get(int(item_id))
if not item or item.quote_id != quote.id:
continue
item.line_kind = "item"
item.display_name = None
item.category = None
item.line_date = None
item.sku = None
item.description = desc_s or "-"
item.quantity = q_dec
item.unit_price = p_dec
item.total_amount = q_dec * p_dec
item.unit = unit.strip() if unit and str(unit).strip() else None
item.stock_item_id = stock_item_id
item.warehouse_id = warehouse_id
item.is_stock_item = stock_item_id is not None
item.position = line_position
else:
item = QuoteItem(
quote_id=quote.id,
description=desc_s or "-",
quantity=q_dec,
unit_price=p_dec,
unit=unit.strip() if unit and str(unit).strip() else None,
stock_item_id=stock_item_id,
warehouse_id=warehouse_id,
position=line_position,
line_kind="item",
)
db.session.add(item)
line_position += 1
except (TypeError, ValueError, InvalidOperation):
pass
for qe_id, title, qe_desc, cat, amount, qe_d in zip(
qe_ids, qe_titles, qe_descriptions, qe_categories, qe_amounts, qe_dates
):
title_s = (title or "").strip()
qe_desc_s = (qe_desc or "").strip()
if not title_s and not qe_desc_s and not (amount and str(amount).strip()):
continue
try:
amt = Decimal(amount) if amount and str(amount).strip() else Decimal("0")
except (InvalidOperation, ValueError):
continue
if amt <= 0 and not title_s and not qe_desc_s:
continue
ld = _parse_quote_form_date(qe_d)
cat_s = (cat or "").strip() or None
try:
if qe_id and str(qe_id).strip():
item = QuoteItem.query.get(int(qe_id))
if not item or item.quote_id != quote.id:
continue
item.line_kind = "expense"
item.display_name = title_s or None
item.description = qe_desc_s if qe_desc_s else (title_s or "-")
item.category = cat_s
item.line_date = ld
item.sku = None
item.quantity = Decimal("1")
item.unit_price = amt
item.total_amount = amt
item.unit = None
item.stock_item_id = None
item.warehouse_id = None
item.is_stock_item = False
item.position = line_position
else:
item = QuoteItem(
quote_id=quote.id,
description=qe_desc_s if qe_desc_s else (title_s or "-"),
quantity=Decimal("1"),
unit_price=amt,
line_kind="expense",
display_name=title_s or None,
category=cat_s,
line_date=ld,
position=line_position,
)
db.session.add(item)
line_position += 1
except (TypeError, ValueError, InvalidOperation):
pass
for qg_id, name, g_desc, g_cat, g_qty, g_price, g_sku in zip(
qg_ids, qg_names, qg_descriptions, qg_categories, qg_quantities, qg_prices, qg_skus
):
name_s = (name or "").strip()
g_desc_s = (g_desc or "").strip()
if not name_s and not g_desc_s:
continue
try:
gq = Decimal(g_qty) if g_qty and str(g_qty).strip() else Decimal("1")
gp = Decimal(g_price) if g_price and str(g_price).strip() else Decimal("0")
except (InvalidOperation, ValueError):
continue
if gq <= 0 or gp < 0:
continue
g_cat_s = (g_cat or "").strip() or None
g_sku_s = (g_sku or "").strip() or None
try:
if qg_id and str(qg_id).strip():
item = QuoteItem.query.get(int(qg_id))
if not item or item.quote_id != quote.id:
continue
item.line_kind = "good"
item.display_name = name_s or None
item.description = g_desc_s if g_desc_s else (name_s or "-")
item.category = g_cat_s
item.line_date = None
item.sku = g_sku_s
item.quantity = gq
item.unit_price = gp
item.total_amount = gq * gp
item.unit = None
item.stock_item_id = None
item.warehouse_id = None
item.is_stock_item = False
item.position = line_position
else:
item = QuoteItem(
quote_id=quote.id,
description=g_desc_s if g_desc_s else (name_s or "-"),
quantity=gq,
unit_price=gp,
line_kind="good",
display_name=name_s or None,
category=g_cat_s,
sku=g_sku_s,
position=line_position,
)
db.session.add(item)
line_position += 1
except (TypeError, ValueError, InvalidOperation):
pass
quote.calculate_totals()
if not safe_commit("edit_quote", {"quote_id": quote_id}):
flash(_("Could not update quote due to a database error. Please check server logs."), "error")
import json
from app.models import StockItem, Warehouse
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
stock_items_json = json.dumps(
[
{
"id": item.id,
"sku": item.sku,
"name": item.name,
"default_price": float(item.default_price) if item.default_price else None,
"unit": item.unit or "pcs",
"description": item.name,
}
for item in stock_items
]
)
warehouses_json = json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses])
inv = _quote_form_inventory_context()
return render_template(
"quotes/edit.html",
quote=quote,
clients=Client.get_active_clients(),
stock_items=stock_items,
warehouses=warehouses,
stock_items_json=stock_items_json,
warehouses_json=warehouses_json,
**inv,
)
log_event("quote.updated", user_id=current_user.id, quote_id=quote.id, quote_title=title)
@@ -497,34 +844,12 @@ def edit_quote(quote_id):
flash(_("Quote updated successfully"), "success")
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
import json
from app.models import StockItem, Warehouse
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
stock_items_json = json.dumps(
[
{
"id": item.id,
"sku": item.sku,
"name": item.name,
"default_price": float(item.default_price) if item.default_price else None,
"unit": item.unit or "pcs",
"description": item.name,
}
for item in stock_items
]
)
warehouses_json = json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses])
inv = _quote_form_inventory_context()
return render_template(
"quotes/edit.html",
quote=quote,
clients=Client.get_active_clients(),
stock_items=stock_items,
warehouses=warehouses,
stock_items_json=stock_items_json,
warehouses_json=warehouses_json,
**inv,
)
@@ -1454,6 +1779,13 @@ def duplicate_quote(quote_id):
unit_price=original_item.unit_price,
unit=original_item.unit,
position=original_item.position,
stock_item_id=original_item.stock_item_id,
warehouse_id=original_item.warehouse_id,
line_kind=getattr(original_item, "line_kind", None) or "item",
display_name=getattr(original_item, "display_name", None),
category=getattr(original_item, "category", None),
line_date=getattr(original_item, "line_date", None),
sku=getattr(original_item, "sku", None),
)
db.session.add(new_item)
@@ -1552,6 +1884,13 @@ def bulk_action():
unit_price=item.unit_price,
unit=item.unit,
position=item.position,
stock_item_id=item.stock_item_id,
warehouse_id=item.warehouse_id,
line_kind=getattr(item, "line_kind", None) or "item",
display_name=getattr(item, "display_name", None),
category=getattr(item, "category", None),
line_date=getattr(item, "line_date", None),
sku=getattr(item, "sku", None),
)
db.session.add(new_item)
+10 -1
View File
@@ -75,7 +75,16 @@
<tbody>
{% for item in quote.items %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-3" data-label="{{ _('Description') }}">{{ item.description }}</td>
<td class="p-3" data-label="{{ _('Description') }}">
{% if item.display_name %}
<span class="font-medium">{{ item.display_name }}</span>
{% if item.description and item.description != item.display_name and item.description != '-' %}<br><span class="text-sm opacity-80">{{ item.description }}</span>{% endif %}
{% else %}
{{ item.description }}
{% endif %}
{% if item.line_kind == 'expense' and item.category %}<br><span class="text-xs opacity-70">{{ item.category }}</span>{% endif %}
{% if item.line_kind == 'good' and item.sku %}<br><span class="text-xs opacity-70">{{ _('SKU') }}: {{ item.sku }}</span>{% endif %}
</td>
<td class="p-3" data-label="{{ _('Quantity') }}">{{ "%.2f"|format(item.quantity) }} {% if item.unit %}{{ item.unit }}{% endif %}</td>
<td class="p-3" data-label="{{ _('Unit Price') }}">{{ "%.2f"|format(item.unit_price) }} {{ quote.currency_code }}</td>
<td class="p-3 font-medium" data-label="{{ _('Total') }}">{{ "%.2f"|format(item.total_amount) }} {{ quote.currency_code }}</td>
@@ -0,0 +1,270 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
const itemsContainer = document.getElementById('quote-items');
const expensesContainer = document.getElementById('quote-expenses');
const goodsContainer = document.getElementById('quote-goods');
const addItemBtn = document.getElementById('add-item');
const addExpBtn = document.getElementById('add-quote-expense');
const addGoodBtn = document.getElementById('add-quote-good');
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
function stockOptionsHtml(selectedId) {
let h = '<option value="">{{ _("None") }}</option>';
if (stockItems && Array.isArray(stockItems)) {
stockItems.forEach(function(si) {
const sel = String(si.id) === String(selectedId || '') ? ' selected' : '';
const desc = String(si.description || si.name || '').replace(/"/g, '&quot;');
h += '<option value="' + si.id + '" data-price="' + (si.default_price || 0) + '" data-unit="' + (si.unit || '') + '" data-description="' + desc + '"' + sel + '>' + si.sku + ' - ' + si.name + '</option>';
});
}
return h;
}
function warehouseOptionsHtml(selectedId) {
let h = '<option value="">{{ _("None") }}</option>';
if (warehouses && Array.isArray(warehouses)) {
warehouses.forEach(function(wh) {
const sel = String(wh.id) === String(selectedId || '') ? ' selected' : '';
h += '<option value="' + wh.id + '"' + sel + '>' + wh.code + ' - ' + wh.name + '</option>';
});
}
return h;
}
function applyItemRowLayout(row) {
const src = row.querySelector('.item-line-source');
const stockMode = src && src.value === 'stock';
const stockCols = row.querySelector('.item-stock-cols');
const descWrap = row.querySelector('.item-desc-wrap');
if (!stockCols || !descWrap) return;
if (stockMode) {
stockCols.classList.remove('hidden');
descWrap.classList.remove('md:col-span-6');
descWrap.classList.add('md:col-span-2');
} else {
stockCols.classList.add('hidden');
const hidS = row.querySelector('input[name="item_stock_item_id[]"]');
const hidW = row.querySelector('input[name="item_warehouse_id[]"]');
if (hidS) hidS.value = '';
if (hidW) hidW.value = '';
const ss = row.querySelector('.item-stock-select');
const ws = row.querySelector('.item-warehouse-select');
if (ss) ss.value = '';
if (ws) ws.value = '';
descWrap.classList.remove('md:col-span-2');
descWrap.classList.add('md:col-span-6');
}
}
function wireItemRow(row) {
const src = row.querySelector('.item-line-source');
if (src) {
src.addEventListener('change', function() {
applyItemRowLayout(row);
calculateTotals();
});
}
const ss = row.querySelector('.item-stock-select');
if (ss) {
ss.addEventListener('change', function() {
const hid = row.querySelector('input[name="item_stock_item_id[]"]');
if (hid) hid.value = this.value || '';
const opt = this.options[this.selectedIndex];
if (this.value && opt) {
const price = parseFloat(opt.dataset.price || 0);
const description = opt.dataset.description || '';
const unit = opt.dataset.unit || '';
const dEl = row.querySelector('.item-description');
if (dEl && description && !dEl.value) dEl.value = description;
const pEl = row.querySelector('.item-price');
if (pEl && price > 0 && !pEl.value) pEl.value = price.toFixed(2);
const uEl = row.querySelector('.item-unit');
if (uEl && unit && !uEl.value) uEl.value = unit;
}
calculateTotals();
});
}
const ws = row.querySelector('.item-warehouse-select');
if (ws) {
ws.addEventListener('change', function() {
const hid = row.querySelector('input[name="item_warehouse_id[]"]');
if (hid) hid.value = this.value || '';
});
}
row.querySelector('.remove-item')?.addEventListener('click', function() {
row.remove();
calculateTotals();
});
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
applyItemRowLayout(row);
}
function addItemRow() {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200/50 dark:border-blue-800/50 quote-item-row min-w-0 hover:shadow-sm transition';
row.innerHTML =
'<input type="hidden" name="item_id[]" value="">' +
'<input type="hidden" name="item_stock_item_id[]" value="">' +
'<input type="hidden" name="item_warehouse_id[]" value="">' +
'<div class="md:col-span-2 min-w-0">' +
'<select name="item_line_source[]" class="form-input item-line-source text-sm">' +
'<option value="manual" selected>{{ _("Manual entry") }}</option>' +
'<option value="stock">{{ _("From stock") }}</option></select></div>' +
'<div class="item-stock-cols md:col-span-4 min-w-0 hidden grid grid-cols-1 md:grid-cols-2 gap-3">' +
'<select class="form-input item-stock-select text-sm">' + stockOptionsHtml() + '</select>' +
'<select class="form-input item-warehouse-select text-sm">' + warehouseOptionsHtml() + '</select></div>' +
'<div class="item-desc-wrap md:col-span-6 min-w-0">' +
'<input type="text" name="item_description[]" class="w-full form-input item-description" placeholder="{{ _("Item description") }}" data-calc-trigger></div>' +
'<input type="number" name="item_quantity[]" value="1" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>' +
'<input type="text" name="item_unit[]" class="md:col-span-1 min-w-0 form-input item-unit" placeholder="{{ _("Unit") }}">' +
'<input type="number" name="item_price[]" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-price" data-calc-trigger>' +
'<div class="md:col-span-1 min-w-0 flex items-center justify-between gap-2">' +
'<span class="font-medium item-total">0.00</span>' +
'<button type="button" class="remove-item shrink-0 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200" title="{{ _("Remove item") }}"><i class="fas fa-trash"></i></button></div>';
itemsContainer.appendChild(row);
wireItemRow(row);
calculateTotals();
}
const qeCategoryOptions =
'<option value="travel">{{ _("Travel") }}</option>' +
'<option value="meals">{{ _("Meals") }}</option>' +
'<option value="supplies">{{ _("Supplies") }}</option>' +
'<option value="services">{{ _("Services") }}</option>' +
'<option value="equipment">{{ _("Equipment") }}</option>' +
'<option value="other" selected>{{ _("Other") }}</option>';
function addExpenseRow() {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 quote-expense-row min-w-0 hover:shadow-sm transition';
row.innerHTML =
'<input type="hidden" name="qe_id[]" value="">' +
'<div class="md:col-span-2 min-w-0"><input type="text" name="qe_title[]" class="form-input" placeholder="{{ _("Title") }}" data-calc-trigger></div>' +
'<div class="md:col-span-3 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><select name="qe_category[]" class="form-input">' + qeCategoryOptions + '</select></div>' +
'<div class="md:col-span-2 min-w-0"><input type="number" name="qe_amount[]" class="form-input qe-amount" step="0.01" min="0" placeholder="0" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><input type="date" name="qe_date[]" class="form-input user-date-input text-sm"></div>' +
'<div class="md:col-span-1 min-w-0 flex items-center justify-center">' +
'<button type="button" class="remove-quote-expense bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200" title="{{ _("Remove") }}"><i class="fas fa-trash"></i></button></div>';
expensesContainer.appendChild(row);
row.querySelector('.remove-quote-expense')?.addEventListener('click', function() {
row.remove();
calculateTotals();
});
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
calculateTotals();
}
const qgCategoryOptions =
'<option value="product">{{ _("Product") }}</option>' +
'<option value="service">{{ _("Service") }}</option>' +
'<option value="material">{{ _("Material") }}</option>' +
'<option value="license">{{ _("License") }}</option>' +
'<option value="other" selected>{{ _("Other") }}</option>';
function addGoodRow() {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/50 quote-good-row min-w-0 hover:shadow-sm transition';
row.innerHTML =
'<input type="hidden" name="qg_id[]" value="">' +
'<div class="md:col-span-2 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _("Name") }}" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><input type="text" name="qg_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><select name="qg_category[]" class="form-input">' + qgCategoryOptions + '</select></div>' +
'<div class="md:col-span-2 min-w-0"><input type="number" name="qg_quantity[]" class="form-input qg-quantity" value="1" step="0.01" min="0" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><input type="number" name="qg_unit_price[]" class="form-input qg-price" step="0.01" min="0" data-calc-trigger></div>' +
'<div class="md:col-span-1 min-w-0"><input type="text" name="qg_sku[]" class="form-input text-xs" placeholder="{{ _("SKU") }}"></div>' +
'<div class="md:col-span-1 min-w-0 flex items-center justify-center">' +
'<button type="button" class="remove-quote-good bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200" title="{{ _("Remove") }}"><i class="fas fa-trash"></i></button></div>';
goodsContainer.appendChild(row);
row.querySelector('.remove-quote-good')?.addEventListener('click', function() {
row.remove();
calculateTotals();
});
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
calculateTotals();
}
function calculateTotals() {
let itemsTotal = 0;
let itemsCount = 0;
document.querySelectorAll('.quote-item-row').forEach(function(row) {
const qty = parseFloat(row.querySelector('.item-quantity')?.value || 0);
const price = parseFloat(row.querySelector('.item-price')?.value || 0);
const total = qty * price;
const totalEl = row.querySelector('.item-total');
if (totalEl) totalEl.textContent = total.toFixed(2);
if (qty > 0) {
itemsTotal += total;
const desc = row.querySelector('.item-description')?.value?.trim();
const sid = row.querySelector('input[name="item_stock_item_id[]"]')?.value;
if (desc || sid) itemsCount++;
}
});
let expTotal = 0;
let expCount = 0;
document.querySelectorAll('.quote-expense-row').forEach(function(row) {
const amt = parseFloat(row.querySelector('.qe-amount')?.value || 0);
const t = row.querySelector('[name="qe_title[]"]')?.value?.trim();
const d = row.querySelector('[name="qe_description[]"]')?.value?.trim();
if (amt > 0) expTotal += amt;
if (amt > 0 || t || d) expCount++;
});
let goodsTotal = 0;
let goodsCount = 0;
document.querySelectorAll('.quote-good-row').forEach(function(row) {
const qty = parseFloat(row.querySelector('.qg-quantity')?.value || 0);
const price = parseFloat(row.querySelector('.qg-price')?.value || 0);
const nm = row.querySelector('[name="qg_name[]"]')?.value?.trim();
const ds = row.querySelector('[name="qg_description[]"]')?.value?.trim();
if (qty > 0 && price > 0) goodsTotal += qty * price;
if ((qty > 0 && price > 0) || nm || ds) goodsCount++;
});
const grand = itemsTotal + expTotal + goodsTotal;
const el = (id) => document.getElementById(id);
if (el('quote-items-section-total')) el('quote-items-section-total').textContent = itemsTotal.toFixed(2);
if (el('quote-expenses-section-total')) el('quote-expenses-section-total').textContent = expTotal.toFixed(2);
if (el('quote-goods-section-total')) el('quote-goods-section-total').textContent = goodsTotal.toFixed(2);
if (el('quote-all-lines-subtotal')) el('quote-all-lines-subtotal').textContent = grand.toFixed(2);
if (el('items-count')) el('items-count').textContent = itemsCount;
if (el('quote-expenses-count')) el('quote-expenses-count').textContent = expCount;
if (el('quote-goods-count')) el('quote-goods-count').textContent = goodsCount;
}
addItemBtn?.addEventListener('click', addItemRow);
addExpBtn?.addEventListener('click', addExpenseRow);
addGoodBtn?.addEventListener('click', addGoodRow);
document.querySelectorAll('.quote-item-row').forEach(wireItemRow);
document.querySelectorAll('.quote-expense-row').forEach(function(row) {
row.querySelector('.remove-quote-expense')?.addEventListener('click', function() {
row.remove();
calculateTotals();
});
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
});
document.querySelectorAll('.quote-good-row').forEach(function(row) {
row.querySelector('.remove-quote-good')?.addEventListener('click', function() {
row.remove();
calculateTotals();
});
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
});
document.getElementById('tax_rate')?.addEventListener('input', calculateTotals);
calculateTotals();
});
</script>
+90 -232
View File
@@ -43,43 +43,106 @@
</div>
</div>
<!-- Quote Items Section -->
<!-- Line items (manual or from stock — issue #585) -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4 pb-2 border-b border-border-light dark:border-border-dark">
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-list mr-2 text-primary"></i>{{ _('Quote Items') }}
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-full">0</span>
</h2>
<button type="button" id="add-item" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add Item') }}
<div>
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-list mr-2 text-blue-600"></i>{{ _('Quote line items') }}
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">0</span>
</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Services, labor, and catalog lines') }}</p>
</div>
<button type="button" id="add-item" class="btn btn-primary shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add line') }}
</button>
</div>
<!-- Items header (desktop) -->
<div class="hidden md:grid md:grid-cols-[repeat(13,minmax(0,1fr))] gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-1 text-center">#</div>
<div class="md:col-span-2">{{ _('Stock Item') }}</div>
<div class="md:col-span-2">{{ _('Warehouse') }}</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-2">{{ _('Line type') }}</div>
<div class="md:col-span-4">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-1">{{ _('Quantity') }}</div>
<div class="md:col-span-1">{{ _('Qty') }}</div>
<div class="md:col-span-1">{{ _('Unit') }}</div>
<div class="md:col-span-2">{{ _('Unit Price') }}</div>
<div class="md:col-span-1">{{ _('Price') }}</div>
<div class="md:col-span-1">{{ _('Total') }}</div>
</div>
<div id="quote-items" class="space-y-2"></div>
<div class="mt-3 p-3 bg-blue-50/30 dark:bg-blue-950/10 rounded-lg border border-blue-200/30 dark:border-blue-800/30">
<div class="flex justify-between items-center text-sm font-medium">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Line items subtotal') }}:</span>
<span class="text-lg font-bold text-blue-700 dark:text-blue-400"><span id="quote-items-section-total">0.00</span></span>
</div>
</div>
</div>
<div class="mb-8 mt-8 border-t border-border-light dark:border-border-dark pt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-receipt mr-2 text-amber-600"></i>{{ _('Costs') }}
<span id="quote-expenses-count" class="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full">0</span>
</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('One-off costs (e.g. travel, materials)') }}</p>
</div>
<button type="button" id="add-quote-expense" class="btn bg-amber-600 text-white hover:bg-amber-700 shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add cost') }}
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-2">{{ _('Title') }}</div>
<div class="md:col-span-3">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Category') }}</div>
<div class="md:col-span-2">{{ _('Amount') }}</div>
<div class="md:col-span-2">{{ _('Date') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
<div id="quote-items" class="space-y-2">
<!-- Items will be added here dynamically -->
</div>
<div id="items-subtotal" class="mt-3 p-3 bg-primary/5 rounded-lg border border-primary/20">
<div id="quote-expenses" class="space-y-2"></div>
<div class="mt-3 p-3 bg-amber-50/30 dark:bg-amber-950/10 rounded-lg border border-amber-200/30 dark:border-amber-800/30">
<div class="flex justify-between items-center text-sm font-medium">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Subtotal') }}:</span>
<span class="text-lg font-bold text-primary"><span id="items-subtotal-amount">0.00</span></span>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Costs subtotal') }}:</span>
<span class="text-lg font-bold text-amber-700 dark:text-amber-400"><span id="quote-expenses-section-total">0.00</span></span>
</div>
</div>
</div>
<div class="mb-8 mt-8 border-t border-border-light dark:border-border-dark pt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-box mr-2 text-emerald-600"></i>{{ _('Extra goods') }}
<span id="quote-goods-count" class="ml-2 px-2 py-0.5 text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-full">0</span>
</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Products, licenses, hardware') }}</p>
</div>
<button type="button" id="add-quote-good" class="btn bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add good') }}
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-2">{{ _('Name') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Category') }}</div>
<div class="md:col-span-2">{{ _('Qty') }}</div>
<div class="md:col-span-2">{{ _('Price') }}</div>
<div class="md:col-span-1">{{ _('SKU') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
<div id="quote-goods" class="space-y-2"></div>
<div class="mt-3 p-3 bg-emerald-50/30 dark:bg-emerald-950/10 rounded-lg border border-emerald-200/30 dark:border-emerald-800/30">
<div class="flex justify-between items-center text-sm font-medium">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Goods subtotal') }}:</span>
<span class="text-lg font-bold text-emerald-700 dark:text-emerald-400"><span id="quote-goods-section-total">0.00</span></span>
</div>
</div>
</div>
<div class="mb-8 p-4 rounded-xl border border-border-light dark:border-border-dark bg-gray-50 dark:bg-gray-900/40">
<div class="flex justify-between items-center text-sm font-medium">
<span>{{ _('Subtotal (all quote lines)') }}</span>
<span class="text-xl font-bold text-primary"><span id="quote-all-lines-subtotal">0.00</span></span>
</div>
</div>
<!-- Financial Details Section -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4 pb-2 border-b border-border-light dark:border-border-dark flex items-center">
@@ -205,218 +268,13 @@
{% endblock %}
{% block scripts_extra %}
{% include 'quotes/_edit_quote_form_scripts.html' %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const itemsContainer = document.getElementById('quote-items');
const addItemBtn = document.getElementById('add-item');
let itemIndex = 0;
function refreshQuoteItemReorderControls() {
const rows = Array.from(itemsContainer.querySelectorAll('.quote-item-row'));
rows.forEach((row, i) => {
const up = row.querySelector('.quote-item-move-up');
const down = row.querySelector('.quote-item-move-down');
if (up) up.disabled = i === 0;
if (down) down.disabled = i === rows.length - 1;
});
var qi = document.getElementById('quote-items');
if (qi && qi.children.length === 0) {
document.getElementById('add-item')?.click();
}
function moveQuoteItemRowUp(row) {
const prev = row.previousElementSibling;
if (prev && prev.classList.contains('quote-item-row')) {
itemsContainer.insertBefore(row, prev);
refreshQuoteItemReorderControls();
}
}
function moveQuoteItemRowDown(row) {
const next = row.nextElementSibling;
if (next && next.classList.contains('quote-item-row')) {
itemsContainer.insertBefore(next, row);
refreshQuoteItemReorderControls();
}
}
function wireQuoteItemReorderButtons(row) {
row.querySelector('.quote-item-move-up')?.addEventListener('click', function() {
moveQuoteItemRowUp(row);
});
row.querySelector('.quote-item-move-down')?.addEventListener('click', function() {
moveQuoteItemRowDown(row);
});
}
// Stock items and warehouses data
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
// Handle stock item selection
function setupStockItemHandlers() {
document.querySelectorAll('.item-stock-select').forEach(select => {
select.addEventListener('change', function() {
const row = this.closest('.quote-item-row');
const stockItemId = this.value;
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = stockItemId || '';
if (stockItemId && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
const unit = selectedOption.dataset.unit || '';
// Auto-populate fields
if (description && !row.querySelector('.item-description').value) {
row.querySelector('.item-description').value = description;
}
if (price > 0 && !row.querySelector('.item-price').value) {
row.querySelector('.item-price').value = price.toFixed(2);
}
if (unit && !row.querySelector('.item-unit').value) {
row.querySelector('.item-unit').value = unit;
}
calculateTotals();
}
});
});
}
// Add new item row
function addItemRow(item = null) {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-[repeat(13,minmax(0,1fr))] gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row hover:shadow-sm transition';
// Build stock items dropdown
let stockItemsHtml = '<option value="">{{ _("None") }}</option>';
if (stockItems && Array.isArray(stockItems)) {
stockItems.forEach(stockItem => {
const price = stockItem.default_price || 0;
const unit = stockItem.unit || '';
const desc = stockItem.description || stockItem.name || '';
stockItemsHtml += '<option value="' + stockItem.id + '" data-price="' + price + '" data-unit="' + unit + '" data-description="' + desc.replace(/"/g, '&quot;') + '">' + stockItem.sku + ' - ' + stockItem.name + '</option>';
});
}
// Build warehouses dropdown
let warehousesHtml = '<option value="">{{ _("None") }}</option>';
if (warehouses && Array.isArray(warehouses)) {
warehouses.forEach(wh => {
warehousesHtml += '<option value="' + wh.id + '">' + wh.code + ' - ' + wh.name + '</option>';
});
}
// Translated strings
const placeholderDesc = '{{ _("Item description") }}';
const placeholderQty = '{{ _("Qty") }}';
const placeholderUnit = '{{ _("Unit") }}';
const placeholderPrice = '{{ _("Price") }}';
const removeTitle = '{{ _("Remove item") }}';
const moveUpTitle = '{{ _("Move up") }}';
const moveDownTitle = '{{ _("Move down") }}';
const reorderBtnClass = 'w-full px-2 py-1 text-xs rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed';
row.innerHTML =
'<input type="hidden" name="item_id[]" value="' + (item ? item.id : '') + '">' +
'<input type="hidden" name="item_stock_item_id[]" value="">' +
'<input type="hidden" name="item_warehouse_id[]" value="">' +
'<div class="md:col-span-1 flex flex-col gap-1 items-stretch justify-center min-w-0">' +
'<button type="button" class="quote-item-move-up ' + reorderBtnClass + '" title="' + moveUpTitle + '" aria-label="' + moveUpTitle + '"><i class="fas fa-chevron-up"></i></button>' +
'<button type="button" class="quote-item-move-down ' + reorderBtnClass + '" title="' + moveDownTitle + '" aria-label="' + moveDownTitle + '"><i class="fas fa-chevron-down"></i></button>' +
'</div>' +
'<select class="md:col-span-2 form-input item-stock-select text-sm">' + stockItemsHtml + '</select>' +
'<select class="md:col-span-2 form-input item-warehouse-select text-sm">' + warehousesHtml + '</select>' +
'<input type="text" name="item_description[]" placeholder="' + placeholderDesc + '" value="' + (item ? (item.description || '').replace(/"/g, '&quot;') : '') + '" class="md:col-span-2 form-input item-description" data-calc-trigger>' +
'<input type="number" name="item_quantity[]" placeholder="' + placeholderQty + '" value="' + (item ? item.quantity : '1') + '" step="0.01" min="0" class="md:col-span-1 form-input item-quantity" data-calc-trigger>' +
'<input type="text" name="item_unit[]" placeholder="' + placeholderUnit + '" value="' + (item ? (item.unit || '') : '') + '" class="md:col-span-1 form-input item-unit" placeholder="hrs, pcs, etc.">' +
'<input type="number" name="item_price[]" placeholder="' + placeholderPrice + '" value="' + (item ? item.unit_price : '') + '" step="0.01" min="0" class="md:col-span-2 form-input item-price" data-calc-trigger>' +
'<div class="md:col-span-1 flex items-center font-medium item-total">0.00</div>' +
'<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="' + removeTitle + '">' +
'<i class="fas fa-trash"></i>' +
'</button>';
itemsContainer.appendChild(row);
// Setup handlers for new row
const stockSelect = row.querySelector('.item-stock-select');
stockSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = this.value || '';
if (this.value && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
const unit = selectedOption.dataset.unit || '';
if (description) row.querySelector('.item-description').value = description;
if (price > 0) row.querySelector('.item-price').value = price.toFixed(2);
if (unit) row.querySelector('.item-unit').value = unit;
calculateTotals();
}
});
const warehouseSelect = row.querySelector('.item-warehouse-select');
warehouseSelect.addEventListener('change', function() {
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
hiddenWarehouseInput.value = this.value || '';
});
// Add event listeners
row.querySelector('.remove-item').addEventListener('click', function() {
row.remove();
calculateTotals();
refreshQuoteItemReorderControls();
});
wireQuoteItemReorderButtons(row);
// Add calculation triggers
row.querySelectorAll('[data-calc-trigger]').forEach(input => {
input.addEventListener('input', calculateTotals);
});
setupStockItemHandlers();
itemIndex++;
calculateTotals();
refreshQuoteItemReorderControls();
}
// Calculate totals
function calculateTotals() {
let itemsTotal = 0;
let itemsCount = 0;
document.querySelectorAll('.quote-item-row').forEach(row => {
const qty = parseFloat(row.querySelector('.item-quantity')?.value || 0);
const price = parseFloat(row.querySelector('.item-price')?.value || 0);
const total = qty * price;
if (qty > 0 && price > 0) {
itemsTotal += total;
itemsCount++;
}
// Update row total
const totalEl = row.querySelector('.item-total');
if (totalEl) {
totalEl.textContent = total.toFixed(2);
}
});
// Update subtotal
document.getElementById('items-subtotal-amount').textContent = itemsTotal.toFixed(2);
document.getElementById('items-count').textContent = itemsCount;
}
// Add item button
addItemBtn.addEventListener('click', function() {
addItemRow();
});
// Initialize stock item handlers
setupStockItemHandlers();
// Add initial empty row
addItemRow();
refreshQuoteItemReorderControls();
// Calculate on tax rate change
document.getElementById('tax_rate')?.addEventListener('input', calculateTotals);
});
</script>
{% endblock %}
+161 -282
View File
@@ -48,73 +48,188 @@
</div>
</div>
<!-- Quote Items Section -->
<!-- Line items (manual or from stock — issue #585) -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4 pb-2 border-b border-border-light dark:border-border-dark">
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-list mr-2 text-primary"></i>{{ _('Quote Items') }}
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-full">{{ quote.items|length if quote.items else 0 }}</span>
</h2>
<button type="button" id="add-item" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add Item') }}
<div>
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-list mr-2 text-blue-600"></i>{{ _('Quote line items') }}
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">0</span>
</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Services, labor, and catalog lines') }}</p>
</div>
<button type="button" id="add-item" class="btn btn-primary shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add line') }}
</button>
</div>
<!-- Items header (desktop) -->
<div class="hidden md:grid md:grid-cols-[repeat(13,minmax(0,1fr))] gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-1 text-center">#</div>
<div class="md:col-span-2">{{ _('Stock Item') }}</div>
<div class="md:col-span-2">{{ _('Warehouse') }}</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-2">{{ _('Line type') }}</div>
<div class="md:col-span-4 item-stock-header">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-1">{{ _('Quantity') }}</div>
<div class="md:col-span-1">{{ _('Qty') }}</div>
<div class="md:col-span-1">{{ _('Unit') }}</div>
<div class="md:col-span-2">{{ _('Unit Price') }}</div>
<div class="md:col-span-1">{{ _('Price') }}</div>
<div class="md:col-span-1">{{ _('Total') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
<div id="quote-items" class="space-y-2">
{% for item in quote.items %}
<div class="grid grid-cols-1 md:grid-cols-[repeat(13,minmax(0,1fr))] gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row min-w-0 hover:shadow-sm transition">
{% for item in quote.items if (item.line_kind or 'item') == 'item' %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200/50 dark:border-blue-800/50 quote-item-row min-w-0 hover:shadow-sm transition">
<input type="hidden" name="item_id[]" value="{{ item.id }}">
<input type="hidden" name="item_stock_item_id[]" value="{{ item.stock_item_id if item.stock_item_id else '' }}">
<input type="hidden" name="item_warehouse_id[]" value="{{ item.warehouse_id if item.warehouse_id else '' }}">
<div class="md:col-span-1 flex flex-col gap-1 items-stretch justify-center min-w-0">
<button type="button" class="quote-item-move-up w-full px-2 py-1 text-xs rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed" title="{{ _('Move up') }}" aria-label="{{ _('Move up') }}"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="quote-item-move-down w-full px-2 py-1 text-xs rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed" title="{{ _('Move down') }}" aria-label="{{ _('Move down') }}"><i class="fas fa-chevron-down"></i></button>
<div class="md:col-span-2 min-w-0">
<select name="item_line_source[]" class="form-input item-line-source text-sm" title="{{ _('Line type') }}">
<option value="manual" {% if not item.stock_item_id %}selected{% endif %}>{{ _('Manual entry') }}</option>
<option value="stock" {% if item.stock_item_id %}selected{% endif %}>{{ _('From stock') }}</option>
</select>
</div>
<div class="item-stock-cols md:col-span-4 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-3 {% if not item.stock_item_id %}hidden{% endif %}">
<select class="form-input item-stock-select text-sm" title="{{ _('Select Stock Item') }}">
<option value="">{{ _('None') }}</option>
{% for stock_item in stock_items %}
<option value="{{ stock_item.id }}" data-price="{{ stock_item.default_price or 0 }}" data-unit="{{ stock_item.unit or '' }}" data-description="{{ stock_item.name }}" {% if item.stock_item_id == stock_item.id %}selected{% endif %}>{{ stock_item.sku }} - {{ stock_item.name }}</option>
{% endfor %}
</select>
<select class="form-input item-warehouse-select text-sm" title="{{ _('Select Warehouse') }}">
<option value="">{{ _('None') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}" {% if item.warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
</div>
<div class="item-desc-wrap md:col-span-2 min-w-0 {% if not item.stock_item_id %}md:col-span-6{% endif %}">
<input type="text" name="item_description[]" placeholder="{{ _('Item description') }}" value="{{ item.description }}" class="w-full form-input item-description" data-calc-trigger>
</div>
<select class="md:col-span-2 min-w-0 form-input item-stock-select text-sm" title="{{ _('Select Stock Item') }}">
<option value="">{{ _('None') }}</option>
{% for stock_item in stock_items %}
<option value="{{ stock_item.id }}" data-price="{{ stock_item.default_price or 0 }}" data-unit="{{ stock_item.unit }}" data-description="{{ stock_item.name }}" {% if item.stock_item_id == stock_item.id %}selected{% endif %}>{{ stock_item.sku }} - {{ stock_item.name }}</option>
{% endfor %}
</select>
<select class="md:col-span-2 min-w-0 form-input item-warehouse-select text-sm" title="{{ _('Select Warehouse') }}">
<option value="">{{ _('None') }}</option>
{% for warehouse in warehouses %}
<option value="{{ warehouse.id }}" {% if item.warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
{% endfor %}
</select>
<input type="text" name="item_description[]" placeholder="{{ _('Item description') }}" value="{{ item.description }}" class="md:col-span-2 min-w-0 form-input item-description" data-calc-trigger>
<input type="number" name="item_quantity[]" placeholder="{{ _('Qty') }}" value="{{ item.quantity }}" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>
<input type="text" name="item_unit[]" placeholder="{{ _('Unit') }}" value="{{ item.unit or '' }}" class="md:col-span-1 min-w-0 form-input item-unit" placeholder="hrs, pcs, etc.">
<input type="number" name="item_price[]" placeholder="{{ _('Price') }}" value="{{ item.unit_price }}" step="0.01" min="0" class="md:col-span-2 min-w-0 form-input item-price" data-calc-trigger>
<div class="md:col-span-1 min-w-0 flex items-center font-medium item-total">{{ "%.2f"|format(item.total_amount) }}</div>
<button type="button" class="remove-item md:col-span-1 min-w-0 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove item') }}">
<i class="fas fa-trash"></i>
</button>
<input type="text" name="item_unit[]" placeholder="{{ _('Unit') }}" value="{{ item.unit or '' }}" class="md:col-span-1 min-w-0 form-input item-unit">
<input type="number" name="item_price[]" placeholder="{{ _('Price') }}" value="{{ item.unit_price }}" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-price" data-calc-trigger>
<div class="md:col-span-1 min-w-0 flex items-center justify-between gap-2">
<span class="font-medium item-total">{{ "%.2f"|format(item.total_amount) }}</span>
<button type="button" class="remove-item shrink-0 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove item') }}"><i class="fas fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<div id="items-subtotal" class="mt-3 p-3 bg-primary/5 rounded-lg border border-primary/20">
<div class="mt-3 p-3 bg-blue-50/30 dark:bg-blue-950/10 rounded-lg border border-blue-200/30 dark:border-blue-800/30">
<div class="flex justify-between items-center text-sm font-medium">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Subtotal') }}:</span>
<span class="text-lg font-bold text-primary"><span id="items-subtotal-amount">{{ "%.2f"|format(quote.subtotal) if quote.subtotal else '0.00' }}</span></span>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Line items subtotal') }}:</span>
<span class="text-lg font-bold text-blue-700 dark:text-blue-400"><span id="quote-items-section-total">0.00</span></span>
</div>
</div>
</div>
<!-- Costs / expenses (quote) -->
<div class="mb-8 mt-8 border-t border-border-light dark:border-border-dark pt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-receipt mr-2 text-amber-600"></i>{{ _('Costs') }}
<span id="quote-expenses-count" class="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full">0</span>
</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('One-off costs (e.g. travel, materials)') }}</p>
</div>
<button type="button" id="add-quote-expense" class="btn bg-amber-600 text-white hover:bg-amber-700 shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add cost') }}
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-2">{{ _('Title') }}</div>
<div class="md:col-span-3">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Category') }}</div>
<div class="md:col-span-2">{{ _('Amount') }}</div>
<div class="md:col-span-2">{{ _('Date') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
<div id="quote-expenses" class="space-y-2">
{% for item in quote.items if (item.line_kind or 'item') == 'expense' %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 quote-expense-row min-w-0 hover:shadow-sm transition">
<input type="hidden" name="qe_id[]" value="{{ item.id }}">
<div class="md:col-span-2 min-w-0"><input type="text" name="qe_title[]" class="form-input" placeholder="{{ _('Title') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
<div class="md:col-span-3 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0">
<select name="qe_category[]" class="form-input">
<option value="travel" {% if item.category == 'travel' %}selected{% endif %}>{{ _('Travel') }}</option>
<option value="meals" {% if item.category == 'meals' %}selected{% endif %}>{{ _('Meals') }}</option>
<option value="supplies" {% if item.category == 'supplies' %}selected{% endif %}>{{ _('Supplies') }}</option>
<option value="services" {% if item.category == 'services' %}selected{% endif %}>{{ _('Services') }}</option>
<option value="equipment" {% if item.category == 'equipment' %}selected{% endif %}>{{ _('Equipment') }}</option>
<option value="other" {% if (item.category or 'other') == 'other' %}selected{% endif %}>{{ _('Other') }}</option>
</select>
</div>
<div class="md:col-span-2 min-w-0"><input type="number" name="qe_amount[]" class="form-input qe-amount" step="0.01" min="0" value="{{ item.unit_price }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0"><input type="date" name="qe_date[]" class="form-input user-date-input text-sm" value="{{ item.line_date.strftime('%Y-%m-%d') if item.line_date else '' }}"></div>
<div class="md:col-span-1 min-w-0 flex items-center justify-center"><button type="button" class="remove-quote-expense bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove') }}"><i class="fas fa-trash"></i></button></div>
</div>
{% endfor %}
</div>
<div class="mt-3 p-3 bg-amber-50/30 dark:bg-amber-950/10 rounded-lg border border-amber-200/30 dark:border-amber-800/30">
<div class="flex justify-between items-center text-sm font-medium">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Costs subtotal') }}:</span>
<span class="text-lg font-bold text-amber-700 dark:text-amber-400"><span id="quote-expenses-section-total">0.00</span></span>
</div>
</div>
</div>
<!-- Extra goods -->
<div class="mb-8 mt-8 border-t border-border-light dark:border-border-dark pt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold flex items-center">
<i class="fas fa-box mr-2 text-emerald-600"></i>{{ _('Extra goods') }}
<span id="quote-goods-count" class="ml-2 px-2 py-0.5 text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-full">0</span>
</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Products, licenses, hardware') }}</p>
</div>
<button type="button" id="add-quote-good" class="btn bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm">
<i class="fas fa-plus mr-2"></i>{{ _('Add good') }}
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-2">{{ _('Name') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Category') }}</div>
<div class="md:col-span-2">{{ _('Qty') }}</div>
<div class="md:col-span-2">{{ _('Price') }}</div>
<div class="md:col-span-1">{{ _('SKU') }}</div>
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
</div>
<div id="quote-goods" class="space-y-2">
{% for item in quote.items if (item.line_kind or 'item') == 'good' %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/50 quote-good-row min-w-0 hover:shadow-sm transition">
<input type="hidden" name="qg_id[]" value="{{ item.id }}">
<div class="md:col-span-2 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _('Name') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0"><input type="text" name="qg_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0">
<select name="qg_category[]" class="form-input">
<option value="product" {% if item.category == 'product' %}selected{% endif %}>{{ _('Product') }}</option>
<option value="service" {% if item.category == 'service' %}selected{% endif %}>{{ _('Service') }}</option>
<option value="material" {% if item.category == 'material' %}selected{% endif %}>{{ _('Material') }}</option>
<option value="license" {% if item.category == 'license' %}selected{% endif %}>{{ _('License') }}</option>
<option value="other" {% if (item.category or 'other') == 'other' %}selected{% endif %}>{{ _('Other') }}</option>
</select>
</div>
<div class="md:col-span-2 min-w-0"><input type="number" name="qg_quantity[]" class="form-input qg-quantity" step="0.01" min="0" value="{{ item.quantity }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0"><input type="number" name="qg_unit_price[]" class="form-input qg-price" step="0.01" min="0" value="{{ item.unit_price }}" data-calc-trigger></div>
<div class="md:col-span-1 min-w-0"><input type="text" name="qg_sku[]" class="form-input text-xs" placeholder="{{ _('SKU') }}" value="{{ item.sku or '' }}"></div>
<div class="md:col-span-1 min-w-0 flex items-center justify-center"><button type="button" class="remove-quote-good bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove') }}"><i class="fas fa-trash"></i></button></div>
</div>
{% endfor %}
</div>
<div class="mt-3 p-3 bg-emerald-50/30 dark:bg-emerald-950/10 rounded-lg border border-emerald-200/30 dark:border-emerald-800/30">
<div class="flex justify-between items-center text-sm font-medium">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Goods subtotal') }}:</span>
<span class="text-lg font-bold text-emerald-700 dark:text-emerald-400"><span id="quote-goods-section-total">0.00</span></span>
</div>
</div>
</div>
<div class="mb-8 p-4 rounded-xl border border-border-light dark:border-border-dark bg-gray-50 dark:bg-gray-900/40">
<div class="flex justify-between items-center text-sm font-medium">
<span>{{ _('Subtotal (all quote lines)') }}</span>
<span class="text-xl font-bold text-primary"><span id="quote-all-lines-subtotal">0.00</span></span>
</div>
</div>
<!-- Financial Details Section -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4 pb-2 border-b border-border-light dark:border-border-dark flex items-center">
@@ -226,241 +341,5 @@
{% endblock %}
{% block scripts_extra %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const itemsContainer = document.getElementById('quote-items');
const addItemBtn = document.getElementById('add-item');
let itemIndex = 0;
function refreshQuoteItemReorderControls() {
const rows = Array.from(itemsContainer.querySelectorAll('.quote-item-row'));
rows.forEach((row, i) => {
const up = row.querySelector('.quote-item-move-up');
const down = row.querySelector('.quote-item-move-down');
if (up) up.disabled = i === 0;
if (down) down.disabled = i === rows.length - 1;
});
}
function moveQuoteItemRowUp(row) {
const prev = row.previousElementSibling;
if (prev && prev.classList.contains('quote-item-row')) {
itemsContainer.insertBefore(row, prev);
refreshQuoteItemReorderControls();
}
}
function moveQuoteItemRowDown(row) {
const next = row.nextElementSibling;
if (next && next.classList.contains('quote-item-row')) {
itemsContainer.insertBefore(next, row);
refreshQuoteItemReorderControls();
}
}
function wireQuoteItemReorderButtons(row) {
row.querySelector('.quote-item-move-up')?.addEventListener('click', function() {
moveQuoteItemRowUp(row);
});
row.querySelector('.quote-item-move-down')?.addEventListener('click', function() {
moveQuoteItemRowDown(row);
});
}
// Stock items and warehouses data
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
// Handle stock item selection
function setupStockItemHandlers() {
document.querySelectorAll('.item-stock-select').forEach(select => {
select.addEventListener('change', function() {
const row = this.closest('.quote-item-row');
const stockItemId = this.value;
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = stockItemId || '';
if (stockItemId && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
const unit = selectedOption.dataset.unit || '';
// Auto-populate fields
if (description && !row.querySelector('.item-description').value) {
row.querySelector('.item-description').value = description;
}
if (price > 0 && !row.querySelector('.item-price').value) {
row.querySelector('.item-price').value = price.toFixed(2);
}
if (unit && !row.querySelector('.item-unit').value) {
row.querySelector('.item-unit').value = unit;
}
calculateTotals();
}
});
});
}
// Add new item row
function addItemRow(item = null) {
const row = document.createElement('div');
row.className = 'grid grid-cols-1 md:grid-cols-[repeat(13,minmax(0,1fr))] gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row min-w-0 hover:shadow-sm transition';
// Build stock items dropdown
let stockItemsHtml = '<option value="">{{ _("None") }}</option>';
if (stockItems && Array.isArray(stockItems)) {
stockItems.forEach(stockItem => {
const price = stockItem.default_price || 0;
const unit = stockItem.unit || '';
const desc = stockItem.description || stockItem.name || '';
stockItemsHtml += '<option value="' + stockItem.id + '" data-price="' + price + '" data-unit="' + unit + '" data-description="' + desc.replace(/"/g, '&quot;') + '">' + stockItem.sku + ' - ' + stockItem.name + '</option>';
});
}
// Build warehouses dropdown
let warehousesHtml = '<option value="">{{ _("None") }}</option>';
if (warehouses && Array.isArray(warehouses)) {
warehouses.forEach(wh => {
warehousesHtml += '<option value="' + wh.id + '">' + wh.code + ' - ' + wh.name + '</option>';
});
}
// Translated strings
const placeholderDesc = '{{ _("Item description") }}';
const placeholderQty = '{{ _("Qty") }}';
const placeholderUnit = '{{ _("Unit") }}';
const placeholderPrice = '{{ _("Price") }}';
const removeTitle = '{{ _("Remove item") }}';
const moveUpTitle = '{{ _("Move up") }}';
const moveDownTitle = '{{ _("Move down") }}';
const reorderBtnClass = 'w-full px-2 py-1 text-xs rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed';
row.innerHTML =
'<input type="hidden" name="item_id[]" value="' + (item ? item.id : '') + '">' +
'<input type="hidden" name="item_stock_item_id[]" value="' + (item && item.stock_item_id ? item.stock_item_id : '') + '">' +
'<input type="hidden" name="item_warehouse_id[]" value="' + (item && item.warehouse_id ? item.warehouse_id : '') + '">' +
'<div class="md:col-span-1 flex flex-col gap-1 items-stretch justify-center min-w-0">' +
'<button type="button" class="quote-item-move-up ' + reorderBtnClass + '" title="' + moveUpTitle + '" aria-label="' + moveUpTitle + '"><i class="fas fa-chevron-up"></i></button>' +
'<button type="button" class="quote-item-move-down ' + reorderBtnClass + '" title="' + moveDownTitle + '" aria-label="' + moveDownTitle + '"><i class="fas fa-chevron-down"></i></button>' +
'</div>' +
'<select class="md:col-span-2 min-w-0 form-input item-stock-select text-sm">' + stockItemsHtml + '</select>' +
'<select class="md:col-span-2 min-w-0 form-input item-warehouse-select text-sm">' + warehousesHtml + '</select>' +
'<input type="text" name="item_description[]" placeholder="' + placeholderDesc + '" value="' + (item ? (item.description || '').replace(/"/g, '&quot;') : '') + '" class="md:col-span-2 min-w-0 form-input item-description" data-calc-trigger>' +
'<input type="number" name="item_quantity[]" placeholder="' + placeholderQty + '" value="' + (item ? item.quantity : '1') + '" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>' +
'<input type="text" name="item_unit[]" placeholder="' + placeholderUnit + '" value="' + (item ? (item.unit || '') : '') + '" class="md:col-span-1 min-w-0 form-input item-unit" placeholder="hrs, pcs, etc.">' +
'<input type="number" name="item_price[]" placeholder="' + placeholderPrice + '" value="' + (item ? item.unit_price : '') + '" step="0.01" min="0" class="md:col-span-2 min-w-0 form-input item-price" data-calc-trigger>' +
'<div class="md:col-span-1 min-w-0 flex items-center font-medium item-total">0.00</div>' +
'<button type="button" class="remove-item md:col-span-1 min-w-0 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="' + removeTitle + '">' +
'<i class="fas fa-trash"></i>' +
'</button>';
itemsContainer.appendChild(row);
// Setup handlers for new row
const stockSelect = row.querySelector('.item-stock-select');
stockSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
hiddenStockInput.value = this.value || '';
if (this.value && selectedOption) {
const price = parseFloat(selectedOption.dataset.price || 0);
const description = selectedOption.dataset.description || '';
const unit = selectedOption.dataset.unit || '';
if (description) row.querySelector('.item-description').value = description;
if (price > 0) row.querySelector('.item-price').value = price.toFixed(2);
if (unit) row.querySelector('.item-unit').value = unit;
calculateTotals();
}
});
const warehouseSelect = row.querySelector('.item-warehouse-select');
warehouseSelect.addEventListener('change', function() {
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
hiddenWarehouseInput.value = this.value || '';
});
// Add event listeners
row.querySelector('.remove-item').addEventListener('click', function() {
row.remove();
calculateTotals();
refreshQuoteItemReorderControls();
});
wireQuoteItemReorderButtons(row);
// Add calculation triggers
row.querySelectorAll('[data-calc-trigger]').forEach(input => {
input.addEventListener('input', calculateTotals);
});
setupStockItemHandlers();
itemIndex++;
calculateTotals();
refreshQuoteItemReorderControls();
}
// Initialize stock item handlers for existing rows
setupStockItemHandlers();
// Setup warehouse handlers
document.querySelectorAll('.item-warehouse-select').forEach(select => {
select.addEventListener('change', function() {
const row = this.closest('.quote-item-row');
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
hiddenWarehouseInput.value = this.value || '';
});
});
// Calculate totals
function calculateTotals() {
let itemsTotal = 0;
let itemsCount = 0;
document.querySelectorAll('.quote-item-row').forEach(row => {
const qty = parseFloat(row.querySelector('.item-quantity')?.value || 0);
const price = parseFloat(row.querySelector('.item-price')?.value || 0);
const total = qty * price;
if (qty > 0 && price > 0) {
itemsTotal += total;
itemsCount++;
}
// Update row total
const totalEl = row.querySelector('.item-total');
if (totalEl) {
totalEl.textContent = total.toFixed(2);
}
});
// Update subtotal
document.getElementById('items-subtotal-amount').textContent = itemsTotal.toFixed(2);
document.getElementById('items-count').textContent = itemsCount;
}
// Add item button
addItemBtn.addEventListener('click', function() {
addItemRow();
});
// Add event listeners to existing items
document.querySelectorAll('.quote-item-row').forEach(row => {
wireQuoteItemReorderButtons(row);
row.querySelector('.remove-item').addEventListener('click', function() {
row.remove();
calculateTotals();
refreshQuoteItemReorderControls();
});
row.querySelectorAll('[data-calc-trigger]').forEach(input => {
input.addEventListener('input', calculateTotals);
});
});
refreshQuoteItemReorderControls();
// Calculate on tax rate change
document.getElementById('tax_rate')?.addEventListener('input', calculateTotals);
// Initial calculation
calculateTotals();
});
</script>
{% include 'quotes/_edit_quote_form_scripts.html' %}
{% endblock %}
+5 -1
View File
@@ -101,7 +101,11 @@
<tbody>
{% for item in quote.items %}
<tr>
<td>{{ item.description|e }}</td>
<td>
{% if item.display_name %}{{ item.display_name|e }}{% if item.description and item.description != item.display_name and item.description != '-' %} — {{ item.description|e }}{% endif %}{% else %}{{ item.description|e }}{% endif %}
{% if item.line_kind == 'expense' and item.category %} ({{ item.category|e }}){% endif %}
{% if item.line_kind == 'good' and item.sku %} [{{ _('SKU') }}: {{ item.sku|e }}]{% endif %}
</td>
<td class="num">{{ '%.2f' % item.quantity }} {% if item.unit %}{{ item.unit }}{% endif %}</td>
<td class="num">{{ format_money(item.unit_price) }}</td>
<td class="num">{{ format_money(item.total_amount) }}</td>
+10 -1
View File
@@ -108,7 +108,16 @@
<tbody>
{% for item in quote.items %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-3">{{ item.description }}</td>
<td class="p-3">
{% if item.display_name %}
<div class="font-medium">{{ item.display_name }}</div>
{% if item.description and item.description != item.display_name and item.description != '-' %}<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ item.description }}</div>{% endif %}
{% else %}
{{ item.description }}
{% endif %}
{% if item.line_kind == 'expense' and item.category %}<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ item.category }}</div>{% endif %}
{% if item.line_kind == 'good' and item.sku %}<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('SKU') }}: {{ item.sku }}</div>{% endif %}
</td>
<td class="p-3">{{ "%.2f"|format(item.quantity) }} {% if item.unit %}{{ item.unit }}{% endif %}</td>
<td class="p-3">{{ "%.2f"|format(item.unit_price) }} {{ quote.currency_code }}</td>
<td class="p-3 font-medium">{{ "%.2f"|format(item.total_amount) }} {{ quote.currency_code }}</td>
+19 -1
View File
@@ -402,6 +402,24 @@ class InvoicePDFGeneratorFallback:
return story
def format_quote_item_description_for_pdf(item) -> str:
"""Label for a quote line including expense/goods metadata (issue #585)."""
dn = getattr(item, "display_name", None)
desc = (getattr(item, "description", None) or "") or ""
lk = (getattr(item, "line_kind", None) or "item") or "item"
if dn:
text = str(dn)
if desc and str(desc) not in (str(dn), "-"):
text = f"{text}{desc}"
else:
text = str(desc)
if lk == "expense" and getattr(item, "category", None):
text = f"{text} ({item.category})"
if lk == "good" and getattr(item, "sku", None):
text = f"{text} [SKU: {item.sku}]"
return text
class QuotePDFGeneratorFallback:
"""Generate PDF quotes with company branding using ReportLab"""
@@ -559,7 +577,7 @@ class QuotePDFGeneratorFallback:
for item in self.quote.items:
data.append(
[
item.description,
format_quote_item_description_for_pdf(item),
str(item.quantity),
self._format_currency(item.unit_price),
self._format_currency(item.total_amount),
@@ -159,8 +159,12 @@ This document outlines the complete implementation plan for adding a comprehensi
- Add `stock_item_id` (Integer, ForeignKey -> stock_items.id, Optional, Indexed)
- Add `warehouse_id` (Integer, ForeignKey -> warehouses.id, Optional) - Preferred warehouse
- Add `is_stock_item` (Boolean, Default: False) - Flag to indicate if linked to inventory
- Add `line_kind` (String(20), not null, default `item`) — discriminates **item**, **expense** (costs), and **good** (extra goods) on a single `quote_items` table (see migration `147_add_quote_item_line_kind.py`)
- Optional metadata for non-item lines (nullable): `display_name` (expense title / good name), `category`, `line_date` (expense date), `sku` (good SKU)
**Behavior**:
- Quote create/edit mirrors invoice billing: **line items** (manual or from stock), **costs** (expenses), and **extra goods**. Stock item and warehouse selectors appear only for **item** lines that are explicitly linked to inventory—not on every row.
- Inventory fields apply only when `line_kind == "item"` and a stock line is chosen; `expense` and `good` rows clear `stock_item_id` / `warehouse_id`.
- When quote item is linked to stock item, show current available quantity
- Allow reserving stock when quote is sent (optional)
- Auto-reserve on quote acceptance (if enabled)
@@ -518,6 +522,9 @@ inventory_permissions = [
2. `invoice_items` - Add `stock_item_id`, `warehouse_id`, `is_stock_item`
3. `extra_goods` - Add `stock_item_id`
**Follow-up (quote line kinds, issue #585)** — migration `147_add_quote_item_line_kind.py`:
- `quote_items`: `line_kind`, `display_name`, `category`, `line_date`, `sku`
**Indexes**:
- Index on `stock_items.sku`
- Index on `stock_items.barcode`
@@ -555,6 +562,8 @@ inventory_permissions = [
- Color coding for low stock
### 8.4 Add Stock Item to Quote/Invoice
- **Quotes:** Use the line-items section; choose **from stock** on a row to show the product selector and warehouse. Costs and extra goods sections do not offer stock linkage.
- **Invoices:** Existing time/stock/expense/goods split remains the reference UX.
- Product selector with search/filter
- Show available quantity per warehouse
- Select warehouse for reservation
@@ -0,0 +1,71 @@
"""Add line_kind and optional fields to quote_items (issue #585)
Revision ID: 147_add_quote_item_line_kind
Revises: 146_add_quote_item_position
Create Date: 2026-04-12
Idempotent: safe if columns already exist.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect, text
revision = "147_add_quote_item_line_kind"
down_revision = "146_add_quote_item_position"
branch_labels = None
depends_on = None
def _has_table(inspector, name: str) -> bool:
try:
return name in inspector.get_table_names()
except Exception:
return False
def _has_column(inspector, table_name: str, column_name: str) -> bool:
try:
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
except Exception:
return False
def upgrade():
bind = op.get_bind()
inspector = inspect(bind)
if not _has_table(inspector, "quote_items"):
return
if not _has_column(inspector, "quote_items", "line_kind"):
op.add_column(
"quote_items",
sa.Column("line_kind", sa.String(length=20), nullable=False, server_default="item"),
)
if not _has_column(inspector, "quote_items", "display_name"):
op.add_column("quote_items", sa.Column("display_name", sa.String(length=200), nullable=True))
if not _has_column(inspector, "quote_items", "category"):
op.add_column("quote_items", sa.Column("category", sa.String(length=50), nullable=True))
if not _has_column(inspector, "quote_items", "line_date"):
op.add_column("quote_items", sa.Column("line_date", sa.Date(), nullable=True))
if not _has_column(inspector, "quote_items", "sku"):
op.add_column("quote_items", sa.Column("sku", sa.String(length=100), nullable=True))
connection = op.get_bind()
connection.execute(text("UPDATE quote_items SET line_kind = 'item' WHERE line_kind IS NULL OR line_kind = ''"))
def downgrade():
bind = op.get_bind()
for col in ("sku", "line_date", "category", "display_name", "line_kind"):
inspector = inspect(bind)
if not _has_table(inspector, "quote_items"):
return
if _has_column(inspector, "quote_items", col):
op.drop_column("quote_items", col)
@@ -101,6 +101,7 @@ class TestQuoteInventoryIntegration:
"item_quantity[]": ["5"],
"item_price[]": ["25.00"],
"item_unit[]": ["pcs"],
"item_line_source[]": ["stock"],
"item_stock_item_id[]": [str(test_stock_item.id)],
"item_warehouse_id[]": [str(test_warehouse.id)],
},
@@ -121,6 +122,47 @@ class TestQuoteInventoryIntegration:
assert quote_item.warehouse_id == test_warehouse.id
assert quote_item.is_stock_item is True
def test_quote_create_expense_and_good_lines(self, client, test_user, test_client):
"""Quote form posts costs + extra goods as separate line_kind rows (#585)."""
with client.session_transaction() as sess:
sess["_user_id"] = str(test_user.id)
response = client.post(
url_for("quotes.create_quote"),
data={
"client_id": test_client.id,
"title": "Mixed quote lines",
"tax_rate": "0",
"currency_code": "EUR",
"qe_title[]": ["Trip"],
"qe_description[]": ["Client visit"],
"qe_category[]": ["travel"],
"qe_amount[]": ["99.50"],
"qe_date[]": ["2026-04-01"],
"qg_name[]": ["License pack"],
"qg_description[]": [""],
"qg_category[]": ["license"],
"qg_quantity[]": ["2"],
"qg_unit_price[]": ["25"],
"qg_sku[]": ["L-1"],
},
follow_redirects=True,
)
assert response.status_code == 200
quote = Quote.query.filter_by(title="Mixed quote lines").first()
assert quote is not None
kinds = {i.line_kind for i in quote.items}
assert "expense" in kinds
assert "good" in kinds
exp = next(i for i in quote.items if i.line_kind == "expense")
assert exp.display_name == "Trip"
assert exp.unit_price == Decimal("99.50")
assert exp.quantity == Decimal("1")
good = next(i for i in quote.items if i.line_kind == "good")
assert good.display_name == "License pack"
assert good.sku == "L-1"
assert good.total_amount == Decimal("50.00")
def test_quote_send_reserves_stock(
self, client, test_user, test_client, test_stock_item, test_warehouse, test_stock_with_quantity
):