All checks were successful
Build and Test / build-and-test (push) Successful in 36s
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>
236 lines
9.5 KiB
HTML
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}}
|