feat: Phase 2A — product cost tracking
- Products: add cost_simple (flat €) and cost_breakdown (JSON line items) - OrderItems: add unit_cost snapshot written at time of order creation - Snapshot logic: breakdown sum takes priority over cost_simple; NULL if neither set - Product schema: serialize/deserialize cost_breakdown as JSON; expose in API - Product performance report: add trackable_profit, uncosted_revenue, has_gap per product - Manager UI: cost section in product edit form (simple / detailed toggle with live margin %) - Products list: show margin % or cost-error badge per product - Product performance report: profit column + gap warning banner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -262,6 +262,10 @@ def _run_migrations():
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)""",
|
)""",
|
||||||
|
# Phase 2A — product cost tracking
|
||||||
|
"ALTER TABLE products ADD COLUMN cost_simple REAL",
|
||||||
|
"ALTER TABLE products ADD COLUMN cost_breakdown TEXT",
|
||||||
|
"ALTER TABLE order_items ADD COLUMN unit_cost REAL",
|
||||||
]
|
]
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ class OrderItem(Base):
|
|||||||
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
||||||
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
|
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
|
||||||
|
|
||||||
|
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
|
||||||
|
unit_cost = Column(Float, nullable=True)
|
||||||
|
|
||||||
order = relationship("Order", back_populates="items")
|
order = relationship("Order", back_populates="items")
|
||||||
product = relationship("Product", back_populates="order_items")
|
product = relationship("Product", back_populates="order_items")
|
||||||
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ class Product(Base):
|
|||||||
digital_discount = Column(Float, default=0.0, nullable=False)
|
digital_discount = Column(Float, default=0.0, nullable=False)
|
||||||
digital_image_url = Column(String, nullable=True)
|
digital_image_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Phase 2A — cost tracking
|
||||||
|
cost_simple = Column(Float, nullable=True)
|
||||||
|
cost_breakdown = Column(Text, nullable=True) # JSON: [{"label": str, "amount": float}]
|
||||||
|
|
||||||
category = relationship("Category", back_populates="products")
|
category = relationship("Category", back_populates="products")
|
||||||
printer_zone = relationship("Printer", back_populates="products")
|
printer_zone = relationship("Printer", back_populates="products")
|
||||||
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
||||||
|
|||||||
@@ -270,12 +270,25 @@ def add_items(
|
|||||||
(o.price_delta or o.extra_cost or 0.0)
|
(o.price_delta or o.extra_cost or 0.0)
|
||||||
for o in (item_in.selected_options or [])
|
for o in (item_in.selected_options or [])
|
||||||
)
|
)
|
||||||
|
# Phase 2A: cost snapshot — breakdown takes priority over simple cost
|
||||||
|
unit_cost = None
|
||||||
|
if product.cost_breakdown:
|
||||||
|
try:
|
||||||
|
entries = json.loads(product.cost_breakdown)
|
||||||
|
total = sum(e.get("amount", 0.0) for e in entries if isinstance(e, dict))
|
||||||
|
if total > 0:
|
||||||
|
unit_cost = total
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if unit_cost is None and product.cost_simple:
|
||||||
|
unit_cost = product.cost_simple
|
||||||
item = OrderItem(
|
item = OrderItem(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
product_id=item_in.product_id,
|
product_id=item_in.product_id,
|
||||||
added_by=user.id,
|
added_by=user.id,
|
||||||
quantity=item_in.quantity,
|
quantity=item_in.quantity,
|
||||||
unit_price=product.base_price + extra_cost,
|
unit_price=product.base_price + extra_cost,
|
||||||
|
unit_cost=unit_cost,
|
||||||
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
|
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
|
||||||
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
||||||
notes=item_in.notes,
|
notes=item_in.notes,
|
||||||
|
|||||||
@@ -235,9 +235,11 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
|||||||
|
|
||||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
|
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"})
|
||||||
if data.get("sort_order") == 0:
|
if data.get("sort_order") == 0:
|
||||||
data["sort_order"] = db.query(Product).count()
|
data["sort_order"] = db.query(Product).count()
|
||||||
|
if body.cost_breakdown is not None:
|
||||||
|
data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||||
product = Product(**data)
|
product = Product(**data)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -277,8 +279,18 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
|||||||
product = db.query(Product).filter(Product.id == product_id).first()
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
|
scalar_fields = body.model_dump(
|
||||||
|
exclude_none=True,
|
||||||
|
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"},
|
||||||
|
)
|
||||||
|
for field, value in scalar_fields.items():
|
||||||
setattr(product, field, value)
|
setattr(product, field, value)
|
||||||
|
# cost_breakdown is a list of objects — serialize to JSON for storage
|
||||||
|
if body.cost_breakdown is not None:
|
||||||
|
product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||||
|
elif "cost_breakdown" in body.model_fields_set:
|
||||||
|
# explicitly set to null — clear it
|
||||||
|
product.cost_breakdown = None
|
||||||
if body.quick_options is not None:
|
if body.quick_options is not None:
|
||||||
_replace_quick_options(db, product, body.quick_options)
|
_replace_quick_options(db, product, body.quick_options)
|
||||||
if body.options is not None:
|
if body.options is not None:
|
||||||
|
|||||||
@@ -950,16 +950,29 @@ def product_performance(
|
|||||||
"category_id": product.category_id if product else None,
|
"category_id": product.category_id if product else None,
|
||||||
"qty_sold": 0,
|
"qty_sold": 0,
|
||||||
"revenue": 0.0,
|
"revenue": 0.0,
|
||||||
|
"trackable_profit": 0.0,
|
||||||
|
"uncosted_revenue": 0.0,
|
||||||
|
"uncosted_items": 0,
|
||||||
"order_ids": set(),
|
"order_ids": set(),
|
||||||
}
|
}
|
||||||
summary[pid]["qty_sold"] += item.quantity
|
qty = item.quantity
|
||||||
summary[pid]["revenue"] += item.unit_price * item.quantity
|
revenue = item.unit_price * qty
|
||||||
|
summary[pid]["qty_sold"] += qty
|
||||||
|
summary[pid]["revenue"] += revenue
|
||||||
summary[pid]["order_ids"].add(item.order_id)
|
summary[pid]["order_ids"].add(item.order_id)
|
||||||
|
if item.unit_cost is not None:
|
||||||
|
summary[pid]["trackable_profit"] += revenue - (item.unit_cost * qty)
|
||||||
|
else:
|
||||||
|
summary[pid]["uncosted_revenue"] += revenue
|
||||||
|
summary[pid]["uncosted_items"] += qty
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for entry in summary.values():
|
for entry in summary.values():
|
||||||
entry["order_count"] = len(entry.pop("order_ids"))
|
entry["order_count"] = len(entry.pop("order_ids"))
|
||||||
entry["revenue"] = round(entry["revenue"], 2)
|
entry["revenue"] = round(entry["revenue"], 2)
|
||||||
|
entry["trackable_profit"] = round(entry["trackable_profit"], 2)
|
||||||
|
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
|
||||||
|
entry["has_gap"] = entry["uncosted_items"] > 0
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
|
|
||||||
result.sort(key=lambda x: x["qty_sold"], reverse=True)
|
result.sort(key=lambda x: x["qty_sold"], reverse=True)
|
||||||
|
|||||||
@@ -255,6 +255,11 @@ class PreferenceSetOut(BaseModel):
|
|||||||
|
|
||||||
# ── Products ──────────────────────────────────────────────────────────────────
|
# ── Products ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CostBreakdownItem(BaseModel):
|
||||||
|
label: str
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
|
||||||
class ProductBase(BaseModel):
|
class ProductBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
@@ -272,6 +277,9 @@ class ProductBase(BaseModel):
|
|||||||
digital_price: Optional[float] = None
|
digital_price: Optional[float] = None
|
||||||
digital_discount: Optional[float] = None
|
digital_discount: Optional[float] = None
|
||||||
digital_image_url: Optional[str] = None
|
digital_image_url: Optional[str] = None
|
||||||
|
# Phase 2A — cost tracking
|
||||||
|
cost_simple: Optional[float] = None
|
||||||
|
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
@@ -302,6 +310,9 @@ class ProductUpdate(BaseModel):
|
|||||||
digital_price: Optional[float] = None
|
digital_price: Optional[float] = None
|
||||||
digital_discount: Optional[float] = None
|
digital_discount: Optional[float] = None
|
||||||
digital_image_url: Optional[str] = None
|
digital_image_url: Optional[str] = None
|
||||||
|
# Phase 2A — cost tracking
|
||||||
|
cost_simple: Optional[float] = None
|
||||||
|
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||||
|
|
||||||
|
|
||||||
class ProductReorderItem(BaseModel):
|
class ProductReorderItem(BaseModel):
|
||||||
@@ -318,3 +329,42 @@ class ProductOut(ProductBase):
|
|||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
@model_validator(mode='before')
|
||||||
|
@classmethod
|
||||||
|
def parse_cost_breakdown(cls, data: Any) -> Any:
|
||||||
|
if hasattr(data, 'cost_breakdown'):
|
||||||
|
raw = data.cost_breakdown
|
||||||
|
parsed = None
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
# Return a plain dict so other validators still see all fields
|
||||||
|
return {
|
||||||
|
'id': data.id,
|
||||||
|
'name': data.name,
|
||||||
|
'description': data.description,
|
||||||
|
'category_id': data.category_id,
|
||||||
|
'base_price': data.base_price,
|
||||||
|
'is_available': data.is_available,
|
||||||
|
'lifecycle_status': data.lifecycle_status,
|
||||||
|
'printer_zone_id': data.printer_zone_id,
|
||||||
|
'sort_order': data.sort_order,
|
||||||
|
'image_url': data.image_url,
|
||||||
|
'digital_visible': bool(data.digital_visible),
|
||||||
|
'digital_available': bool(data.digital_available),
|
||||||
|
'digital_name': data.digital_name,
|
||||||
|
'digital_description': data.digital_description,
|
||||||
|
'digital_price': data.digital_price,
|
||||||
|
'digital_discount': data.digital_discount,
|
||||||
|
'digital_image_url': data.digital_image_url,
|
||||||
|
'cost_simple': data.cost_simple,
|
||||||
|
'cost_breakdown': parsed,
|
||||||
|
'quick_options': list(data.quick_options),
|
||||||
|
'options': list(data.options),
|
||||||
|
'ingredients': list(data.ingredients),
|
||||||
|
'preference_sets': list(data.preference_sets),
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|||||||
@@ -146,6 +146,12 @@ function buildFormFromProduct(product) {
|
|||||||
digital_image_url: product.digital_image_url ?? '',
|
digital_image_url: product.digital_image_url ?? '',
|
||||||
digital_price: product.digital_price ?? '',
|
digital_price: product.digital_price ?? '',
|
||||||
digital_discount: product.digital_discount ?? '',
|
digital_discount: product.digital_discount ?? '',
|
||||||
|
// Phase 2A — cost tracking
|
||||||
|
cost_mode: product.cost_breakdown?.length ? 'detailed' : 'simple',
|
||||||
|
cost_simple: product.cost_simple ?? '',
|
||||||
|
cost_breakdown: product.cost_breakdown?.length
|
||||||
|
? product.cost_breakdown.map(e => ({ label: e.label, amount: e.amount }))
|
||||||
|
: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +445,11 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
|||||||
digital_image_url: form.digital_image_url || null,
|
digital_image_url: form.digital_image_url || null,
|
||||||
digital_price: form.digital_price !== '' ? parseFloat(form.digital_price) : null,
|
digital_price: form.digital_price !== '' ? parseFloat(form.digital_price) : null,
|
||||||
digital_discount: form.digital_discount !== '' ? parseFloat(form.digital_discount) : null,
|
digital_discount: form.digital_discount !== '' ? parseFloat(form.digital_discount) : null,
|
||||||
|
// Phase 2A — cost tracking
|
||||||
|
cost_simple: form.cost_mode === 'simple' && form.cost_simple !== '' ? parseFloat(form.cost_simple) : null,
|
||||||
|
cost_breakdown: form.cost_mode === 'detailed' && form.cost_breakdown.length > 0
|
||||||
|
? form.cost_breakdown.map(e => ({ label: e.label, amount: parseFloat(e.amount) || 0 }))
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +535,75 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
|||||||
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
|
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phase 2A — Cost section */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5 bg-gray-50 border-b border-gray-200">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Κόστος</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['simple', 'detailed'].map(mode => (
|
||||||
|
<button key={mode} type="button"
|
||||||
|
onClick={() => setField('cost_mode', mode)}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||||
|
form.cost_mode === mode
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-gray-500 hover:bg-gray-100'
|
||||||
|
}`}>
|
||||||
|
{mode === 'simple' ? 'Απλό' : 'Ανάλυση'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{form.cost_mode === 'simple' ? (
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs">Κόστος ανά μονάδα (€)</label>
|
||||||
|
<PriceInput value={form.cost_simple} onChange={v => setField('cost_simple', v)} className="w-full" />
|
||||||
|
{form.cost_simple !== '' && parseFloat(form.base_price) > 0 && parseFloat(form.cost_simple) > 0 && (() => {
|
||||||
|
const margin = ((parseFloat(form.base_price) - parseFloat(form.cost_simple)) / parseFloat(form.base_price) * 100)
|
||||||
|
const isError = parseFloat(form.cost_simple) > parseFloat(form.base_price)
|
||||||
|
return (
|
||||||
|
<p className={`text-xs mt-1 font-medium ${isError ? 'text-red-500' : 'text-green-600'}`}>
|
||||||
|
{isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{form.cost_breakdown.map((entry, bi) => (
|
||||||
|
<div key={bi} className="flex gap-2 items-center">
|
||||||
|
<input className="input flex-1 text-sm" placeholder="π.χ. Γάλα"
|
||||||
|
value={entry.label}
|
||||||
|
onChange={e => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, label: e.target.value } : x) }))} />
|
||||||
|
<PriceInput value={entry.amount}
|
||||||
|
onChange={v => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, amount: v } : x) }))}
|
||||||
|
className="w-28" />
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.filter((_, i) => i !== bi) }))}
|
||||||
|
className="text-gray-400 hover:text-red-500 px-1.5 text-sm shrink-0">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
|
||||||
|
className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους</button>
|
||||||
|
{form.cost_breakdown.length > 0 && (() => {
|
||||||
|
const total = form.cost_breakdown.reduce((s, e) => s + (parseFloat(e.amount) || 0), 0)
|
||||||
|
const price = parseFloat(form.base_price) || 0
|
||||||
|
const isError = total > price && price > 0
|
||||||
|
const margin = price > 0 ? ((price - total) / price * 100) : null
|
||||||
|
return (
|
||||||
|
<div className={`text-xs font-medium ${isError ? 'text-red-500' : 'text-gray-600'}`}>
|
||||||
|
Σύνολο: €{total.toFixed(2)}
|
||||||
|
{margin !== null && !isError && <span className="text-green-600 ml-2">({margin.toFixed(1)}% περιθώριο)</span>}
|
||||||
|
{isError && <span className="ml-1">— Κόστος > Τιμή!</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Κατηγορία</label>
|
<label className="label">Κατηγορία</label>
|
||||||
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
|
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
|
||||||
|
|||||||
@@ -845,10 +845,29 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price + cost/margin */}
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', marginRight: 4, whiteSpace: 'nowrap' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginRight: 4, flexShrink: 0 }}>
|
||||||
|
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', whiteSpace: 'nowrap' }}>
|
||||||
€{parseFloat(p.base_price).toFixed(2)}
|
€{parseFloat(p.base_price).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const effectiveCost = p.cost_breakdown?.length
|
||||||
|
? p.cost_breakdown.reduce((s, e) => s + (e.amount || 0), 0)
|
||||||
|
: (p.cost_simple ?? null)
|
||||||
|
if (effectiveCost === null) return null
|
||||||
|
const price = parseFloat(p.base_price)
|
||||||
|
const isError = effectiveCost > price
|
||||||
|
const margin = price > 0 ? ((price - effectiveCost) / price * 100) : null
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
|
||||||
|
color: isError ? '#ef4444' : '#16a34a',
|
||||||
|
}}>
|
||||||
|
{isError ? '⚠ κόστος > τιμή' : margin !== null ? `${margin.toFixed(0)}% margin` : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -350,29 +350,87 @@ export default function ProductPerformance() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Panel title="Όλα τα Προϊόντα" subtitle={`${products.length} προϊόντα με πωλήσεις`} padded={false}>
|
{(() => {
|
||||||
|
const totalProfit = products.reduce((s, p) => s + (p.trackable_profit || 0), 0)
|
||||||
|
const totalUncostedRev = products.reduce((s, p) => s + (p.uncosted_revenue || 0), 0)
|
||||||
|
const hasGap = products.some(p => p.has_gap)
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Όλα τα Προϊόντα"
|
||||||
|
subtitle={`${products.length} προϊόντα με πωλήσεις`}
|
||||||
|
padded={false}
|
||||||
|
>
|
||||||
|
{hasGap && (
|
||||||
|
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
|
||||||
|
<span className="shrink-0 font-bold">⚠</span>
|
||||||
|
<span>
|
||||||
|
Μερικά προϊόντα δεν έχουν κόστος — το κέρδος που φαίνεται είναι μερικό.
|
||||||
|
Ορισμένα έσοδα <strong>{fmtEUR(totalUncostedRev)}</strong> δεν συμπεριλαμβάνονται στο κέρδος.{' '}
|
||||||
|
<a href="/management?tab=products" className="underline hover:text-amber-900">Ορισμός κόστους προϊόντων →</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
||||||
) : (
|
) : (
|
||||||
<DataTable>
|
<DataTable>
|
||||||
<THead><TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH><TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH></THead>
|
<THead>
|
||||||
|
<TH>Προϊόν</TH>
|
||||||
|
<TH align="right">Τεμάχια</TH>
|
||||||
|
<TH align="right">Έσοδα</TH>
|
||||||
|
<TH align="right">Κέρδος</TH>
|
||||||
|
<TH align="right">% Συνόλου</TH>
|
||||||
|
<TH align="right">Παραγγελίες</TH>
|
||||||
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{products.map(p => {
|
{products.map(p => {
|
||||||
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
|
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
|
||||||
|
const margin = p.revenue > 0 && !p.has_gap
|
||||||
|
? ((p.trackable_profit / p.revenue) * 100).toFixed(0) + '%'
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<TR key={p.product_id} striped>
|
<TR key={p.product_id} striped>
|
||||||
<TD className="font-medium text-slate-900">{p.product_name}</TD>
|
<TD className="font-medium text-slate-900">{p.product_name}</TD>
|
||||||
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
|
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{p.has_gap ? (
|
||||||
|
<span className="text-amber-500 text-[11px]">⚠ χωρίς κόστος</span>
|
||||||
|
) : (
|
||||||
|
<span className={p.trackable_profit >= 0 ? 'text-green-700' : 'text-red-600'}>
|
||||||
|
{fmtEUR(p.trackable_profit)}
|
||||||
|
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
<TD mono align="right">{pct.toFixed(1)}%</TD>
|
<TD mono align="right">{pct.toFixed(1)}%</TD>
|
||||||
<TD mono align="right">{fmtNum(p.order_count)}</TD>
|
<TD mono align="right">{fmtNum(p.order_count)}</TD>
|
||||||
</TR>
|
</TR>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
{products.length > 1 && (
|
||||||
|
<tfoot>
|
||||||
|
<TR>
|
||||||
|
<TD className="font-semibold text-slate-700" colSpan={2}>Σύνολο</TD>
|
||||||
|
<TD mono align="right" className="font-bold">{fmtEUR(totalRev)}</TD>
|
||||||
|
<TD mono align="right" className="font-bold">
|
||||||
|
{hasGap ? (
|
||||||
|
<span className="text-amber-600">{fmtEUR(totalProfit)} {' '}<span className="text-[10px]">+χωρίς κόστος</span></span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-700">{fmtEUR(totalProfit)}</span>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right" className="font-bold">100%</TD>
|
||||||
|
<TD />
|
||||||
|
</TR>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user