2026-06-18 01:40:12 +00:00
{{define "title"}}Add receipt{{end}}
{{define "content"}}
< header >
< span > {{.Subject}}< / span >
2026-06-19 02:04:42 +00:00
< span > < a href = "/tally" > Tally< / a > · < a href = "/recent" > Recent< / a > · < a href = "/manage" > Manage< / a > · < a href = "/export" > Export< / a > · < a href = "/logout" > Log out< / a > < / span >
2026-06-18 01:40:12 +00:00
< / header >
< h1 > Add receipt< / h1 >
{{range .Errors}}< div class = "err" > {{.}}< / div > {{end}}
2026-06-19 02:04:42 +00:00
< form method = "post" action = "/upload" enctype = "multipart/form-data" id = "upload-form" >
2026-06-18 01:40:12 +00:00
< label for = "receipt" > Receipt photo or PDF< / label >
< input id = "receipt" type = "file" name = "receipt" accept = "image/*,application/pdf" capture = "environment" required >
2026-06-19 02:04:42 +00:00
{{if .ClassifyEnabled}}
< label class = "check" > < input type = "checkbox" id = "skip-ai" > Skip AI auto-fill (enter fields by hand)< / label >
< p class = "footnote" id = "classify-note" > Receipt is read automatically by < code > {{.ClassifyModel}}< / code > to pre-fill the fields below — review before submitting.< / p >
{{else}}
< p class = "footnote" > Auto-fill is off (no API key configured). Enter the fields manually.< / p >
{{end}}
2026-06-18 01:40:12 +00:00
< label for = "amount" > Amount ($)< / label >
< input id = "amount" type = "number" name = "amount" step = "0.01" min = "0.01" inputmode = "decimal" value = "{{.Amount}}" required >
< label for = "receipt_date" > Date on receipt< / label >
< input id = "receipt_date" type = "date" name = "receipt_date" value = "{{.Date}}" required >
< label for = "category_id" > Category< / label >
< select id = "category_id" name = "category_id" >
{{$sc := .CategoryID}}
{{range .Categories}}< option value = "{{.ID}}" { { if eq ( printf " % d " . ID ) $ sc } } selected { { end } } > {{.Label}}< / option > {{end}}
< / select >
< label for = "person_id" > Who< / label >
< select id = "person_id" name = "person_id" >
{{$sp := .PersonID}}
< option value = "" > —< / option >
{{range .People}}< option value = "{{.ID}}" { { if eq ( printf " % d " . ID ) $ sp } } selected { { end } } > {{.Label}}< / option > {{end}}
< / select >
2026-06-20 12:35:14 +00:00
< label > Tags (optional)< / label >
< button type = "button" class = "tags-btn" id = "tags-btn" > Add tags< / button >
< span class = "tags-summary" id = "tags-summary" > < / span >
< div id = "tags-hidden" >
{{range .TagChips}}{{if .Selected}}< input type = "hidden" name = "tags" value = "{{.Label}}" > {{end}}{{end}}
< / div >
< div class = "modal" id = "tags-modal" hidden >
< div class = "modal-card" >
< h2 > Tags< / h2 >
< div class = "chips" id = "chip-list" >
{{range .TagChips}}< button type = "button" class = "chip{{if .Selected}} sel{{end}}" data-label = "{{.Label}}" > {{.Label}}< / button > {{end}}
< / div >
< div class = "newtag" >
< input type = "text" id = "newtag-input" placeholder = "Create a tag" autocomplete = "off" >
< button type = "button" id = "newtag-add" > Add< / button >
< / div >
< button type = "button" class = "primary" id = "tags-done" > Done with tags< / button >
< / div >
< / div >
2026-06-19 11:06:15 +00:00
< label for = "attachments" > Additional files (optional)< / label >
< input id = "attachments" type = "file" name = "attachments" accept = "image/*,application/pdf" multiple >
2026-06-19 02:04:42 +00:00
< div class = "dup-warn" id = "dup-warn" hidden >
< strong > Possible duplicate< / strong > — a receipt with this date and amount is already saved:
< ul id = "dup-list" > < / ul >
< label class = "check" > < input type = "checkbox" id = "dup-ack" > Add it anyway< / label >
< / div >
< button type = "submit" class = "primary" id = "submit-btn" > Submit< / button >
2026-06-18 01:40:12 +00:00
< / form >
2026-06-19 02:04:42 +00:00
2026-06-20 12:35:14 +00:00
< script >
// --- Tag picker: in-page modal with a chip mosaic. Selection is mirrored into
// hidden "tags" inputs so it submits with the rest of the form. ---
(function () {
var btn = document.getElementById("tags-btn");
var modal = document.getElementById("tags-modal");
var chipList = document.getElementById("chip-list");
var hidden = document.getElementById("tags-hidden");
var summary = document.getElementById("tags-summary");
var newInput = document.getElementById("newtag-input");
var newAdd = document.getElementById("newtag-add");
var done = document.getElementById("tags-done");
function chips() { return Array.prototype.slice.call(chipList.querySelectorAll(".chip")); }
function sync() {
var labels = chips().filter(function (c) { return c.classList.contains("sel"); })
.map(function (c) { return c.getAttribute("data-label"); });
hidden.innerHTML = "";
labels.forEach(function (l) {
var i = document.createElement("input");
i.type = "hidden"; i.name = "tags"; i.value = l;
hidden.appendChild(i);
});
summary.textContent = labels.length ? labels.length + " selected" : "none yet";
}
function find(label) {
var lower = label.toLowerCase();
return chips().filter(function (c) {
return c.getAttribute("data-label").toLowerCase() === lower;
})[0];
}
function addNew() {
var label = newInput.value.trim();
if (!label) return;
var existing = find(label);
if (existing) {
existing.classList.add("sel");
} else {
var c = document.createElement("button");
c.type = "button"; c.className = "chip sel";
c.setAttribute("data-label", label); c.textContent = label;
chipList.appendChild(c);
}
newInput.value = "";
sync();
}
chipList.addEventListener("click", function (e) {
var chip = e.target.closest(".chip");
if (!chip) return;
chip.classList.toggle("sel");
sync();
});
newAdd.addEventListener("click", addNew);
newInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); addNew(); }
});
btn.addEventListener("click", function () { modal.hidden = false; });
done.addEventListener("click", function () { modal.hidden = true; });
modal.addEventListener("click", function (e) {
if (e.target === modal) modal.hidden = true; // tap the backdrop to close
});
sync(); // reflect any pre-selected chips (e.g. carried across an error re-render)
})();
< / script >
2026-06-19 02:04:42 +00:00
< script >
(function () {
var form = document.getElementById("upload-form");
var amount = document.getElementById("amount");
var dateEl = document.getElementById("receipt_date");
var dupWarn = document.getElementById("dup-warn");
var dupList = document.getElementById("dup-list");
var dupAck = document.getElementById("dup-ack");
var submitBtn = document.getElementById("submit-btn");
var dupVisible = false;
// --- Duplicate check (runs whenever date + amount are both known) ---
function updateSubmitState() {
submitBtn.disabled = dupVisible & & !dupAck.checked;
}
dupAck.addEventListener("change", updateSubmitState);
function checkDuplicates() {
var a = amount.value.trim(), d = dateEl.value.trim();
if (!a || !d) { hideDup(); return; }
var url = "/duplicates?date=" + encodeURIComponent(d) + "& amount=" + encodeURIComponent(a);
fetch(url)
.then(function (r) { return r.ok ? r.json() : { matches: [] }; })
.then(function (data) {
if (!data.matches || !data.matches.length) { hideDup(); return; }
dupList.innerHTML = "";
data.matches.forEach(function (m) {
var li = document.createElement("li");
var who = m.who ? " · " + m.who : "";
li.textContent = "$" + m.amount + " · " + m.date + " · " + m.category + who +
" — " + m.filename + " (uploaded " + m.uploaded + " by " + m.by + ")";
dupList.appendChild(li);
});
dupWarn.hidden = false;
dupVisible = true;
dupAck.checked = false;
updateSubmitState();
})
.catch(hideDup);
}
function hideDup() {
dupWarn.hidden = true;
dupVisible = false;
updateSubmitState();
}
amount.addEventListener("change", checkDuplicates);
dateEl.addEventListener("change", checkDuplicates);
checkDuplicates(); // in case the form was pre-filled (e.g. re-render after error)
})();
< / script >
{{if .ClassifyEnabled}}
< script >
(function () {
var fileInput = document.getElementById("receipt");
var skip = document.getElementById("skip-ai");
var note = document.getElementById("classify-note");
var model = {{.ClassifyModel}};
var baseNote = note.innerHTML;
skip.addEventListener("change", function () {
note.style.opacity = skip.checked ? "0.5" : "";
if (skip.checked) note.textContent = "AI auto-fill skipped — enter the fields by hand.";
else note.innerHTML = baseNote;
});
fileInput.addEventListener("change", function () {
var file = fileInput.files[0];
if (!file || skip.checked) return;
note.style.opacity = "";
note.textContent = "Reading receipt with " + model + "…";
var body = new FormData();
body.append("receipt", file);
fetch("/classify", { method: "POST", body: body })
.then(function (resp) {
if (!resp.ok) throw new Error("classify failed (" + resp.status + ")");
return resp.json();
})
.then(function (d) {
if (d.amount) setValue("amount", d.amount);
if (d.date) setValue("receipt_date", d.date);
if (d.category_id != null) setValue("category_id", String(d.category_id));
if (d.person_id != null) setValue("person_id", String(d.person_id));
var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : "";
note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit.";
// Pre-filled date+amount may match an existing receipt — re-check.
document.getElementById("receipt_date").dispatchEvent(new Event("change"));
})
.catch(function (err) {
note.innerHTML = baseNote;
var e = document.createElement("span");
e.className = "err-inline";
e.textContent = " Auto-fill failed: " + err.message + ". Enter the fields manually.";
note.appendChild(e);
});
});
function setValue(id, val) {
var el = document.getElementById(id);
if (el) el.value = val;
}
})();
< / script >
{{end}}
2026-06-18 01:40:12 +00:00
{{end}}