diff --git a/backend/main.py b/backend/main.py index af55333..40255cf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routers import eggs, flock, feed, stats +from routers import eggs, flock, feed, stats, other app = FastAPI(title="Eggtracker API") @@ -16,6 +16,7 @@ app.add_middleware( app.include_router(eggs.router) app.include_router(flock.router) app.include_router(feed.router) +app.include_router(other.router) app.include_router(stats.router) diff --git a/backend/models.py b/backend/models.py index 186356f..bf45591 100644 --- a/backend/models.py +++ b/backend/models.py @@ -33,3 +33,13 @@ class FeedPurchase(Base): price_per_bag: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) notes: Mapped[str] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + +class OtherPurchase(Base): + __tablename__ = "other_purchases" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + total: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + notes: Mapped[str] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/routers/other.py b/backend/routers/other.py new file mode 100644 index 0000000..dedb7a1 --- /dev/null +++ b/backend/routers/other.py @@ -0,0 +1,59 @@ +from datetime import date +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from database import get_db +from models import OtherPurchase +from schemas import OtherPurchaseCreate, OtherPurchaseUpdate, OtherPurchaseOut + +router = APIRouter(prefix="/api/other", tags=["other"]) + + +@router.get("", response_model=list[OtherPurchaseOut]) +def list_other_purchases( + start: Optional[date] = None, + end: Optional[date] = None, + db: Session = Depends(get_db), +): + q = select(OtherPurchase).order_by(OtherPurchase.date.desc()) + if start: + q = q.where(OtherPurchase.date >= start) + if end: + q = q.where(OtherPurchase.date <= end) + return db.scalars(q).all() + + +@router.post("", response_model=OtherPurchaseOut, status_code=201) +def create_other_purchase(body: OtherPurchaseCreate, db: Session = Depends(get_db)): + record = OtherPurchase(**body.model_dump()) + db.add(record) + db.commit() + db.refresh(record) + return record + + +@router.put("/{record_id}", response_model=OtherPurchaseOut) +def update_other_purchase( + record_id: int, + body: OtherPurchaseUpdate, + db: Session = Depends(get_db), +): + record = db.get(OtherPurchase, record_id) + if not record: + raise HTTPException(status_code=404, detail="Record not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(record, field, value) + db.commit() + db.refresh(record) + return record + + +@router.delete("/{record_id}", status_code=204) +def delete_other_purchase(record_id: int, db: Session = Depends(get_db)): + record = db.get(OtherPurchase, record_id) + if not record: + raise HTTPException(status_code=404, detail="Record not found") + db.delete(record) + db.commit() diff --git a/backend/routers/stats.py b/backend/routers/stats.py index eb3c401..f3e7933 100644 --- a/backend/routers/stats.py +++ b/backend/routers/stats.py @@ -6,7 +6,7 @@ from sqlalchemy import select, func from sqlalchemy.orm import Session from database import get_db -from models import EggCollection, FlockHistory, FeedPurchase +from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase from schemas import DashboardStats, BudgetStats, MonthlySummary router = APIRouter(prefix="/api/stats", tags=["stats"]) @@ -65,6 +65,15 @@ def _total_feed_cost(db: Session, start: date | None = None, end: date | None = return db.scalar(q) +def _total_other_cost(db: Session, start: date | None = None, end: date | None = None): + q = select(func.coalesce(func.sum(OtherPurchase.total), 0)) + if start: + q = q.where(OtherPurchase.date >= start) + if end: + q = q.where(OtherPurchase.date <= end) + return db.scalar(q) + + @router.get("/dashboard", response_model=DashboardStats) def dashboard_stats(db: Session = Depends(get_db)): today = date.today() @@ -132,6 +141,18 @@ def monthly_stats(db: Session = Depends(get_db)): feed_map = {(r.year, r.month): r.feed_cost for r in feed_rows} + # Monthly other costs + other_rows = db.execute( + select( + func.year(OtherPurchase.date).label('year'), + func.month(OtherPurchase.date).label('month'), + func.sum(OtherPurchase.total).label('other_cost'), + ) + .group_by(func.year(OtherPurchase.date), func.month(OtherPurchase.date)) + ).all() + + other_map = {(r.year, r.month): r.other_cost for r in other_rows} + results = [] for row in egg_rows: y, m = int(row.year), int(row.month) @@ -152,9 +173,13 @@ def monthly_stats(db: Session = Depends(get_db)): avg_per_day = round(total_eggs / days_logged, 2) if days_logged else None avg_per_hen = round(avg_per_day / flock, 3) if (avg_per_day and flock) else None - raw_feed_cost = feed_map.get((y, m)) - feed_cost = round(Decimal(str(raw_feed_cost)), 2) if raw_feed_cost else None - cpe = round(Decimal(str(raw_feed_cost)) / Decimal(total_eggs), 4) if (raw_feed_cost and total_eggs) else None + raw_feed_cost = feed_map.get((y, m)) + raw_other_cost = other_map.get((y, m)) + feed_cost = round(Decimal(str(raw_feed_cost)), 2) if raw_feed_cost else None + other_cost = round(Decimal(str(raw_other_cost)), 2) if raw_other_cost else None + + raw_total_cost = (raw_feed_cost or 0) + (raw_other_cost or 0) + cpe = round(Decimal(str(raw_total_cost)) / Decimal(total_eggs), 4) if (raw_total_cost and total_eggs) else None cpd = round(cpe * 12, 4) if cpe else None results.append(MonthlySummary( @@ -167,6 +192,7 @@ def monthly_stats(db: Session = Depends(get_db)): flock_at_month_end=flock, avg_eggs_per_hen_per_day=avg_per_hen, feed_cost=feed_cost, + other_cost=other_cost, cost_per_egg=cpe, cost_per_dozen=cpd, )) @@ -179,10 +205,12 @@ def budget_stats(db: Session = Depends(get_db)): today = date.today() start_30d = today - timedelta(days=30) - total_cost = _total_feed_cost(db) - total_cost_30d = _total_feed_cost(db, start=start_30d) - total_eggs = _total_eggs(db) - total_eggs_30d = _total_eggs(db, start=start_30d) + total_feed_cost = _total_feed_cost(db) + total_feed_cost_30d = _total_feed_cost(db, start=start_30d) + total_other_cost = _total_other_cost(db) + total_other_cost_30d = _total_other_cost(db, start=start_30d) + total_eggs = _total_eggs(db) + total_eggs_30d = _total_eggs(db, start=start_30d) def cost_per_egg(cost, eggs): if not eggs or not cost: @@ -192,12 +220,17 @@ def budget_stats(db: Session = Depends(get_db)): def cost_per_dozen(cpe): return round(cpe * 12, 4) if cpe else None - cpe = cost_per_egg(total_cost, total_eggs) - cpe_30d = cost_per_egg(total_cost_30d, total_eggs_30d) + combined_cost = total_feed_cost + total_other_cost + combined_cost_30d = total_feed_cost_30d + total_other_cost_30d + + cpe = cost_per_egg(combined_cost, total_eggs) + cpe_30d = cost_per_egg(combined_cost_30d, total_eggs_30d) return BudgetStats( - total_feed_cost=round(Decimal(str(total_cost)), 2) if total_cost else None, - total_feed_cost_30d=round(Decimal(str(total_cost_30d)), 2) if total_cost_30d else None, + total_feed_cost=round(Decimal(str(total_feed_cost)), 2) if total_feed_cost else None, + total_feed_cost_30d=round(Decimal(str(total_feed_cost_30d)), 2) if total_feed_cost_30d else None, + total_other_cost=round(Decimal(str(total_other_cost)), 2) if total_other_cost else None, + total_other_cost_30d=round(Decimal(str(total_other_cost_30d)), 2) if total_other_cost_30d else None, total_eggs_alltime=total_eggs, total_eggs_30d=total_eggs_30d, cost_per_egg=cpe, diff --git a/backend/schemas.py b/backend/schemas.py index fd7b515..67896c1 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -73,6 +73,28 @@ class FeedPurchaseOut(BaseModel): model_config = {"from_attributes": True} +# ── Other Purchases ─────────────────────────────────────────────────────────── + +class OtherPurchaseCreate(BaseModel): + date: date + total: Decimal = Field(gt=0, decimal_places=2) + notes: Optional[str] = None + +class OtherPurchaseUpdate(BaseModel): + date: Optional[date] = None + total: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2) + notes: Optional[str] = None + +class OtherPurchaseOut(BaseModel): + id: int + date: date + total: Decimal + notes: Optional[str] + created_at: datetime + + model_config = {"from_attributes": True} + + # ── Stats ───────────────────────────────────────────────────────────────────── class MonthlySummary(BaseModel): @@ -85,6 +107,7 @@ class MonthlySummary(BaseModel): flock_at_month_end: Optional[int] avg_eggs_per_hen_per_day: Optional[float] feed_cost: Optional[Decimal] + other_cost: Optional[Decimal] cost_per_egg: Optional[Decimal] cost_per_dozen: Optional[Decimal] @@ -99,11 +122,13 @@ class DashboardStats(BaseModel): days_tracked: int class BudgetStats(BaseModel): - total_feed_cost: Optional[Decimal] - total_feed_cost_30d: Optional[Decimal] - total_eggs_alltime: int - total_eggs_30d: int - cost_per_egg: Optional[Decimal] - cost_per_dozen: Optional[Decimal] - cost_per_egg_30d: Optional[Decimal] - cost_per_dozen_30d: Optional[Decimal] + total_feed_cost: Optional[Decimal] + total_feed_cost_30d: Optional[Decimal] + total_other_cost: Optional[Decimal] + total_other_cost_30d: Optional[Decimal] + total_eggs_alltime: int + total_eggs_30d: int + cost_per_egg: Optional[Decimal] + cost_per_dozen: Optional[Decimal] + cost_per_egg_30d: Optional[Decimal] + cost_per_dozen_30d: Optional[Decimal] diff --git a/mysql/init.sql b/mysql/init.sql index fc1ab9b..6554384 100644 --- a/mysql/init.sql +++ b/mysql/init.sql @@ -43,3 +43,15 @@ CREATE TABLE IF NOT EXISTS feed_purchases ( PRIMARY KEY (id), INDEX idx_date (date) ) ENGINE=InnoDB; + +-- ── Other purchases ─────────────────────────────────────────────────────────── +-- Catch-all for non-feed costs: bedding, snacks, shelter, etc. +CREATE TABLE IF NOT EXISTS other_purchases ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + date DATE NOT NULL, + total DECIMAL(10, 2) NOT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_date (date) +) ENGINE=InnoDB; diff --git a/nginx/html/budget.html b/nginx/html/budget.html index 079686e..7d802da 100644 --- a/nginx/html/budget.html +++ b/nginx/html/budget.html @@ -29,6 +29,7 @@

All-Time

Total Feed Cost
+
Other Costs
Total Eggs
Cost / Egg
Cost / Dozen
@@ -38,6 +39,7 @@

Last 30 Days

Feed Cost (30d)
+
Other Costs (30d)
Eggs (30d)
Cost / Egg (30d)
Cost / Dozen (30d)
@@ -75,6 +77,30 @@
+ +
+

Log Other Purchases

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+

Purchase History

@@ -82,17 +108,17 @@ Date - Bags - Price / Bag + Type + Details Total Notes - + Loading… - +
diff --git a/nginx/html/js/budget.js b/nginx/html/js/budget.js index d4a0081..e80af6b 100644 --- a/nginx/html/js/budget.js +++ b/nginx/html/js/budget.js @@ -1,26 +1,31 @@ -let feedData = []; +let feedData = []; +let otherData = []; async function loadBudget() { const msg = document.getElementById('msg'); try { - const [stats, purchases] = await Promise.all([ + const [stats, purchases, otherPurchases] = await Promise.all([ API.get('/api/stats/budget'), API.get('/api/feed'), + API.get('/api/other'), ]); // All-time stats - document.getElementById('b-cost-total').textContent = fmtMoney(stats.total_feed_cost); - document.getElementById('b-eggs-total').textContent = stats.total_eggs_alltime; - document.getElementById('b-cpe').textContent = fmtMoneyFull(stats.cost_per_egg); - document.getElementById('b-cpd').textContent = fmtMoney(stats.cost_per_dozen); + document.getElementById('b-cost-total').textContent = fmtMoney(stats.total_feed_cost); + document.getElementById('b-other-total').textContent = fmtMoney(stats.total_other_cost); + document.getElementById('b-eggs-total').textContent = stats.total_eggs_alltime; + document.getElementById('b-cpe').textContent = fmtMoneyFull(stats.cost_per_egg); + document.getElementById('b-cpd').textContent = fmtMoney(stats.cost_per_dozen); // Last 30 days - document.getElementById('b-cost-30d').textContent = fmtMoney(stats.total_feed_cost_30d); - document.getElementById('b-eggs-30d').textContent = stats.total_eggs_30d; - document.getElementById('b-cpe-30d').textContent = fmtMoneyFull(stats.cost_per_egg_30d); - document.getElementById('b-cpd-30d').textContent = fmtMoney(stats.cost_per_dozen_30d); + document.getElementById('b-cost-30d').textContent = fmtMoney(stats.total_feed_cost_30d); + document.getElementById('b-other-30d').textContent = fmtMoney(stats.total_other_cost_30d); + document.getElementById('b-eggs-30d').textContent = stats.total_eggs_30d; + document.getElementById('b-cpe-30d').textContent = fmtMoneyFull(stats.cost_per_egg_30d); + document.getElementById('b-cpd-30d').textContent = fmtMoney(stats.cost_per_dozen_30d); - feedData = purchases; + feedData = purchases; + otherData = otherPurchases; renderTable(); } catch (err) { showMessage(msg, `Failed to load budget data: ${err.message}`, 'error'); @@ -28,65 +33,96 @@ async function loadBudget() { } function renderTable() { - const tbody = document.getElementById('feed-body'); - const tfoot = document.getElementById('feed-foot'); + const tbody = document.getElementById('purchase-body'); + const tfoot = document.getElementById('purchase-foot'); - if (feedData.length === 0) { - tbody.innerHTML = 'No feed purchases logged yet.'; + const combined = [ + ...feedData.map(e => ({ ...e, _type: 'feed' })), + ...otherData.map(e => ({ ...e, _type: 'other' })), + ].sort((a, b) => { + if (b.date !== a.date) return b.date.localeCompare(a.date); + return b.created_at.localeCompare(a.created_at); + }); + + if (combined.length === 0) { + tbody.innerHTML = 'No purchases logged yet.'; tfoot.innerHTML = ''; return; } - tbody.innerHTML = feedData.map(e => { - const total = (parseFloat(e.bags) * parseFloat(e.price_per_bag)).toFixed(2); - return ` - - ${fmtDate(e.date)} - ${parseFloat(e.bags)} - ${fmtMoney(e.price_per_bag)} - ${fmtMoney(total)} - ${e.notes || ''} - - - - - - `; + tbody.innerHTML = combined.map(e => { + if (e._type === 'feed') { + const total = (parseFloat(e.bags) * parseFloat(e.price_per_bag)).toFixed(2); + return ` + + ${fmtDate(e.date)} + Feed + ${parseFloat(e.bags)} bags @ ${fmtMoney(e.price_per_bag)}/bag + ${fmtMoney(total)} + ${e.notes || ''} + + + + + + `; + } else { + return ` + + ${fmtDate(e.date)} + Other + — + ${fmtMoney(e.total)} + ${e.notes || ''} + + + + + + `; + } }).join(''); - // Total row - const grandTotal = feedData.reduce((sum, e) => sum + parseFloat(e.bags) * parseFloat(e.price_per_bag), 0); + const feedTotal = feedData.reduce((sum, e) => sum + parseFloat(e.bags) * parseFloat(e.price_per_bag), 0); + const otherTotal = otherData.reduce((sum, e) => sum + parseFloat(e.total), 0); + const grandTotal = feedTotal + otherTotal; + tfoot.innerHTML = ` Total ${fmtMoney(grandTotal)} - ${feedData.length} purchases + ${combined.length} purchase${combined.length === 1 ? '' : 's'} `; } -function startEdit(id) { +// ── Feed edit / delete ──────────────────────────────────────────────────────── + +function startEditFeed(id) { const entry = feedData.find(e => e.id === id); - const row = document.querySelector(`tr[data-id="${id}"]`); + const row = document.querySelector(`tr[data-id="${id}"][data-type="feed"]`); row.innerHTML = ` - - + Feed + + + bags @ + /bag + — - + `; } -async function saveEdit(id) { +async function saveEditFeed(id) { const msg = document.getElementById('msg'); - const row = document.querySelector(`tr[data-id="${id}"]`); - const inputs = row.querySelectorAll('input'); - const [dateInput, bagsInput, priceInput, notesInput] = inputs; + const row = document.querySelector(`tr[data-id="${id}"][data-type="feed"]`); + const [dateInput, bagsInput, priceInput, notesInput] = row.querySelectorAll('input'); try { const updated = await API.put(`/api/feed/${id}`, { @@ -95,8 +131,7 @@ async function saveEdit(id) { price_per_bag: parseFloat(priceInput.value), notes: notesInput.value.trim() || null, }); - const idx = feedData.findIndex(e => e.id === id); - feedData[idx] = updated; + feedData[feedData.findIndex(e => e.id === id)] = updated; renderTable(); loadBudget(); showMessage(msg, 'Purchase updated.'); @@ -105,7 +140,7 @@ async function saveEdit(id) { } } -async function deleteEntry(id) { +async function deleteFeed(id) { if (!confirm('Delete this purchase?')) return; const msg = document.getElementById('msg'); try { @@ -119,16 +154,73 @@ async function deleteEntry(id) { } } +// ── Other edit / delete ─────────────────────────────────────────────────────── + +function startEditOther(id) { + const entry = otherData.find(e => e.id === id); + const row = document.querySelector(`tr[data-id="${id}"][data-type="other"]`); + + row.innerHTML = ` + + Other + — + + + + + + + `; +} + +async function saveEditOther(id) { + const msg = document.getElementById('msg'); + const row = document.querySelector(`tr[data-id="${id}"][data-type="other"]`); + const [dateInput, totalInput, notesInput] = row.querySelectorAll('input'); + + try { + const updated = await API.put(`/api/other/${id}`, { + date: dateInput.value, + total: parseFloat(totalInput.value), + notes: notesInput.value.trim() || null, + }); + otherData[otherData.findIndex(e => e.id === id)] = updated; + renderTable(); + loadBudget(); + showMessage(msg, 'Purchase updated.'); + } catch (err) { + showMessage(msg, `Error: ${err.message}`, 'error'); + } +} + +async function deleteOther(id) { + if (!confirm('Delete this purchase?')) return; + const msg = document.getElementById('msg'); + try { + await API.del(`/api/other/${id}`); + otherData = otherData.filter(e => e.id !== id); + renderTable(); + loadBudget(); + showMessage(msg, 'Purchase deleted.'); + } catch (err) { + showMessage(msg, `Error: ${err.message}`, 'error'); + } +} + +// ── Init ────────────────────────────────────────────────────────────────────── + document.addEventListener('DOMContentLoaded', () => { - const form = document.getElementById('feed-form'); + const feedForm = document.getElementById('feed-form'); + const otherForm = document.getElementById('other-form'); const msg = document.getElementById('msg'); const bagsInput = document.getElementById('bags'); const priceInput = document.getElementById('price'); const totalDisplay = document.getElementById('total-display'); setToday(document.getElementById('date')); + setToday(document.getElementById('other-date')); - // Live total calculation + // Live total calculation for feed form function updateTotal() { const bags = parseFloat(bagsInput.value) || 0; const price = parseFloat(priceInput.value) || 0; @@ -137,20 +229,18 @@ document.addEventListener('DOMContentLoaded', () => { bagsInput.addEventListener('input', updateTotal); priceInput.addEventListener('input', updateTotal); - form.addEventListener('submit', async (e) => { + feedForm.addEventListener('submit', async (e) => { e.preventDefault(); - const data = { date: document.getElementById('date').value, bags: parseFloat(bagsInput.value), price_per_bag: parseFloat(priceInput.value), notes: document.getElementById('notes').value.trim() || null, }; - try { await API.post('/api/feed', data); - showMessage(msg, 'Purchase saved!'); - form.reset(); + showMessage(msg, 'Feed purchase saved!'); + feedForm.reset(); totalDisplay.value = ''; setToday(document.getElementById('date')); loadBudget(); @@ -159,5 +249,23 @@ document.addEventListener('DOMContentLoaded', () => { } }); + otherForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = { + date: document.getElementById('other-date').value, + total: parseFloat(document.getElementById('other-total').value), + notes: document.getElementById('other-notes').value.trim() || null, + }; + try { + await API.post('/api/other', data); + showMessage(msg, 'Purchase saved!'); + otherForm.reset(); + setToday(document.getElementById('other-date')); + loadBudget(); + } catch (err) { + showMessage(msg, `Error: ${err.message}`, 'error'); + } + }); + loadBudget(); });