hsa-app/internal/web/templates/upload.html
Jean-Michel Tremblay 2ff5c3847c
All checks were successful
Build and Test / build-and-test (push) Successful in 36s
Optional receipt tags; changelog 0.0.2
Add a shared, free-form tag vocabulary attachable to a receipt at upload.
New tags/receipt_tags tables (case-insensitive label dedup); an in-page
chip-mosaic picker after "Who" with inline tag creation. Tags are
resolved/created only on successful submit, and shown on the confirm
page and recent lists. Documented as spec item 14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:35:14 -04:00

236 lines
9.5 KiB
HTML

{{define "title"}}Add receipt{{end}}
{{define "content"}}
<header>
<span>{{.Subject}}</span>
<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>
</header>
<h1>Add receipt</h1>
{{range .Errors}}<div class="err">{{.}}</div>{{end}}
<form method="post" action="/upload" enctype="multipart/form-data" id="upload-form">
<label for="receipt">Receipt photo or PDF</label>
<input id="receipt" type="file" name="receipt" accept="image/*,application/pdf" capture="environment" required>
{{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}}
<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>
<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>
<label for="attachments">Additional files (optional)</label>
<input id="attachments" type="file" name="attachments" accept="image/*,application/pdf" multiple>
<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>
</form>
<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>
<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}}
{{end}}