Add Other Purchases to budget page

- New other_purchases table (date, total, notes)
- /api/other CRUD endpoints
- Budget stats now include other costs in cost/egg and cost/dozen math
- Budget page: new Log Other Purchases form, stat cards for other costs,
  combined Purchase History table showing feed and other entries together

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:47:57 -08:00
parent ceb0780663
commit 404fd0510f
8 changed files with 351 additions and 77 deletions

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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") app = FastAPI(title="Eggtracker API")
@@ -16,6 +16,7 @@ app.add_middleware(
app.include_router(eggs.router) app.include_router(eggs.router)
app.include_router(flock.router) app.include_router(flock.router)
app.include_router(feed.router) app.include_router(feed.router)
app.include_router(other.router)
app.include_router(stats.router) app.include_router(stats.router)

View File

@@ -33,3 +33,13 @@ class FeedPurchase(Base):
price_per_bag: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) price_per_bag: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=True) notes: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) 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())

59
backend/routers/other.py Normal file
View File

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

View File

@@ -6,7 +6,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import EggCollection, FlockHistory, FeedPurchase from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase
from schemas import DashboardStats, BudgetStats, MonthlySummary from schemas import DashboardStats, BudgetStats, MonthlySummary
router = APIRouter(prefix="/api/stats", tags=["stats"]) 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) 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) @router.get("/dashboard", response_model=DashboardStats)
def dashboard_stats(db: Session = Depends(get_db)): def dashboard_stats(db: Session = Depends(get_db)):
today = date.today() 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} 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 = [] results = []
for row in egg_rows: for row in egg_rows:
y, m = int(row.year), int(row.month) 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_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 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)) raw_feed_cost = feed_map.get((y, m))
feed_cost = round(Decimal(str(raw_feed_cost)), 2) if raw_feed_cost else None raw_other_cost = other_map.get((y, m))
cpe = round(Decimal(str(raw_feed_cost)) / Decimal(total_eggs), 4) if (raw_feed_cost and total_eggs) else None 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 cpd = round(cpe * 12, 4) if cpe else None
results.append(MonthlySummary( results.append(MonthlySummary(
@@ -167,6 +192,7 @@ def monthly_stats(db: Session = Depends(get_db)):
flock_at_month_end=flock, flock_at_month_end=flock,
avg_eggs_per_hen_per_day=avg_per_hen, avg_eggs_per_hen_per_day=avg_per_hen,
feed_cost=feed_cost, feed_cost=feed_cost,
other_cost=other_cost,
cost_per_egg=cpe, cost_per_egg=cpe,
cost_per_dozen=cpd, cost_per_dozen=cpd,
)) ))
@@ -179,10 +205,12 @@ def budget_stats(db: Session = Depends(get_db)):
today = date.today() today = date.today()
start_30d = today - timedelta(days=30) start_30d = today - timedelta(days=30)
total_cost = _total_feed_cost(db) total_feed_cost = _total_feed_cost(db)
total_cost_30d = _total_feed_cost(db, start=start_30d) total_feed_cost_30d = _total_feed_cost(db, start=start_30d)
total_eggs = _total_eggs(db) total_other_cost = _total_other_cost(db)
total_eggs_30d = _total_eggs(db, start=start_30d) 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): def cost_per_egg(cost, eggs):
if not eggs or not cost: if not eggs or not cost:
@@ -192,12 +220,17 @@ def budget_stats(db: Session = Depends(get_db)):
def cost_per_dozen(cpe): def cost_per_dozen(cpe):
return round(cpe * 12, 4) if cpe else None return round(cpe * 12, 4) if cpe else None
cpe = cost_per_egg(total_cost, total_eggs) combined_cost = total_feed_cost + total_other_cost
cpe_30d = cost_per_egg(total_cost_30d, total_eggs_30d) 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( return BudgetStats(
total_feed_cost=round(Decimal(str(total_cost)), 2) if total_cost 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_cost_30d)), 2) if total_cost_30d 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_alltime=total_eggs,
total_eggs_30d=total_eggs_30d, total_eggs_30d=total_eggs_30d,
cost_per_egg=cpe, cost_per_egg=cpe,

