Files
grotti/web/templates/auftraege.html
T

568 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Aufträge</h1>
<p class="tiny">Kunden, Angebote, Positionen und Fortschritt.</p>
</div>
<div class="actions">
<a class="ghost" id="lieferschein-link" href="#" hidden target="_blank" rel="noopener">Lieferschein</a>
<button type="button" class="btn-primary" id="order-new-head"><svg class="ico"><use href="#i-plus"/></svg>Neuer Auftrag</button>
</div>
</div>
<div class="split edit-split is-idle" id="order-layout">
<section class="card">
<div class="field inline">
<label for="filter-status">Status</label>
<select id="filter-status">
<option value="">alle</option>
<option value="idee">Idee</option>
<option value="angebot">Angebot</option>
<option value="bestaetigt">bestätigt</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
<option value="geliefert">geliefert</option>
<option value="bezahlt">bezahlt</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody>
</table>
<div class="pager" id="order-pager" hidden></div>
</div>
</section>
<section class="card edit-panel" id="order-panel" hidden>
<div class="mat-form-head">
<h2 id="order-title">Neuer Auftrag</h2>
<button type="button" class="ghost tiny-btn" id="order-edit-btn" hidden>Bearbeiten</button>
<button type="button" class="icon-btn" id="order-close" title="Schließen" aria-label="Schließen"></button>
</div>
<form id="order-form" hidden>
<input id="order-id" type="hidden">
<div class="field"><label for="order-title-in">Titel</label><input id="order-title-in" required></div>
<div class="grid-2">
<div class="field"><label for="order-status">Status</label>
<select id="order-status">
<option value="idee">Idee</option>
<option value="angebot">Angebot</option>
<option value="bestaetigt">bestätigt</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
<option value="geliefert">geliefert</option>
<option value="bezahlt">bezahlt</option>
</select>
</div>
<div class="field"><label for="order-due">Fällig</label><input id="order-due" type="date"></div>
</div>
<div class="field"><label for="order-customer">Kunde</label>
<select id="order-customer"><option value="">— keiner —</option></select>
</div>
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
<div class="actions">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="order-cancel-edit" hidden>Abbrechen</button>
<button type="button" class="btn-danger" id="order-delete" hidden>Löschen</button>
</div>
</form>
<p id="order-note" class="tiny"></p>
<div id="order-detail" hidden>
<p class="tiny" id="order-progress"></p>
<p class="tiny" id="order-actuals"></p>
<p class="tiny" id="order-notes-view"></p>
<h2 class="section-gap">Positionen</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Datei</th><th class="num">Stk</th><th class="num">fertig</th><th></th></tr></thead>
<tbody id="item-rows"></tbody>
</table>
</div>
<form id="item-edit-form" class="section-gap card" hidden style="padding:0.75rem;margin-top:0.75rem">
<input type="hidden" id="item-edit-id">
<h3 style="margin:0 0 0.5rem;font-size:0.95rem">Position ändern</h3>
<div class="grid-2">
<div class="field"><label for="item-edit-qty">Stück</label><input id="item-edit-qty" type="number" min="1" step="1" value="1"></div>
<div class="field"><label for="item-edit-price">Preis/Stk €</label><input id="item-edit-price" type="number" min="0" step="0.01" value="0"></div>
</div>
<div class="actions">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="item-edit-cancel">Abbrechen</button>
</div>
</form>
<form id="item-form" class="section-gap">
<div class="grid-2">
<div class="field"><label for="item-file">Datei</label><select id="item-file"><option value="">—</option></select></div>
<div class="field"><label for="item-qty">Stück</label><input id="item-qty" type="number" min="1" step="1" value="1"></div>
<div class="field"><label for="item-price">Preis/Stk €</label><input id="item-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="item-material">Material</label><input id="item-material"></div>
</div>
<div class="actions"><button type="submit">Position hinzufügen</button></div>
</form>
<details class="section-gap">
<summary>Kunde anlegen / bearbeiten</summary>
<form id="customer-form">
<input type="hidden" id="cust-id">
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
<div class="actions">
<button type="submit" class="btn-primary">Kunde speichern</button>
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
</div>
</form>
</details>
</div>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = {
idee: "Idee", angebot: "Angebot", bestaetigt: "bestätigt", druckt: "druckt",
fertig: "fertig", geliefert: "geliefert", bezahlt: "bezahlt",
};
let orders = [];
let customers = [];
let files = [];
let current = null;
/** idle | create | view | edit */
let panelMode = "idle";
let orderPage = 1;
document.getElementById("order-close").innerHTML = icon("x");
function openPanel() {
document.getElementById("order-panel").hidden = false;
document.getElementById("order-layout").classList.remove("is-idle");
document.getElementById("order-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function closePanel() {
panelMode = "idle";
current = null;
document.getElementById("order-panel").hidden = true;
document.getElementById("order-layout").classList.add("is-idle");
document.getElementById("order-form").hidden = true;
document.getElementById("order-detail").hidden = true;
document.getElementById("order-edit-btn").hidden = true;
document.getElementById("order-cancel-edit").hidden = true;
document.getElementById("order-delete").hidden = true;
document.getElementById("lieferschein-link").hidden = true;
document.getElementById("item-edit-form").hidden = true;
document.getElementById("order-note").textContent = "";
history.replaceState(null, "", location.pathname);
renderList();
}
function setFormValues(order) {
document.getElementById("order-id").value = order ? order.id : "";
document.getElementById("order-title-in").value = order ? order.title : "";
document.getElementById("order-status").value = order ? order.status : "idee";
document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : "";
document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : "";
document.getElementById("order-notes").value = order ? (order.notes || "") : "";
}
function fillCustomers() {
const sel = document.getElementById("order-customer");
const val = sel.value;
sel.innerHTML = '<option value="">— keiner —</option>';
customers.forEach((c) => {
const o = document.createElement("option");
o.value = c.id;
o.textContent = c.name;
sel.appendChild(o);
});
if (val) sel.value = val;
}
function fillFiles() {
const sel = document.getElementById("item-file");
sel.innerHTML = '<option value="">—</option>';
files.slice(0, 400).forEach((f) => {
const o = document.createElement("option");
o.value = f.id;
o.textContent = f.name;
sel.appendChild(o);
});
}
function paintActuals(order) {
const el = document.getElementById("order-actuals");
const a = order.actuals;
if (!a) { el.textContent = ""; return; }
const bits = [
"Ist: " + euro(a.cost_total),
hours(a.duration_s) || "0 min",
"Ausschuss " + a.pieces_bad + " Stk",
];
if (a.cost_per_part != null) bits.push(euro(a.cost_per_part) + "/Teil");
if (a.yield_percent != null) bits.push("Yield " + Math.round(a.yield_percent) + " %");
el.textContent = bits.join(" · ");
}
function showCreate() {
panelMode = "create";
current = null;
setFormValues(null);
document.getElementById("order-title").textContent = "Neuer Auftrag";
document.getElementById("order-form").hidden = false;
document.getElementById("order-detail").hidden = true;
document.getElementById("order-edit-btn").hidden = true;
document.getElementById("order-cancel-edit").hidden = true;
document.getElementById("order-delete").hidden = true;
document.getElementById("lieferschein-link").hidden = true;
document.getElementById("order-note").textContent = "";
document.getElementById("item-edit-form").hidden = true;
history.replaceState(null, "", location.pathname);
openPanel();
renderList();
document.getElementById("order-title-in").focus();
}
function showView(order) {
panelMode = "view";
current = order;
setFormValues(order);
document.getElementById("order-title").textContent = order.title || "Auftrag";
document.getElementById("order-form").hidden = true;
document.getElementById("order-detail").hidden = false;
document.getElementById("order-edit-btn").hidden = false;
document.getElementById("order-cancel-edit").hidden = true;
document.getElementById("order-delete").hidden = true;
document.getElementById("item-edit-form").hidden = true;
const link = document.getElementById("lieferschein-link");
link.href = "/auftraege/" + order.id + "/lieferschein";
link.hidden = false;
history.replaceState(null, "", "#" + order.id);
const prog = order.progress;
document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : "";
paintActuals(order);
const notes = (order.notes || "").trim();
document.getElementById("order-notes-view").textContent = notes ? "Notiz: " + notes : "";
renderItems(order.items || []);
openPanel();
renderList();
}
function showEdit(order) {
panelMode = "edit";
current = order;
setFormValues(order);
document.getElementById("order-title").textContent = "Bearbeiten";
document.getElementById("order-form").hidden = false;
document.getElementById("order-detail").hidden = true;
document.getElementById("order-edit-btn").hidden = true;
document.getElementById("order-cancel-edit").hidden = false;
document.getElementById("order-delete").hidden = false;
document.getElementById("item-edit-form").hidden = true;
openPanel();
renderList();
document.getElementById("order-title-in").focus();
}
function renderItems(items) {
const body = document.getElementById("item-rows");
body.innerHTML = "";
if (!items.length) {
body.innerHTML = '<tr><td colspan="4" class="empty">Keine Positionen.</td></tr>';
return;
}
items.forEach((item) => {
const tr = document.createElement("tr");
tr.innerHTML = "<td></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"actions\"></td>";
if (item.file_id) {
tr.children[0].innerHTML = linkTo("datei", item.file_id, item.file_name || ("#" + item.file_id));
} else {
tr.children[0].textContent = item.file_name || "—";
}
tr.children[1].textContent = item.qty;
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
const tools = document.createElement("div");
tools.className = "queue-tools";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ghost tiny-btn";
btn.textContent = "Einreihen";
btn.disabled = !item.file_id || !current;
btn.addEventListener("click", () => einreihen(item.id));
const edit = document.createElement("button");
edit.type = "button";
edit.className = "ghost tiny-btn";
edit.textContent = "Ändern";
edit.addEventListener("click", () => beginEditItem(item));
const del = document.createElement("button");
del.type = "button";
del.className = "btn-danger tiny-btn";
del.textContent = "Löschen";
del.addEventListener("click", (event) => {
armButton(event.currentTarget, "Position löschen?", async () => {
await api("/auftraege/positionen/" + item.id, { method: "DELETE" });
toast("Position gelöscht.");
const full = await api("/auftraege/" + current.id);
showView(full);
});
});
tools.append(btn, edit, del);
tr.children[3].appendChild(tools);
body.appendChild(tr);
});
}
function beginEditItem(item) {
document.getElementById("item-edit-id").value = item.id;
document.getElementById("item-edit-qty").value = item.qty || 1;
document.getElementById("item-edit-price").value = item.unit_price || 0;
document.getElementById("item-edit-form").hidden = false;
document.getElementById("item-edit-qty").focus();
}
async function einreihen(itemId) {
if (!current) return;
try {
await api("/auftraege/" + current.id + "/einreihen", {
method: "POST",
json: { item_id: itemId, when_free: true, confirm: true },
});
toast("In Warteschlange.");
const full = await api("/auftraege/" + current.id);
showView(full);
await loadList();
} catch (error) {
showNote("order-note", error);
}
}
function renderList() {
const body = document.getElementById("order-rows");
body.innerHTML = "";
const pager = document.getElementById("order-pager");
if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="btn-primary" id="order-empty-new">Ersten Auftrag anlegen</button></p></div></td></tr>';
document.getElementById("order-empty-new")?.addEventListener("click", showCreate);
renderPager(pager, null, () => {});
return;
}
const selected = current ? String(current.id) : "";
const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
orderPage = paged.page;
paged.items.forEach((order) => {
const tr = document.createElement("tr");
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
tr.innerHTML = "<td></td><td></td><td class=\"cell-path\"></td>";
tr.children[0].textContent = order.title;
const st = order.status || "";
const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle";
tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st);
tr.children[2].textContent = (order.progress && order.progress.label) || "";
tr.children[2].title = (order.progress && order.progress.label) || "";
tr.addEventListener("click", async () => {
try {
const full = await api("/auftraege/" + order.id);
showView(full);
} catch (error) {
showNote("order-note", error);
}
});
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
orderPage = p;
renderList();
});
}
async function loadList() {
const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q);
orderPage = 1;
renderList();
}
async function loadBase() {
[customers, files] = await Promise.all([
api("/kunden").catch(() => []),
api("/dateien").catch(() => []),
]);
fillCustomers();
fillFiles();
await loadList();
}
document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e)));
document.getElementById("order-new-head").addEventListener("click", showCreate);
document.getElementById("order-close").addEventListener("click", closePanel);
document.getElementById("order-edit-btn").addEventListener("click", () => {
if (current) showEdit(current);
});
document.getElementById("order-cancel-edit").addEventListener("click", async () => {
if (!current) {
closePanel();
return;
}
try {
const full = await api("/auftraege/" + current.id);
showView(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("order-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("order-id").value;
const payload = {
title: document.getElementById("order-title-in").value,
status: document.getElementById("order-status").value,
notes: document.getElementById("order-notes").value,
due_at: document.getElementById("order-due").value || null,
customer_id: document.getElementById("order-customer").value || null,
};
try {
const saved = id
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
: await api("/auftraege", { method: "POST", json: payload });
showNote("order-note", null);
toast("Gespeichert.", "good");
await loadList();
const full = await api("/auftraege/" + saved.id);
showView(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("order-delete").addEventListener("click", (event) => {
const id = document.getElementById("order-id").value;
if (!id) return;
armButton(event.currentTarget, "Auftrag wirklich löschen?", async () => {
await api("/auftraege/" + id, { method: "DELETE" });
toast("Gelöscht.");
closePanel();
loadList();
});
});
document.getElementById("item-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!current) {
showNote("order-note", new Error("Zuerst Auftrag speichern."));
return;
}
const payload = {
file_id: document.getElementById("item-file").value || null,
qty: Number(document.getElementById("item-qty").value),
unit_price: Number(document.getElementById("item-price").value),
material_type: document.getElementById("item-material").value,
};
try {
await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload });
const full = await api("/auftraege/" + current.id);
showView(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("item-edit-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!current) return;
const itemId = document.getElementById("item-edit-id").value;
try {
await api("/auftraege/positionen/" + itemId, {
method: "PATCH",
json: {
qty: Number(document.getElementById("item-edit-qty").value) || 1,
unit_price: Number(document.getElementById("item-edit-price").value) || 0,
},
});
document.getElementById("item-edit-form").hidden = true;
const full = await api("/auftraege/" + current.id);
showView(full);
toast("Position gespeichert.");
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("item-edit-cancel").addEventListener("click", () => {
document.getElementById("item-edit-form").hidden = true;
});
document.getElementById("customer-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("cust-id").value;
const payload = {
name: document.getElementById("cust-name").value,
email: document.getElementById("cust-email").value,
};
try {
const row = id
? await api("/kunden/" + id, { method: "PATCH", json: payload })
: await api("/kunden", { method: "POST", json: payload });
if (id) {
customers = customers.map((c) => (c.id === row.id ? row : c));
} else {
customers.push(row);
}
fillCustomers();
document.getElementById("order-customer").value = row.id;
document.getElementById("cust-id").value = "";
document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = "";
document.getElementById("cust-delete").hidden = true;
toast(id ? "Kunde gespeichert." : "Kunde angelegt.");
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("cust-edit-current").addEventListener("click", () => {
const id = document.getElementById("order-customer").value;
const row = customers.find((c) => String(c.id) === String(id));
if (!row) {
toast("Zuerst einen Kunden wählen.", "bad");
return;
}
document.getElementById("cust-id").value = row.id;
document.getElementById("cust-name").value = row.name || "";
document.getElementById("cust-email").value = row.email || "";
document.getElementById("cust-delete").hidden = false;
});
document.getElementById("cust-delete").addEventListener("click", (event) => {
const id = document.getElementById("cust-id").value;
if (!id) return;
armButton(event.currentTarget, "Kunde löschen?", async () => {
await api("/kunden/" + id, { method: "DELETE" });
customers = customers.filter((c) => String(c.id) !== String(id));
fillCustomers();
document.getElementById("cust-id").value = "";
document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = "";
document.getElementById("cust-delete").hidden = true;
toast("Kunde gelöscht.");
});
});
loadBase().then(async () => {
const hash = (location.hash || "").replace(/^#/, "");
if (hash && /^\d+$/.test(hash)) {
try {
const full = await api("/auftraege/" + hash);
showView(full);
} catch (error) {
showNote("order-note", error);
}
}
}).catch((error) => showNote("order-note", error));
</script>
{% endblock %}