mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-12 23:39:17 -05:00
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:
+48
-5
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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, '"');
|
||||
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>
|
||||
@@ -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, '"') + '">' + 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, '"') : '') + '" 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
@@ -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, '"') + '">' + 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, '"') : '') + '" 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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user