View File

@@ -73,6 +73,28 @@ class FeedPurchaseOut(BaseModel):
model_config = {"from_attributes": True} 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 ───────────────────────────────────────────────────────────────────── # ── Stats ─────────────────────────────────────────────────────────────────────
class MonthlySummary(BaseModel): class MonthlySummary(BaseModel):
@@ -85,6 +107,7 @@ class MonthlySummary(BaseModel):
flock_at_month_end: Optional[int] flock_at_month_end: Optional[int]
avg_eggs_per_hen_per_day: Optional[float] avg_eggs_per_hen_per_day: Optional[float]
feed_cost: Optional[Decimal] feed_cost: Optional[Decimal]
other_cost: Optional[Decimal]
cost_per_egg: Optional[Decimal] cost_per_egg: Optional[Decimal]
cost_per_dozen: Optional[Decimal] cost_per_dozen: Optional[Decimal]
@@ -99,11 +122,13 @@ class DashboardStats(BaseModel):
days_tracked: int days_tracked: int
class BudgetStats(BaseModel): class BudgetStats(BaseModel):
total_feed_cost: Optional[Decimal] total_feed_cost: Optional[Decimal]
total_feed_cost_30d: Optional[Decimal] total_feed_cost_30d: Optional[Decimal]
total_eggs_alltime: int total_other_cost: Optional[Decimal]
total_eggs_30d: int total_other_cost_30d: Optional[Decimal]
cost_per_egg: Optional[Decimal] total_eggs_alltime: int
cost_per_dozen: Optional[Decimal] total_eggs_30d: int
cost_per_egg_30d: Optional[Decimal] cost_per_egg: Optional[Decimal]
cost_per_dozen_30d: Optional[Decimal] cost_per_dozen: Optional[Decimal]
cost_per_egg_30d: Optional[Decimal]
cost_per_dozen_30d: Optional[Decimal]

View File

@@ -43,3 +43,15 @@ CREATE TABLE IF NOT EXISTS feed_purchases (
PRIMARY KEY (id), PRIMARY KEY (id),
INDEX idx_date (date) INDEX idx_date (date)
) ENGINE=InnoDB; ) 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;

View File

