source: src/main.js@ a4ca79c

main
Last change on this file since a4ca79c was a4ca79c, checked in by Georgi Paunkov <paunkovgeorgi@…>, 4 days ago

Initial commit

  • Property mode set to 100644
File size: 42.8 KB
Line 
1function tauriInvoke(cmd, args) {
2 const g = window.__TAURI__;
3 if (g && g.core && typeof g.core.invoke === "function") return g.core.invoke(cmd, args);
4 if (g && typeof g.invoke === "function") return g.invoke(cmd, args);
5 return Promise.reject(new Error("Tauri API не е достапно."));
6}
7
8const state = {
9 token: null,
10 user: null,
11 isAdmin: false,
12 isManager: false,
13 view: "tables",
14
15 settings: { restaurant_name: "HoReCa", currency: "MKD", vat_rate: "0" },
16
17 categories: [],
18 products: [],
19 activeCategoryId: null,
20
21 tables: [],
22
23 order: {
24 tableId: null,
25 tableNumber: null,
26 orderId: null,
27 items: [],
28 },
29
30 kitchenTimer: null,
31};
32
33const pinBuffer = { value: "" };
34
35const KIOSK_MODE = new URLSearchParams(window.location.search).get("mode") === "kitchen";
36if (KIOSK_MODE) document.documentElement.classList.add("kiosk-mode");
37
38function $(sel, root = document) { return root.querySelector(sel); }
39function $all(sel, root = document) { return Array.from(root.querySelectorAll(sel)); }
40
41function escapeHtml(str) {
42 return String(str ?? "").replace(/[&<>"']/g, (c) => ({
43 "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
44 }[c]));
45}
46
47function toNumber(v) {
48 if (v === null || v === undefined) return 0;
49 const n = typeof v === "string" ? parseFloat(v) : Number(v);
50 return Number.isFinite(n) ? n : 0;
51}
52
53function methodLabel(raw) {
54 const s = String(raw ?? "");
55 if (!s) return "—";
56 return s.charAt(0) + s.slice(1).toLowerCase();
57}
58
59function money(v) {
60 const n = toNumber(v);
61 return `${n.toFixed(2)} ${state.settings.currency || ""}`.trim();
62}
63
64function toast(message, type = "info") {
65 const stack = $("#toast-stack");
66 const el = document.createElement("div");
67 el.className = `toast toast--${type}`;
68 el.textContent = message;
69 stack.appendChild(el);
70 setTimeout(() => el.remove(), 4200);
71}
72
73function errMsg(e) {
74 if (typeof e === "string") return e;
75 if (e && e.message) return e.message;
76 return "Настана непозната грешка.";
77}
78
79async function call(cmd, args, opts = {}) {
80 try {
81 return await tauriInvoke(cmd, args);
82 } catch (e) {
83 if (!opts.silent) toast(errMsg(e), "error");
84 throw e;
85 }
86}
87
88function openModal(innerHtml) {
89 const root = $("#modal-root");
90 root.innerHTML = `<button class="modal-close" data-modal-close>&times;</button>${innerHtml}`;
91 $("#modal-overlay").hidden = false;
92}
93function closeModal() {
94 $("#modal-overlay").hidden = true;
95 $("#modal-root").innerHTML = "";
96}
97document.addEventListener("click", (e) => {
98 if (e.target === $("#modal-overlay") || e.target.closest("[data-modal-close]")) closeModal();
99});
100
101$("#win-min").addEventListener("click", () => call("minimize_window", {}, { silent: true }));
102$("#win-close").addEventListener("click", () => call("close_window", {}, { silent: true }));
103$("#win-max").addEventListener("click", async () => {
104 try {
105 const maxed = await tauriInvoke("is_maximized", {});
106 await tauriInvoke(maxed ? "unmaximize_window" : "maximize_window", {});
107 } catch (_) { }
108});
109
110function tickClock() {
111 const now = new Date();
112 $("#sidebar-clock").textContent = now.toLocaleTimeString("mk-MK", { hour: "2-digit", minute: "2-digit" });
113}
114setInterval(tickClock, 1000 * 20);
115
116function renderPinDots() {
117 const dots = $all(".pin-dot");
118 dots.forEach((d, i) => d.classList.toggle("is-filled", i < pinBuffer.value.length));
119}
120
121$all(".keypad__key").forEach((btn) => {
122 btn.addEventListener("click", () => {
123 const key = btn.dataset.key;
124 if (key === "clear") pinBuffer.value = "";
125 else if (key === "back") pinBuffer.value = pinBuffer.value.slice(0, -1);
126 else if (pinBuffer.value.length < 6) pinBuffer.value += key;
127 renderPinDots();
128 $("#login-error").textContent = "";
129 });
130});
131
132document.addEventListener("keydown", (e) => {
133 if (!$("#view-login").classList.contains("is-active")) return;
134 if (/^[0-9]$/.test(e.key) && pinBuffer.value.length < 6) { pinBuffer.value += e.key; renderPinDots(); }
135 else if (e.key === "Backspace") { pinBuffer.value = pinBuffer.value.slice(0, -1); renderPinDots(); }
136 else if (e.key === "Enter") doLogin();
137});
138
139$("#btn-login").addEventListener("click", doLogin);
140
141async function doLogin() {
142 const pin = pinBuffer.value;
143 if (!pin) { $("#login-error").textContent = "Внесете ПИН"; return; }
144
145 const btn = $("#btn-login");
146 btn.disabled = true;
147 $("#btn-login-label").textContent = "Најавување…";
148 $("#login-error").textContent = "";
149
150 try {
151 const res = await tauriInvoke("login", { pin });
152 state.token = res.token;
153 state.user = res;
154 state.isAdmin = /admin|админ/i.test(res.role_name || "");
155 state.isManager = /admin|админ|manager|менаџер/i.test(res.role_name || "");
156 enterApp();
157 } catch (e) {
158 $("#login-error").textContent = errMsg(e);
159 pinBuffer.value = "";
160 renderPinDots();
161 } finally {
162 btn.disabled = false;
163 $("#btn-login-label").textContent = "Најави се";
164 }
165}
166
167function enterApp() {
168 pinBuffer.value = "";
169 renderPinDots();
170 $("#view-login").classList.remove("is-active");
171 $("#shell").hidden = false;
172
173 $("#tb-session").hidden = false;
174 $("#tb-user-name").textContent = state.user.name || `${state.user.first_name} ${state.user.last_name}`.trim();
175 $("#tb-user-role").textContent = state.user.role_name;
176 $("#nav-admin-group").style.display = state.isManager ? "" : "none";
177
178 if (KIOSK_MODE) {
179 switchView("kitchen");
180 return;
181 }
182
183 switchView("tables");
184 loadTables();
185 loadCategories();
186}
187
188$("#btn-logout").addEventListener("click", () => {
189 state.token = null;
190 state.user = null;
191 state.isAdmin = false;
192 state.isManager = false;
193 clearInterval(state.kitchenTimer);
194 $("#shell").hidden = true;
195 $("#tb-session").hidden = true;
196 $("#tb-user-name").textContent = "—";
197 $("#tb-user-role").textContent = "—";
198 $("#view-login").classList.add("is-active");
199});
200
201const ADMIN_VIEWS = new Set(["inventory", "reports", "products", "employees", "settings"]);
202
203function switchView(view) {
204 if (ADMIN_VIEWS.has(view) && !state.isManager) return;
205 if (KIOSK_MODE && view !== "kitchen") return;
206 state.view = view;
207
208 $all(".view-panel").forEach((p) => p.classList.remove("is-active"));
209 $all(".nav-item").forEach((n) => n.classList.toggle("is-active", n.dataset.view === view));
210 const panel = $(`#panel-${view}`);
211 if (panel) panel.classList.add("is-active");
212
213 if (view === "kitchen") { loadKitchen(); startKitchenPolling(); }
214 else stopKitchenPolling();
215
216 if (view === "inventory") loadInventory();
217 if (view === "reports") loadReports();
218 if (view === "products") loadAdminMenu();
219 if (view === "employees") loadEmployees();
220 if (view === "settings") loadSettings();
221 if (view === "tables") loadTables();
222}
223
224$all(".nav-item").forEach((btn) => btn.addEventListener("click", () => switchView(btn.dataset.view)));
225
226async function loadSettingsQuiet() {
227 try {
228 const s = await tauriInvoke("get_settings", {});
229 state.settings = s;
230 $("#tb-restaurant-name").textContent = s.restaurant_name || "HoReCa";
231 } catch (_) { }
232}
233
234async function loadTables() {
235 if (!state.token) return;
236 try {
237 state.tables = await call("get_tables", { token: state.token });
238 renderTables();
239 } catch (_) { }
240}
241
242function statusClass(status) {
243 if (status === "ЗАФАТЕНА") return "occupied";
244 if (status === "РЕЗЕРВИРАНА") return "reserved";
245 return "free";
246}
247function statusLabel(status) {
248 if (status === "ЗАФАТЕНА") return "Зафатена";
249 if (status === "РЕЗЕРВИРАНА") return "Резервирана";
250 return "Слободна";
251}
252
253function renderTables() {
254 const grid = $("#tables-grid");
255 if (!state.tables.length) {
256 grid.innerHTML = `<p class="empty-hint">Нема дефинирани маси.</p>`;
257 return;
258 }
259 grid.innerHTML = state.tables.map((t) => {
260 const cls = statusClass(t.status);
261 const reserveBtn = t.status === "СЛОБОДНА"
262 ? `<button class="btn btn--ghost btn--sm" data-action="reserve" title="Резервирај маса">Резервирај</button>`
263 : "";
264 return `
265 <div class="table-card table-card--${cls}" data-table-id="${t.table_id}" data-table-number="${escapeHtml(t.table_number)}" data-status="${escapeHtml(t.status)}" role="button" tabindex="0">
266 <span class="table-card__status"></span>
267 <div class="table-card__number">Маса ${escapeHtml(t.table_number)}</div>
268 <div class="table-card__capacity">${t.capacity} места · ${statusLabel(t.status)}</div>
269 ${reserveBtn}
270 </div>`;
271 }).join("");
272}
273
274$("#tables-grid").addEventListener("click", async (e) => {
275 const card = e.target.closest(".table-card");
276 if (!card) return;
277 const tableId = Number(card.dataset.tableId);
278 const tableNumber = card.dataset.tableNumber;
279 const status = card.dataset.status;
280
281 if (e.target.closest('[data-action="reserve"]')) {
282 e.stopPropagation();
283 try {
284 await call("reserve_table", { token: state.token, tableId });
285 toast(`Масата ${tableNumber} е резервирана.`, "success");
286 loadTables();
287 } catch (_) {}
288 return;
289 }
290
291 if (status === "РЕЗЕРВИРАНА") {
292 openReservedTableModal(tableId, tableNumber);
293 return;
294 }
295 await openTable(tableId, tableNumber, status);
296});
297
298function openReservedTableModal(tableId, tableNumber) {
299 openModal(`
300 <h2>Маса ${escapeHtml(tableNumber)}</h2>
301 <p class="modal-sub">Оваа маса е резервирана.</p>
302 <div class="modal-actions" style="justify-content:flex-start; flex-wrap:wrap;">
303 <button class="btn btn--primary" id="m-open-order">Отвори нарачка</button>
304 <button class="btn btn--secondary" id="m-cancel-reservation">Откажи резервација</button>
305 </div>
306 `);
307 $("#m-open-order").addEventListener("click", async () => {
308 closeModal();
309 await openTable(tableId, tableNumber, "РЕЗЕРВИРАНА");
310 });
311 $("#m-cancel-reservation").addEventListener("click", async () => {
312 try {
313 await call("cancel_reservation", { token: state.token, tableId: tableId });
314 toast("Резервацијата е откажана.", "success");
315 closeModal();
316 loadTables();
317 } catch (_) {}
318 });
319}
320
321async function openTable(tableId, tableNumber, status) {
322 try {
323 let orderId;
324 if (status === "ЗАФАТЕНА") {
325 orderId = await call("get_active_order", { tableId: tableId });
326 if (orderId === null || orderId === undefined) {
327 orderId = await call("create_order", { userId: state.user.user_id, tableId: tableId });
328 }
329 } else {
330 orderId = await call("create_order", { userId: state.user.user_id, tableId: tableId });
331 }
332
333 state.order.tableId = tableId;
334 state.order.tableNumber = tableNumber;
335 state.order.orderId = orderId;
336
337 $("#order-table-title").textContent = `Маса ${tableNumber}`;
338 switchView("order");
339 await Promise.all([loadCategories(), loadCartItems()]);
340 renderCategoryTabs();
341 renderProductGrid();
342 } catch (_) {}
343}
344
345$("#btn-back-to-tables").addEventListener("click", () => switchView("tables"));
346
347async function loadCategories() {
348 try {
349 state.categories = await call("get_categories", {}, { silent: true });
350 } catch (_) { state.categories = []; }
351}
352
353async function loadAllProducts() {
354 try {
355 state.products = await call("get_products", { token: state.token });
356 } catch (_) { state.products = []; }
357}
358
359function renderCategoryTabs() {
360 const wrap = $("#category-tabs");
361 const tabs = [`<button class="category-tab ${state.activeCategoryId === null ? "is-active" : ""}" data-cat="all">Сите</button>`]
362 .concat(state.categories.map((c) => `
363 <button class="category-tab ${state.activeCategoryId === c.category_id ? "is-active" : ""}" data-cat="${c.category_id}">${escapeHtml(c.name)}</button>
364 `));
365 wrap.innerHTML = tabs.join("");
366}
367
368$("#category-tabs").addEventListener("click", async (e) => {
369 const btn = e.target.closest(".category-tab");
370 if (!btn) return;
371 state.activeCategoryId = btn.dataset.cat === "all" ? null : Number(btn.dataset.cat);
372 renderCategoryTabs();
373 await renderProductGrid();
374});
375
376async function renderProductGrid() {
377 const grid = $("#product-grid");
378 grid.innerHTML = `<p class="empty-hint">Вчитување…</p>`;
379 try {
380 const products = state.activeCategoryId === null
381 ? await call("get_products", { token: state.token }, { silent: true })
382 : await call("get_products_by_category", { token: state.token, categoryId: state.activeCategoryId }, { silent: true });
383
384 if (!products.length) {
385 grid.innerHTML = `<p class="empty-hint">Нема производи во оваа категорија.</p>`;
386 return;
387 }
388 grid.innerHTML = products.map((p) => `
389 <button class="product-card" data-product-id="${p.product_id}">
390 <span class="product-card__name">${escapeHtml(p.name)}</span>
391 <span class="product-card__desc">${escapeHtml(p.description || "")}</span>
392 <span class="product-card__price">${money(p.price)}</span>
393 </button>
394 `).join("");
395 } catch (_) {
396 grid.innerHTML = `<p class="empty-hint">Грешка при вчитување.</p>`;
397 }
398}
399
400$("#product-grid").addEventListener("click", async (e) => {
401 const card = e.target.closest(".product-card");
402 if (!card || !state.order.orderId) return;
403 const productId = Number(card.dataset.productId);
404 try {
405 await call("add_order_item", {
406 token: state.token, orderId: state.order.orderId, productId, quantity: 1,
407 });
408 await loadCartItems();
409 } catch (_) {}
410});
411
412async function loadCartItems() {
413 if (!state.order.orderId) return;
414 try {
415 state.order.items = await call("get_order_items", { token: state.token, orderId: state.order.orderId }, { silent: true });
416 renderCart();
417 } catch (_) {}
418}
419
420function renderCart() {
421 const wrap = $("#cart-items");
422 const items = state.order.items;
423 if (!items.length) {
424 wrap.innerHTML = `<p class="empty-hint">Нема ставки во нарачката</p>`;
425 } else {
426 wrap.innerHTML = items.map((it) => `
427 <div class="cart-item" data-product-id="${it.product_id}">
428 <div class="cart-item__info">
429 <div class="cart-item__name">${escapeHtml(it.product_name)}</div>
430 <div class="cart-item__price">${money(it.unit_price)} / ед.</div>
431 </div>
432 <div class="cart-item__qty">
433 <button class="qty-btn" data-action="dec">−</button>
434 <span class="cart-item__qty-val">${it.quantity}</span>
435 <button class="qty-btn" data-action="inc">+</button>
436 </div>
437 <button class="cart-item__remove" data-action="remove" title="Отстрани">✕</button>
438 </div>
439 `).join("");
440 }
441
442 const total = items.reduce((sum, it) => sum + toNumber(it.quantity) * toNumber(it.unit_price), 0);
443 $("#cart-total").textContent = money(total);
444 $("#order-status-chip").textContent = items.length ? "Активна нарачка" : "Нова нарачка";
445}
446
447$("#cart-items").addEventListener("click", async (e) => {
448 const row = e.target.closest(".cart-item");
449 if (!row) return;
450 const action = e.target.closest("[data-action]")?.dataset.action;
451 if (!action) return;
452 const productId = Number(row.dataset.productId);
453 const item = state.order.items.find((i) => i.product_id === productId);
454 if (!item) return;
455
456 try {
457 if (action === "inc") {
458 await call("update_order_quantity", { orderId: state.order.orderId, productId, quantity: item.quantity + 1 });
459 } else if (action === "dec") {
460 if (item.quantity <= 1) {
461 await call("delete_order_item", { orderId: state.order.orderId, productId });
462 } else {
463 await call("update_order_quantity", { orderId: state.order.orderId, productId, quantity: item.quantity - 1 });
464 }
465 } else if (action === "remove") {
466 await call("delete_order_item", { orderId: state.order.orderId, productId });
467 }
468 await loadCartItems();
469 } catch (_) {}
470});
471
472$("#btn-pay").addEventListener("click", () => {
473 if (!state.order.items.length) { toast("Нарачката е празна.", "error"); return; }
474 let selected = "КЕШ";
475 openModal(`
476 <h2>Наплата — Маса ${escapeHtml(state.order.tableNumber)}</h2>
477 <p class="modal-sub">Вкупен износ: <strong>${money(state.order.items.reduce((s, i) => s + toNumber(i.quantity) * toNumber(i.unit_price), 0))}</strong></p>
478 <div class="pay-methods">
479 <button class="pay-method is-selected" data-method="КЕШ">Кеш</button>
480 <button class="pay-method" data-method="КАРТИЧКА">Картичка</button>
481 </div>
482 <div class="modal-actions">
483 <button class="btn btn--secondary" data-modal-close>Откажи</button>
484 <button class="btn btn--primary" id="m-confirm-pay">Потврди наплата</button>
485 </div>
486 `);
487 $all(".pay-method").forEach((b) => b.addEventListener("click", () => {
488 $all(".pay-method").forEach((x) => x.classList.remove("is-selected"));
489 b.classList.add("is-selected");
490 selected = b.dataset.method;
491 }));
492 $("#m-confirm-pay").addEventListener("click", async () => {
493 try {
494 const invoiceNumber = await call("pay_order", { orderId: state.order.orderId, method: selected });
495 toast("Нарачката е наплатена.", "success");
496 await showInvoice(invoiceNumber);
497 state.order = { tableId: null, tableNumber: null, orderId: null, items: [] };
498 loadTables();
499 } catch (_) {}
500 });
501});
502
503async function showInvoice(invoiceNumber) {
504 try {
505 const inv = await call("get_invoice", { token: state.token, invoiceNumber });
506 const itemsHtml = inv.items.map((it) => `
507 <div class="receipt__line">
508 <span>${escapeHtml(it.name)} × ${it.quantity}</span>
509 <span>${money(toNumber(it.unit_price) * toNumber(it.quantity))}</span>
510 </div>
511 `).join("");
512 openModal(`
513 <h2>Фактура издадена</h2>
514 <div class="receipt">
515 <div class="receipt__title">${escapeHtml(inv.restaurant_name)}</div>
516 <div class="receipt__meta">${escapeHtml(inv.invoice_number)} · ${new Date(inv.issued_at).toLocaleString("mk-MK")}</div>
517 <div class="receipt__rule"></div>
518 ${itemsHtml}
519 <div class="receipt__rule"></div>
520 <div class="receipt__line"><span>Начин на плаќање</span><span>${escapeHtml(methodLabel(inv.method))}</span></div>
521 <div class="receipt__line"><span>ДДВ</span><span>${escapeHtml(inv.vat_rate)}%</span></div>
522 <div class="receipt__rule"></div>
523 <div class="receipt__total"><span>Вкупно</span><span>${inv.total_amount} ${escapeHtml(inv.currency)}</span></div>
524 </div>
525 <div class="modal-actions">
526 <button class="btn btn--secondary" id="m-print-invoice">Печати</button>
527 <button class="btn btn--primary" data-modal-close>Готово</button>
528 </div>
529 `);
530 $("#m-print-invoice").addEventListener("click", () => window.print());
531 } catch (_) {
532 closeModal();
533 }
534}
535
536function startKitchenPolling() {
537 stopKitchenPolling();
538 state.kitchenTimer = setInterval(loadKitchen, KIOSK_MODE ? 5000 : 15000);
539}
540function stopKitchenPolling() {
541 if (state.kitchenTimer) clearInterval(state.kitchenTimer);
542 state.kitchenTimer = null;
543}
544
545async function loadKitchen() {
546 try {
547 const rows = await call("get_kitchen_orders", {}, { silent: true });
548 renderKitchen(rows);
549 } catch (_) {}
550}
551
552function renderKitchen(rows) {
553 const grid = $("#kitchen-grid");
554 if (!rows.length) {
555 grid.innerHTML = `<p class="empty-hint">Нема активни нарачки.</p>`;
556 return;
557 }
558 const byOrder = new Map();
559 for (const r of rows) {
560 if (!byOrder.has(r.order_id)) {
561 byOrder.set(r.order_id, { table_number: r.table_number, created_at: r.created_at, items: [] });
562 }
563 byOrder.get(r.order_id).items.push(r);
564 }
565
566 grid.innerHTML = Array.from(byOrder.entries()).map(([orderId, o]) => `
567 <div class="ticket" data-order-id="${orderId}">
568 <div class="ticket__head">
569 <span class="ticket__table">Маса ${escapeHtml(o.table_number)}</span>
570 <span class="ticket__time">${new Date(o.created_at).toLocaleTimeString("mk-MK", { hour: "2-digit", minute: "2-digit" })}</span>
571 </div>
572 <div class="ticket__items">
573 ${o.items.map((it) => `
574 <div class="ticket__item">
575 <span>${escapeHtml(it.product_name)}</span>
576 <span class="ticket__item-qty">×${it.quantity}</span>
577 </div>
578 `).join("")}
579 </div>
580 <div class="ticket__footer">
581 <button class="btn btn--secondary btn--sm btn--block" data-action="ready">Спремно</button>
582 </div>
583 </div>
584 `).join("");
585}
586
587$("#kitchen-grid").addEventListener("click", async (e) => {
588 if (!e.target.closest('[data-action="ready"]')) return;
589 const ticket = e.target.closest(".ticket");
590 const orderId = Number(ticket.dataset.orderId);
591 try {
592 await call("mark_kitchen_order_ready", { orderId });
593 ticket.remove();
594 toast("Нарачката е означена како спремна.", "success");
595 } catch (_) {}
596});
597
598$("#btn-refresh-kitchen").addEventListener("click", loadKitchen);
599
600let inventoryCache = [];
601
602async function loadInventory() {
603 try {
604 inventoryCache = await call("get_inventory", {}, { silent: true });
605 renderInventory();
606 } catch (_) {}
607}
608
609function renderInventory() {
610 const onlyLow = $("#toggle-low-stock").checked;
611 const query = $("#inventory-search").value.trim().toLowerCase();
612
613 let rows = inventoryCache;
614 if (onlyLow) rows = rows.filter((r) => r.quantity <= r.min_stock);
615 if (query) rows = rows.filter((r) => r.name.toLowerCase().includes(query));
616
617 const tbody = $("#inventory-table tbody");
618
619 if (!rows.length) {
620 tbody.innerHTML = `<tr><td colspan="5" class="empty-hint">Нема резултати.</td></tr>`;
621 return;
622 }
623
624 tbody.innerHTML = rows.map((r) => {
625 const low = r.quantity <= r.min_stock;
626 return `
627 <tr data-product-id="${r.product_id}">
628 <td>${escapeHtml(r.name)}</td>
629 <td class="num">${r.quantity}</td>
630 <td class="num">${r.min_stock}</td>
631 <td><span class="badge ${low ? "badge--warn" : "badge--ok"}">${low ? "Критично" : "Во ред"}</span></td>
632 <td class="row-actions">
633 ${state.isManager ? `<button class="btn btn--ghost btn--sm" data-action="restock">Дополни</button>` : ""}
634 </td>
635 </tr>`;
636 }).join("");
637}
638
639$("#toggle-low-stock").addEventListener("change", renderInventory);
640$("#inventory-search").addEventListener("input", renderInventory);
641
642$("#inventory-table tbody").addEventListener("click", (e) => {
643 if (!e.target.closest('[data-action="restock"]')) return;
644 const tr = e.target.closest("tr");
645 const productId = Number(tr.dataset.productId);
646 const name = tr.children[0].textContent;
647 openModal(`
648 <h2>Дополни залиха</h2>
649 <p class="modal-sub">${escapeHtml(name)}</p>
650 <form class="modal-form" id="m-restock-form">
651 <label class="field"><span>Количина</span><input type="number" id="m-restock-qty" min="1" value="1" required /></label>
652 <div class="modal-actions">
653 <button type="button" class="btn btn--secondary" data-modal-close>Откажи</button>
654 <button type="submit" class="btn btn--primary">Дополни</button>
655 </div>
656 </form>
657 `);
658 $("#m-restock-form").addEventListener("submit", async (ev) => {
659 ev.preventDefault();
660 const qty = Number($("#m-restock-qty").value);
661 try {
662 await call("restock_product", { token: state.token, productId, quantity: qty });
663 toast("Залихата е дополнета.", "success");
664 closeModal();
665 loadInventory();
666 } catch (_) {}
667 });
668});
669
670async function loadReports() {
671 try {
672 const [sales, orders, best, perf] = await Promise.all([
673 call("today_sales", {}, { silent: true }),
674 call("today_orders", {}, { silent: true }),
675 call("best_product", {}, { silent: true }),
676 call("employee_performance", { token: state.token }, { silent: true }),
677 ]);
678 $("#stat-today-sales").textContent = money(sales);
679 $("#stat-today-orders").textContent = orders;
680 $("#stat-best-product").textContent = best || "—";
681 renderEmployeePerf(perf);
682 } catch (_) {}
683}
684
685function renderEmployeePerf(rows) {
686 const tbody = $("#employee-perf-table tbody");
687 if (!rows.length) { tbody.innerHTML = `<tr><td colspan="3" class="empty-hint">Нема податоци.</td></tr>`; return; }
688 tbody.innerHTML = rows.map((r) => `
689 <tr>
690 <td>${escapeHtml(r.name)}</td>
691 <td class="num">${r.order_count}</td>
692 <td class="num">${money(r.total)}</td>
693 </tr>
694 `).join("");
695}
696
697$("#btn-z-report").addEventListener("click", () => {
698 openModal(`
699 <h2>Затвори смена</h2>
700 <p class="modal-sub">Ова ќе генерира Z-извештај за периодот од последното затворање. Дејството не може да се врати.</p>
701 <div class="modal-actions">
702 <button class="btn btn--secondary" data-modal-close>Откажи</button>
703 <button class="btn btn--danger" id="m-confirm-z">Затвори смена</button>
704 </div>
705 `);
706 $("#m-confirm-z").addEventListener("click", async () => {
707 try {
708 const r = await call("z_report", { token: state.token });
709 openModal(`
710 <h2>Z-извештај</h2>
711 <div class="receipt">
712 <div class="receipt__line"><span>Период од</span><span>${new Date(r.since).toLocaleString("mk-MK")}</span></div>
713 <div class="receipt__line"><span>Број нарачки</span><span>${r.order_count}</span></div>
714 <div class="receipt__rule"></div>
715 <div class="receipt__total"><span>Вкупно</span><span>${money(r.total)}</span></div>
716 </div>
717 <div class="modal-actions">
718 <button class="btn btn--secondary" id="m-print-z">Печати</button>
719 <button class="btn btn--primary" data-modal-close>Готово</button>
720 </div>
721 `);
722 $("#m-print-z").addEventListener("click", () => window.print());
723 loadReports();
724 } catch (_) {}
725 });
726});
727
728$("#search-orders-form").addEventListener("submit", async (e) => {
729 e.preventDefault();
730 const query = $("#search-query").value.trim();
731 const start = $("#search-start").value || null;
732 const end = $("#search-end").value || null;
733 try {
734 const rows = await call("search_orders", { token: state.token, query, startDate: start, endDate: end });
735 const tbody = $("#search-orders-table tbody");
736 if (!rows.length) { tbody.innerHTML = `<tr><td colspan="4" class="empty-hint">Нема резултати.</td></tr>`; return; }
737 tbody.innerHTML = rows.map((r) => `
738 <tr>
739 <td>${escapeHtml(r.invoice)}</td>
740 <td>${new Date(r.date).toLocaleDateString("mk-MK")}</td>
741 <td>${escapeHtml(r.employee)}</td>
742 <td class="num">${money(r.amount)}</td>
743 </tr>
744 `).join("");
745 } catch (_) {}
746});
747
748async function loadAdminMenu() {
749 await loadCategories();
750 renderCategoriesTable();
751 await loadAllProducts();
752 renderProductsTable();
753}
754
755function renderCategoriesTable() {
756 const tbody = $("#categories-table tbody");
757 if (!state.categories.length) { tbody.innerHTML = `<tr><td colspan="3" class="empty-hint">Нема категории.</td></tr>`; return; }
758 tbody.innerHTML = state.categories.map((c) => `
759 <tr data-id="${c.category_id}">
760 <td>${escapeHtml(c.name)}</td>
761 <td>${escapeHtml(c.description || "")}</td>
762 <td class="row-actions">
763 <button class="btn btn--ghost btn--sm" data-action="edit-cat">Уреди</button>
764 <button class="btn btn--ghost btn--sm" data-action="delete-cat">Избриши</button>
765 </td>
766 </tr>
767 `).join("");
768}
769
770function categoryModal(existing) {
771 openModal(`
772 <h2>${existing ? "Уреди категорија" : "Нова категорија"}</h2>
773 <form class="modal-form" id="m-category-form">
774 <label class="field"><span>Име</span><input type="text" id="m-cat-name" value="${existing ? escapeHtml(existing.name) : ""}" required /></label>
775 <label class="field"><span>Опис</span><input type="text" id="m-cat-desc" value="${existing ? escapeHtml(existing.description || "") : ""}" /></label>
776 <div class="modal-actions">
777 <button type="button" class="btn btn--secondary" data-modal-close>Откажи</button>
778 <button type="submit" class="btn btn--primary">Зачувај</button>
779 </div>
780 </form>
781 `);
782 $("#m-category-form").addEventListener("submit", async (e) => {
783 e.preventDefault();
784 const name = $("#m-cat-name").value.trim();
785 const description = $("#m-cat-desc").value.trim();
786 try {
787 if (existing) {
788 await call("update_category", { token: state.token, categoryId: existing.category_id, name, description });
789 } else {
790 await call("create_category", { token: state.token, name, description });
791 }
792 toast("Категоријата е зачувана.", "success");
793 closeModal();
794 await loadCategories();
795 renderCategoriesTable();
796 } catch (_) {}
797 });
798}
799
800$("#btn-new-category").addEventListener("click", () => categoryModal(null));
801
802$("#categories-table tbody").addEventListener("click", async (e) => {
803 const tr = e.target.closest("tr");
804 if (!tr) return;
805 const id = Number(tr.dataset.id);
806 const cat = state.categories.find((c) => c.category_id === id);
807 if (e.target.closest('[data-action="edit-cat"]')) categoryModal(cat);
808 if (e.target.closest('[data-action="delete-cat"]')) {
809 openModal(`
810 <h2>Избриши категорија</h2>
811 <p class="modal-sub">Дали сте сигурни дека сакате да ја избришете „${escapeHtml(cat.name)}“?</p>
812 <div class="modal-actions">
813 <button class="btn btn--secondary" data-modal-close>Откажи</button>
814 <button class="btn btn--danger" id="m-confirm-del-cat">Избриши</button>
815 </div>
816 `);
817 $("#m-confirm-del-cat").addEventListener("click", async () => {
818 try {
819 await call("delete_category", { token: state.token, categoryId: id });
820 toast("Категоријата е избришана.", "success");
821 closeModal();
822 await loadCategories();
823 renderCategoriesTable();
824 } catch (_) {}
825 });
826 }
827});
828
829function renderProductsTable() {
830 const tbody = $("#products-table tbody");
831 if (!state.products.length) { tbody.innerHTML = `<tr><td colspan="4" class="empty-hint">Нема производи.</td></tr>`; return; }
832
833 const catName = (id) => state.categories.find((c) => c.category_id === id)?.name || "—";
834
835 const query = $("#product-search").value.trim().toLowerCase();
836 let rows = state.products;
837 if (query) {
838 rows = rows.filter((p) =>
839 p.name.toLowerCase().includes(query) ||
840 catName(p.category_id).toLowerCase().includes(query)
841 );
842 }
843
844 if (!rows.length) { tbody.innerHTML = `<tr><td colspan="4" class="empty-hint">Нема резултати.</td></tr>`; return; }
845
846 tbody.innerHTML = rows.map((p) => `
847 <tr data-id="${p.product_id}">
848 <td>${escapeHtml(p.name)}</td>
849 <td>${escapeHtml(catName(p.category_id))}</td>
850 <td class="num">${money(p.price)}</td>
851 <td class="row-actions">
852 <button class="btn btn--ghost btn--sm" data-action="edit-prod">Уреди</button>
853 <button class="btn btn--ghost btn--sm" data-action="delete-prod">Избриши</button>
854 </td>
855 </tr>
856 `).join("");
857}
858
859$("#product-search").addEventListener("input", renderProductsTable);
860
861function productModal(existing) {
862 const options = state.categories.map((c) => `<option value="${c.category_id}" ${existing && existing.category_id === c.category_id ? "selected" : ""}>${escapeHtml(c.name)}</option>`).join("");
863 openModal(`
864 <h2>${existing ? "Уреди производ" : "Нов производ"}</h2>
865 <form class="modal-form" id="m-product-form">
866 <label class="field"><span>Име</span><input type="text" id="m-prod-name" value="${existing ? escapeHtml(existing.name) : ""}" required /></label>
867 <label class="field"><span>Категорија</span><select id="m-prod-cat" required>${options}</select></label>
868 <label class="field"><span>Опис</span><input type="text" id="m-prod-desc" value="${existing ? escapeHtml(existing.description || "") : ""}" /></label>
869 <label class="field"><span>Цена</span><input type="number" step="0.01" min="0" id="m-prod-price" value="${existing ? toNumber(existing.price) : ""}" required /></label>
870 <div class="modal-actions">
871 <button type="button" class="btn btn--secondary" data-modal-close>Откажи</button>
872 <button type="submit" class="btn btn--primary">Зачувај</button>
873 </div>
874 </form>
875 `);
876 $("#m-product-form").addEventListener("submit", async (e) => {
877 e.preventDefault();
878 const name = $("#m-prod-name").value.trim();
879 const categoryId = Number($("#m-prod-cat").value);
880 const description = $("#m-prod-desc").value.trim();
881 const price = Number($("#m-prod-price").value);
882 try {
883 if (existing) {
884 await call("update_product", { token: state.token, productId: existing.product_id, categoryId, name, description, price });
885 } else {
886 await call("add_product", { token: state.token, categoryId, name, description, price });
887 }
888 toast("Производот е зачуван.", "success");
889 closeModal();
890 await loadAllProducts();
891 renderProductsTable();
892 } catch (_) {}
893 });
894}
895
896$("#btn-new-product").addEventListener("click", () => {
897 if (!state.categories.length) { toast("Прво креирајте категорија.", "error"); return; }
898 productModal(null);
899});
900
901$("#products-table tbody").addEventListener("click", (e) => {
902 const tr = e.target.closest("tr");
903 if (!tr) return;
904 const id = Number(tr.dataset.id);
905 const prod = state.products.find((p) => p.product_id === id);
906 if (e.target.closest('[data-action="edit-prod"]')) productModal(prod);
907 if (e.target.closest('[data-action="delete-prod"]')) {
908 openModal(`
909 <h2>Избриши производ</h2>
910 <p class="modal-sub">Дали сте сигурни дека сакате да го избришете „${escapeHtml(prod.name)}“?</p>
911 <div class="modal-actions">
912 <button class="btn btn--secondary" data-modal-close>Откажи</button>
913 <button class="btn btn--danger" id="m-confirm-del-prod">Избриши</button>
914 </div>
915 `);
916 $("#m-confirm-del-prod").addEventListener("click", async () => {
917 try {
918 await call("delete_product", { token: state.token, productId: id });
919 toast("Производот е избришан.", "success");
920 closeModal();
921 await loadAllProducts();
922 renderProductsTable();
923 } catch (_) {}
924 });
925 }
926});
927
928let rolesCache = [];
929
930async function loadEmployees() {
931 try {
932 const [emps, roles] = await Promise.all([
933 call("get_employees", { token: state.token }, { silent: true }),
934 call("get_roles", {}, { silent: true }),
935 ]);
936 rolesCache = roles;
937 renderEmployeesTable(emps);
938 } catch (_) {}
939}
940
941function renderEmployeesTable(emps) {
942 const tbody = $("#employees-table tbody");
943 if (!emps.length) { tbody.innerHTML = `<tr><td colspan="4" class="empty-hint">Нема вработени.</td></tr>`; return; }
944 tbody.innerHTML = emps.map((e) => `
945 <tr data-id="${e.id}">
946 <td>${escapeHtml(e.first_name)} ${escapeHtml(e.last_name)}</td>
947 <td>
948 <select data-action="role-select">
949 ${rolesCache.map((r) => `<option value="${r.role_id}" ${r.role_id === e.role_id ? "selected" : ""}>${escapeHtml(r.role_name)}</option>`).join("")}
950 </select>
951 </td>
952 <td><span class="badge ${e.active ? "badge--ok" : "badge--muted"}">${e.active ? "Активен" : "Неактивен"}</span></td>
953 <td class="row-actions">
954 <button class="btn btn--ghost btn--sm" data-action="reset-pin">ПИН</button>
955 <button class="btn btn--ghost btn--sm" data-action="toggle-active">${e.active ? "Деактивирај" : "Активирај"}</button>
956 <button class="btn btn--ghost btn--sm" data-action="delete-emp">Избриши</button>
957 </td>
958 </tr>
959 `).join("");
960}
961
962$("#employees-table tbody").addEventListener("change", async (e) => {
963 if (!e.target.closest('[data-action="role-select"]')) return;
964 const tr = e.target.closest("tr");
965 const userId = Number(tr.dataset.id);
966 const roleId = Number(e.target.value);
967 try {
968 await call("update_employee_role", { token: state.token, userId, roleId });
969 toast("Улогата е ажурирана.", "success");
970 } catch (_) {}
971});
972
973$("#employees-table tbody").addEventListener("click", async (e) => {
974 const tr = e.target.closest("tr");
975 if (!tr) return;
976 const userId = Number(tr.dataset.id);
977
978 if (e.target.closest('[data-action="toggle-active"]')) {
979 try {
980 await call("toggle_employee", { token: state.token, userId });
981 toast("Статусот е променет.", "success");
982 loadEmployees();
983 } catch (_) {}
984 }
985
986 if (e.target.closest('[data-action="reset-pin"]')) {
987 openModal(`
988 <h2>Ресетирај ПИН</h2>
989 <form class="modal-form" id="m-reset-pin-form">
990 <label class="field"><span>Нов ПИН</span><input type="password" id="m-new-pin" inputmode="numeric" required /></label>
991 <div class="modal-actions">
992 <button type="button" class="btn btn--secondary" data-modal-close>Откажи</button>
993 <button type="submit" class="btn btn--primary">Зачувај</button>
994 </div>
995 </form>
996 `);
997 $("#m-reset-pin-form").addEventListener("submit", async (ev) => {
998 ev.preventDefault();
999 const newPin = $("#m-new-pin").value;
1000 try {
1001 await call("reset_employee_pin", { token: state.token, userId, newPin });
1002 toast("ПИН-от е ресетиран.", "success");
1003 closeModal();
1004 } catch (_) {}
1005 });
1006 }
1007
1008 if (e.target.closest('[data-action="delete-emp"]')) {
1009 const nameCell = tr.querySelector("td");
1010 const empName = nameCell ? nameCell.textContent.trim() : "вработениот";
1011 openModal(`
1012 <h2>Избриши вработен</h2>
1013 <p class="modal-sub">Дали сте сигурни дека сакате да го избришете „${escapeHtml(empName)}“? Оваа акција е неповратна.</p>
1014 <div class="modal-actions">
1015 <button class="btn btn--secondary" data-modal-close>Откажи</button>
1016 <button class="btn btn--danger" id="m-confirm-del-emp">Избриши</button>
1017 </div>
1018 `);
1019 $("#m-confirm-del-emp").addEventListener("click", async () => {
1020 try {
1021 await call("delete_employee", { token: state.token, userId });
1022 toast("Вработениот е избришан.", "success");
1023 closeModal();
1024 loadEmployees();
1025 } catch (_) {}
1026 });
1027 }
1028});
1029
1030$("#btn-new-employee").addEventListener("click", () => {
1031 const options = rolesCache.map((r) => `<option value="${r.role_id}">${escapeHtml(r.role_name)}</option>`).join("");
1032 openModal(`
1033 <h2>Нов вработен</h2>
1034 <form class="modal-form" id="m-new-emp-form">
1035 <div class="field-row">
1036 <label class="field"><span>Име</span><input type="text" id="m-emp-first" required /></label>
1037 <label class="field"><span>Презиме</span><input type="text" id="m-emp-last" required /></label>
1038 </div>
1039 <label class="field"><span>Улога</span><select id="m-emp-role" required>${options}</select></label>
1040 <label class="field"><span>ПИН</span><input type="password" id="m-emp-pin" inputmode="numeric" required /></label>
1041 <div class="modal-actions">
1042 <button type="button" class="btn btn--secondary" data-modal-close>Откажи</button>
1043 <button type="submit" class="btn btn--primary">Додади</button>
1044 </div>
1045 </form>
1046 `);
1047 $("#m-new-emp-form").addEventListener("submit", async (e) => {
1048 e.preventDefault();
1049 const firstName = $("#m-emp-first").value.trim();
1050 const lastName = $("#m-emp-last").value.trim();
1051 const roleId = Number($("#m-emp-role").value);
1052 const pin = $("#m-emp-pin").value;
1053 try {
1054 await call("add_employee", { token: state.token, firstName, lastName, pin, roleId });
1055 toast("Вработениот е додаден.", "success");
1056 closeModal();
1057 loadEmployees();
1058 } catch (_) {}
1059 });
1060});
1061
1062async function loadSettings() {
1063 try {
1064 const s = await call("get_settings", {}, { silent: true });
1065 state.settings = s;
1066 $("#set-restaurant-name").value = s.restaurant_name || "";
1067 $("#set-vat-rate").value = toNumber(s.vat_rate);
1068 $("#set-currency").value = s.currency || "";
1069 $("#set-printer-config").value = s.printer_config || "";
1070 } catch (_) {}
1071}
1072
1073$("#settings-form").addEventListener("submit", async (e) => {
1074 e.preventDefault();
1075 try {
1076 await call("update_settings", {
1077 token: state.token,
1078 restaurantName: $("#set-restaurant-name").value.trim(),
1079 vatRate: Number($("#set-vat-rate").value),
1080 currency: $("#set-currency").value.trim(),
1081 printerConfig: $("#set-printer-config").value.trim(),
1082 });
1083 toast("Поставките се зачувани.", "success");
1084 loadSettingsQuiet();
1085 } catch (_) {}
1086});
1087
1088tickClock();
1089loadSettingsQuiet();
1090$("#view-login").classList.add("is-active");
Note: See TracBrowser for help on using the repository browser.