| 1 | function 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 |
|
|---|
| 8 | const 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 |
|
|---|
| 33 | const pinBuffer = { value: "" };
|
|---|
| 34 |
|
|---|
| 35 | const KIOSK_MODE = new URLSearchParams(window.location.search).get("mode") === "kitchen";
|
|---|
| 36 | if (KIOSK_MODE) document.documentElement.classList.add("kiosk-mode");
|
|---|
| 37 |
|
|---|
| 38 | function $(sel, root = document) { return root.querySelector(sel); }
|
|---|
| 39 | function $all(sel, root = document) { return Array.from(root.querySelectorAll(sel)); }
|
|---|
| 40 |
|
|---|
| 41 | function escapeHtml(str) {
|
|---|
| 42 | return String(str ?? "").replace(/[&<>"']/g, (c) => ({
|
|---|
| 43 | "&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|---|
| 44 | }[c]));
|
|---|
| 45 | }
|
|---|
| 46 |
|
|---|
| 47 | function 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 |
|
|---|
| 53 | function methodLabel(raw) {
|
|---|
| 54 | const s = String(raw ?? "");
|
|---|
| 55 | if (!s) return "—";
|
|---|
| 56 | return s.charAt(0) + s.slice(1).toLowerCase();
|
|---|
| 57 | }
|
|---|
| 58 |
|
|---|
| 59 | function money(v) {
|
|---|
| 60 | const n = toNumber(v);
|
|---|
| 61 | return `${n.toFixed(2)} ${state.settings.currency || ""}`.trim();
|
|---|
| 62 | }
|
|---|
| 63 |
|
|---|
| 64 | function 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 |
|
|---|
| 73 | function errMsg(e) {
|
|---|
| 74 | if (typeof e === "string") return e;
|
|---|
| 75 | if (e && e.message) return e.message;
|
|---|
| 76 | return "Настана непозната грешка.";
|
|---|
| 77 | }
|
|---|
| 78 |
|
|---|
| 79 | async 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 |
|
|---|
| 88 | function openModal(innerHtml) {
|
|---|
| 89 | const root = $("#modal-root");
|
|---|
| 90 | root.innerHTML = `<button class="modal-close" data-modal-close>×</button>${innerHtml}`;
|
|---|
| 91 | $("#modal-overlay").hidden = false;
|
|---|
| 92 | }
|
|---|
| 93 | function closeModal() {
|
|---|
| 94 | $("#modal-overlay").hidden = true;
|
|---|
| 95 | $("#modal-root").innerHTML = "";
|
|---|
| 96 | }
|
|---|
| 97 | document.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 |
|
|---|
| 110 | function tickClock() {
|
|---|
| 111 | const now = new Date();
|
|---|
| 112 | $("#sidebar-clock").textContent = now.toLocaleTimeString("mk-MK", { hour: "2-digit", minute: "2-digit" });
|
|---|
| 113 | }
|
|---|
| 114 | setInterval(tickClock, 1000 * 20);
|
|---|
| 115 |
|
|---|
| 116 | function 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 |
|
|---|
| 132 | document.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 |
|
|---|
| 141 | async 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 |
|
|---|
| 167 | function 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 |
|
|---|
| 201 | const ADMIN_VIEWS = new Set(["inventory", "reports", "products", "employees", "settings"]);
|
|---|
| 202 |
|
|---|
| 203 | function 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 |
|
|---|
| 226 | async 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 |
|
|---|
| 234 | async 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 |
|
|---|
| 242 | function statusClass(status) {
|
|---|
| 243 | if (status === "ЗАФАТЕНА") return "occupied";
|
|---|
| 244 | if (status === "РЕЗЕРВИРАНА") return "reserved";
|
|---|
| 245 | return "free";
|
|---|
| 246 | }
|
|---|
| 247 | function statusLabel(status) {
|
|---|
| 248 | if (status === "ЗАФАТЕНА") return "Зафатена";
|
|---|
| 249 | if (status === "РЕЗЕРВИРАНА") return "Резервирана";
|
|---|
| 250 | return "Слободна";
|
|---|
| 251 | }
|
|---|
| 252 |
|
|---|
| 253 | function 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 |
|
|---|
| 298 | function 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 |
|
|---|
| 321 | async 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 |
|
|---|
| 347 | async function loadCategories() {
|
|---|
| 348 | try {
|
|---|
| 349 | state.categories = await call("get_categories", {}, { silent: true });
|
|---|
| 350 | } catch (_) { state.categories = []; }
|
|---|
| 351 | }
|
|---|
| 352 |
|
|---|
| 353 | async function loadAllProducts() {
|
|---|
| 354 | try {
|
|---|
| 355 | state.products = await call("get_products", { token: state.token });
|
|---|
| 356 | } catch (_) { state.products = []; }
|
|---|
| 357 | }
|
|---|
| 358 |
|
|---|
| 359 | function 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 |
|
|---|
| 376 | async 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 |
|
|---|
| 412 | async 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 |
|
|---|
| 420 | function 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 |
|
|---|
| 503 | async 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 |
|
|---|
| 536 | function startKitchenPolling() {
|
|---|
| 537 | stopKitchenPolling();
|
|---|
| 538 | state.kitchenTimer = setInterval(loadKitchen, KIOSK_MODE ? 5000 : 15000);
|
|---|
| 539 | }
|
|---|
| 540 | function stopKitchenPolling() {
|
|---|
| 541 | if (state.kitchenTimer) clearInterval(state.kitchenTimer);
|
|---|
| 542 | state.kitchenTimer = null;
|
|---|
| 543 | }
|
|---|
| 544 |
|
|---|
| 545 | async function loadKitchen() {
|
|---|
| 546 | try {
|
|---|
| 547 | const rows = await call("get_kitchen_orders", {}, { silent: true });
|
|---|
| 548 | renderKitchen(rows);
|
|---|
| 549 | } catch (_) {}
|
|---|
| 550 | }
|
|---|
| 551 |
|
|---|
| 552 | function 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 |
|
|---|
| 600 | let inventoryCache = [];
|
|---|
| 601 |
|
|---|
| 602 | async function loadInventory() {
|
|---|
| 603 | try {
|
|---|
| 604 | inventoryCache = await call("get_inventory", {}, { silent: true });
|
|---|
| 605 | renderInventory();
|
|---|
| 606 | } catch (_) {}
|
|---|
| 607 | }
|
|---|
| 608 |
|
|---|
| 609 | function 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 |
|
|---|
| 670 | async 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 |
|
|---|
| 685 | function 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 |
|
|---|
| 748 | async function loadAdminMenu() {
|
|---|
| 749 | await loadCategories();
|
|---|
| 750 | renderCategoriesTable();
|
|---|
| 751 | await loadAllProducts();
|
|---|
| 752 | renderProductsTable();
|
|---|
| 753 | }
|
|---|
| 754 |
|
|---|
| 755 | function 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 |
|
|---|
| 770 | function 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 |
|
|---|
| 829 | function 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 |
|
|---|
| 861 | function 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 |
|
|---|
| 928 | let rolesCache = [];
|
|---|
| 929 |
|
|---|
| 930 | async 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 |
|
|---|
| 941 | function 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 |
|
|---|
| 1062 | async 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 |
|
|---|
| 1088 | tickClock();
|
|---|
| 1089 | loadSettingsQuiet();
|
|---|
| 1090 | $("#view-login").classList.add("is-active"); |
|---|