@@ -29,6 +29,7 @@
<h2>All-Time</h2> <h2>All-Time</h2>
<div class="stats-grid" style="margin-bottom: 1rem;"> <div class="stats-grid" style="margin-bottom: 1rem;">
<div class="stat-card"><div class="label">Total Feed Cost</div><div class="value" id="b-cost-total"></div></div> <div class="stat-card"><div class="label">Total Feed Cost</div><div class="value" id="b-cost-total"></div></div>
<div class="stat-card"><div class="label">Other Costs</div><div class="value" id="b-other-total"></div></div>
<div class="stat-card"><div class="label">Total Eggs</div><div class="value" id="b-eggs-total"></div></div> <div class="stat-card"><div class="label">Total Eggs</div><div class="value" id="b-eggs-total"></div></div>
<div class="stat-card accent"><div class="label">Cost / Egg</div><div class="value" id="b-cpe"></div></div> <div class="stat-card accent"><div class="label">Cost / Egg</div><div class="value" id="b-cpe"></div></div>
<div class="stat-card accent"><div class="label">Cost / Dozen</div><div class="value" id="b-cpd"></div></div> <div class="stat-card accent"><div class="label">Cost / Dozen</div><div class="value" id="b-cpd"></div></div>
@@ -38,6 +39,7 @@
<h2>Last 30 Days</h2> <h2>Last 30 Days</h2>
<div class="stats-grid" style="margin-bottom: 2rem;"> <div class="stats-grid" style="margin-bottom: 2rem;">
<div class="stat-card"><div class="label">Feed Cost (30d)</div><div class="value" id="b-cost-30d"></div></div> <div class="stat-card"><div class="label">Feed Cost (30d)</div><div class="value" id="b-cost-30d"></div></div>
<div class="stat-card"><div class="label">Other Costs (30d)</div><div class="value" id="b-other-30d"></div></div>
<div class="stat-card"><div class="label">Eggs (30d)</div><div class="value" id="b-eggs-30d"></div></div> <div class="stat-card"><div class="label">Eggs (30d)</div><div class="value" id="b-eggs-30d"></div></div>
<div class="stat-card accent"><div class="label">Cost / Egg (30d)</div><div class="value" id="b-cpe-30d"></div></div> <div class="stat-card accent"><div class="label">Cost / Egg (30d)</div><div class="value" id="b-cpe-30d"></div></div>
<div class="stat-card accent"><div class="label">Cost / Dozen (30d)</div><div class="value" id="b-cpd-30d"></div></div> <div class="stat-card accent"><div class="label">Cost / Dozen (30d)</div><div class="value" id="b-cpd-30d"></div></div>
@@ -75,6 +77,30 @@
</form> </form>
</div> </div>
<!-- Log other purchase -->
<div class="card">
<h2>Log Other Purchases</h2>
<form id="other-form">
<div class="form-grid">
<div class="form-group">
<label for="other-date">Date</label>
<input type="date" id="other-date" required>
</div>
<div class="form-group">
<label for="other-total">Total ($)</label>
<input type="number" id="other-total" min="0.01" step="0.01" required placeholder="0.00">
</div>
<div class="form-group span-full">
<label for="other-notes">Note</label>
<textarea id="other-notes" placeholder="e.g. Snacks, Bedding, Shelter Costs…"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save Purchase</button>
</div>
</div>
</form>
</div>
<!-- Purchase history --> <!-- Purchase history -->
<h2>Purchase History</h2> <h2>Purchase History</h2>
<div class="table-wrap"> <div class="table-wrap">
@@ -82,17 +108,17 @@
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Bags</th> <th>Type</th>
<th>Price / Bag</th> <th>Details</th>
<th>Total</th> <th>Total</th>
<th>Notes</th> <th>Notes</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody id="feed-body"> <tbody id="purchase-body">
<tr class="empty-row"><td colspan="6">Loading…</td></tr> <tr class="empty-row"><td colspan="6">Loading…</td></tr>
</tbody> </tbody>
<tfoot id="feed-foot"></tfoot> <tfoot id="purchase-foot"></tfoot>
</table> </table>
</div> </div>
</main> </main>

View File

@@ -1,26 +1,31 @@
let feedData = []; let feedData = [];
let otherData = [];
async function loadBudget() { async function loadBudget() {
const msg = document.getElementById('msg'); const msg = document.getElementById('msg');
try { try {
const [stats, purchases] = await Promise.all([ const [stats, purchases, otherPurchases] = await Promise.all([
API.get('/api/stats/budget'), API.get('/api/stats/budget'),
API.get('/api/feed'), API.get('/api/feed'),
API.get('/api/other'),
]); ]);
// All-time stats // All-time stats
document.getElementById('b-cost-total').textContent = fmtMoney(stats.total_feed_cost); document.getElementById('b-cost-total').textContent = fmtMoney(stats.total_feed_cost);
document.getElementById('b-eggs-total').textContent = stats.total_eggs_alltime; document.getElementById('b-other-total').textContent = fmtMoney(stats.total_other_cost);
document.getElementById('b-cpe').textContent = fmtMoneyFull(stats.cost_per_egg); document.getElementById('b-eggs-total').textContent = stats.total_eggs_alltime;
document.getElementById('b-cpd').textContent = fmtMoney(stats.cost_per_dozen); document.getElementById('b-cpe').textContent = fmtMoneyFull(stats.cost_per_egg);
document.getElementById('b-cpd').textContent = fmtMoney(stats.cost_per_dozen);
// Last 30 days // Last 30 days
document.getElementById('b-cost-30d').textContent = fmtMoney(stats.total_feed_cost_30d); 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-other-30d').textContent = fmtMoney(stats.total_other_cost_30d);
document.getElementById('b-cpe-30d').textContent = fmtMoneyFull(stats.cost_per_egg_30d); document.getElementById('b-eggs-30d').textContent = stats.total_eggs_30d;
document.getElementById('b-cpd-30d').textContent = fmtMoney(stats.cost_per_dozen_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(); renderTable();
} catch (err) { } catch (err) {
showMessage(msg, `Failed to load budget data: ${err.message}`, 'error'); showMessage(msg, `Failed to load budget data: ${err.message}`, 'error');
@@ -28,65 +33,96 @@ async function loadBudget() {
} }
function renderTable() { function renderTable() {
const tbody = document.getElementById('feed-body'); const tbody = document.getElementById('purchase-body');
const tfoot = document.getElementById('feed-foot'); const tfoot = document.getElementById('purchase-foot');
if (feedData.length === 0) { const combined = [
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No feed purchases logged yet.</td></tr>'; ...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 = '<tr class="empty-row"><td colspan="6">No purchases logged yet.</td></tr>';
tfoot.innerHTML = ''; tfoot.innerHTML = '';
return; return;
} }
tbody.innerHTML = feedData.map(e => { tbody.innerHTML = combined.map(e => {
const total = (parseFloat(e.bags) * parseFloat(e.price_per_bag)).toFixed(2); if (e._type === 'feed') {
return ` const total = (parseFloat(e.bags) * parseFloat(e.price_per_bag)).toFixed(2);
<tr data-id="${e.id}"> return `
<td>${fmtDate(e.date)}</td> <tr data-id="${e.id}" data-type="feed">
<td>${parseFloat(e.bags)}</td> <td>${fmtDate(e.date)}</td>
<td>${fmtMoney(e.price_per_bag)}</td> <td>Feed</td>
<td>${fmtMoney(total)}</td> <td>${parseFloat(e.bags)} bags @ ${fmtMoney(e.price_per_bag)}/bag</td>
<td class="notes">${e.notes || ''}</td> <td>${fmtMoney(total)}</td>
<td class="actions"> <td class="notes">${e.notes || ''}</td>
<button class="btn btn-ghost btn-sm" onclick="startEdit(${e.id})">Edit</button> <td class="actions">
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button> <button class="btn btn-ghost btn-sm" onclick="startEditFeed(${e.id})">Edit</button>
</td> <button class="btn btn-danger btn-sm" onclick="deleteFeed(${e.id})">Delete</button>
</tr> </td>
`; </tr>
`;
} else {
return `
<tr data-id="${e.id}" data-type="other">
<td>${fmtDate(e.date)}</td>
<td>Other</td>
<td>—</td>
<td>${fmtMoney(e.total)}</td>
<td class="notes">${e.notes || ''}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm" onclick="startEditOther(${e.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteOther(${e.id})">Delete</button>
</td>
</tr>
`;
}
}).join(''); }).join('');
// Total row const feedTotal = feedData.reduce((sum, e) => sum + parseFloat(e.bags) * parseFloat(e.price_per_bag), 0);
const grandTotal = 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 = ` tfoot.innerHTML = `
<tr class="total-row"> <tr class="total-row">
<td colspan="3">Total</td> <td colspan="3">Total</td>
<td>${fmtMoney(grandTotal)}</td> <td>${fmtMoney(grandTotal)}</td>
<td colspan="2">${feedData.length} purchases</td> <td colspan="2">${combined.length} purchase${combined.length === 1 ? '' : 's'}</td>
</tr> </tr>
`; `;
} }
function startEdit(id) { // ── Feed edit / delete ────────────────────────────────────────────────────────
function startEditFeed(id) {
const entry = feedData.find(e => e.id === 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 = ` row.innerHTML = `
<td><input type="date" value="${entry.date}"></td> <td><input type="date" value="${entry.date}"></td>
<td><input type="number" min="0.01" step="0.01" value="${parseFloat(entry.bags)}" style="width:80px;"></td> <td>Feed</td>
<td><input type="number" min="0.01" step="0.01" value="${parseFloat(entry.price_per_bag)}" style="width:90px;"></td> <td>
<input type="number" min="0.01" step="0.01" value="${parseFloat(entry.bags)}" placeholder="bags" style="width:70px;">
bags @
<input type="number" min="0.01" step="0.01" value="${parseFloat(entry.price_per_bag)}" placeholder="price" style="width:80px;">/bag
</td>
<td>—</td> <td>—</td>
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td> <td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
<td class="actions"> <td class="actions">
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button> <button class="btn btn-primary btn-sm" onclick="saveEditFeed(${id})">Save</button>
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button> <button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
</td> </td>
`; `;
} }
async function saveEdit(id) { async function saveEditFeed(id) {
const msg = document.getElementById('msg'); const msg = document.getElementById('msg');
const row = document.querySelector(`tr[data-id="${id}"]`); const row = document.querySelector(`tr[data-id="${id}"][data-type="feed"]`);
const inputs = row.querySelectorAll('input'); const [dateInput, bagsInput, priceInput, notesInput] = row.querySelectorAll('input');
const [dateInput, bagsInput, priceInput, notesInput] = inputs;
try { try {
const updated = await API.put(`/api/feed/${id}`, { const updated = await API.put(`/api/feed/${id}`, {
@@ -95,8 +131,7 @@ async function saveEdit(id) {
price_per_bag: parseFloat(priceInput.value), price_per_bag: parseFloat(priceInput.value),
notes: notesInput.value.trim() || null, notes: notesInput.value.trim() || null,
}); });
const idx = feedData.findIndex(e => e.id === id); feedData[feedData.findIndex(e => e.id === id)] = updated;
feedData[idx] = updated;
renderTable(); renderTable();
loadBudget(); loadBudget();
showMessage(msg, 'Purchase updated.'); 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; if (!confirm('Delete this purchase?')) return;
const msg = document.getElementById('msg'); const msg = document.getElementById('msg');
try { 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 = `
<td><input type="date" value="${entry.date}"></td>
<td>Other</td>
<td>—</td>
<td><input type="number" min="0.01" step="0.01" value="${parseFloat(entry.total)}" style="width:100px;"></td>
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
<td class="actions">
<button class="btn btn-primary btn-sm" onclick="saveEditOther(${id})">Save</button>
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
</td>
`;
}
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', () => { 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 msg = document.getElementById('msg');
const bagsInput = document.getElementById('bags'); const bagsInput = document.getElementById('bags');
const priceInput = document.getElementById('price'); const priceInput = document.getElementById('price');
const totalDisplay = document.getElementById('total-display'); const totalDisplay = document.getElementById('total-display');
setToday(document.getElementById('date')); setToday(document.getElementById('date'));
setToday(document.getElementById('other-date'));
// Live total calculation // Live total calculation for feed form
function updateTotal() { function updateTotal() {
const bags = parseFloat(bagsInput.value) || 0; const bags = parseFloat(bagsInput.value) || 0;
const price = parseFloat(priceInput.value) || 0; const price = parseFloat(priceInput.value) || 0;
@@ -137,20 +229,18 @@ document.addEventListener('DOMContentLoaded', () => {
bagsInput.addEventListener('input', updateTotal); bagsInput.addEventListener('input', updateTotal);
priceInput.addEventListener('input', updateTotal); priceInput.addEventListener('input', updateTotal);
form.addEventListener('submit', async (e) => { feedForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const data = { const data = {
date: document.getElementById('date').value, date: document.getElementById('date').value,
bags: parseFloat(bagsInput.value), bags: parseFloat(bagsInput.value),
price_per_bag: parseFloat(priceInput.value), price_per_bag: parseFloat(priceInput.value),
notes: document.getElementById('notes').value.trim() || null, notes: document.getElementById('notes').value.trim() || null,
}; };
try { try {
await API.post('/api/feed', data); await API.post('/api/feed', data);
showMessage(msg, 'Purchase saved!'); showMessage(msg, 'Feed purchase saved!');
form.reset(); feedForm.reset();
totalDisplay.value = ''; totalDisplay.value = '';
setToday(document.getElementById('date')); setToday(document.getElementById('date'));
loadBudget(); 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(); loadBudget();
}); });