Files
karczma-aplikacja-stoliki/public/assets/js/modules/orders.js
T

473 lines
14 KiB
JavaScript

import { endpoints, loaderMinMs } from "./config.js";
import { getLang, onLangChange, t } from "./i18n.js";
import { runPendingProtectedAction, updateNavAccessState } from "./access.js";
import {
refreshGuestPendingActions,
startGuestPendingPoll,
} from "./queue.js";
import {
getAppAccessLevel,
getHashParam,
getPendingProtectedAction,
getTableParam,
setTableParam,
} from "./state.js";
const loadingScreen = document.getElementById("loadingScreen");
const loaderMsg = document.getElementById("loaderMsg");
const tableLabel = document.getElementById("tableLabel");
const prepStatus = document.getElementById("prepStatus");
const progressBar = document.getElementById("progressBar");
const statusMeta = document.getElementById("statusMeta");
const itemsList = document.getElementById("itemsList");
const emptyState = document.getElementById("emptyState");
const metaFooter = document.getElementById("metaFooter");
const statusIcon = document.getElementById("statusIcon");
const historyKey = "stolik2_global_history";
const historySection = document.getElementById("historySection");
const historyList = document.getElementById("historyList");
const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000;
const LOADER_MIN_MS = loaderMinMs;
const loadStartTime = Date.now();
/** @type {Array} */
let lastBillsForUi = [];
let hasRenderedOrders = false;
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function getLoaderMsgs() {
return [t("loader.msg1"), t("loader.msg2"), t("loader.msg3"), t("loader.msg4")];
}
let msgIdx = 0;
const msgInterval = setInterval(() => {
const msgs = getLoaderMsgs();
msgIdx = (msgIdx + 1) % msgs.length;
if (loaderMsg) loaderMsg.textContent = msgs[msgIdx];
}, 4000);
function formatTableLabel(name) {
const raw = String(name || "").trim();
if (!raw) return t("table.label", { name: "" }).trim();
if (raw.toUpperCase().startsWith("STOLIK") || raw.toUpperCase().startsWith("TABLE") || raw.toUpperCase().startsWith("TISCH")) {
return raw;
}
return t("table.label", { name: raw });
}
// Initial State
if (getTableParam()) {
tableLabel.textContent = formatTableLabel(getTableParam());
}
onLangChange(() => {
if (tableLabel && getTableParam()) {
tableLabel.textContent = formatTableLabel(getTableParam());
}
if (loaderMsg && loadingScreen && !loadingScreen.classList.contains("hidden")) {
loaderMsg.textContent = getLoaderMsgs()[msgIdx];
}
if (hasRenderedOrders) {
updateUI(lastBillsForUi, { manageLoader: false });
}
});
/** Storage key must follow current hash (fallback: table), not a frozen empty tableParam at init. */
export function getOrderStorageKey() {
const key = (getHashParam() || getTableParam() || "unknown").toLowerCase();
return `stolik2_state_${key}`;
}
export function hideLoader() {
const elapsed = Date.now() - loadStartTime;
const remaining = Math.max(0, LOADER_MIN_MS - elapsed);
setTimeout(() => {
loadingScreen.classList.add("hidden");
clearInterval(msgInterval);
const bottomNav = document.getElementById("bottomNav");
if (bottomNav) {
bottomNav.hidden = false;
}
updateNavAccessState();
if (getPendingProtectedAction() && getAppAccessLevel() === "full") {
runPendingProtectedAction();
}
}, remaining);
}
export async function resolveTableLabel() {
const hashParam = getHashParam();
if (!hashParam) return;
try {
const response = await fetch(`${endpoints.kds}?h=${encodeURIComponent(hashParam)}`);
const result = await response.json();
if (result.status === "success" && result.tableName && result.tableName !== "") {
tableLabel.textContent = formatTableLabel(result.tableName);
setTableParam(result.tableName);
}
} catch {
// best effort
}
}
export function updateUI(bills, { manageLoader = true } = {}) {
lastBillsForUi = Array.isArray(bills) ? bills : [];
hasRenderedOrders = true;
if (manageLoader) {
hideLoader();
}
const allArticles = lastBillsForUi.flatMap((b) => (Array.isArray(b?.Articles) ? b.Articles : []));
const items = mergeWithPersistedItems(allArticles);
renderGlobalHistory();
if (items.length === 0) {
showEmptyState();
return;
}
renderItems(items);
updateStatus(lastBillsForUi, items);
}
function showEmptyState() {
const title = document.getElementById("ordersTitle");
if (title) title.classList.add("hidden");
emptyState.classList.remove("hidden");
itemsList.innerHTML = "";
prepStatus.textContent = t("status.none");
statusIcon.textContent = "🍃";
progressBar.style.width = "0%";
statusMeta.textContent = t("status.none_meta");
// Historia może istnieć nawet gdy brak bieżących pozycji
renderGlobalHistory();
}
function normalizeArticleName(rawName) {
const name = String(rawName || t("orders.item_fallback"));
// Usuwa gramatury typu: "300G", "250 G", "500/200/150G".
const withoutWeight = name.replace(
/\b\d+(?:[.,]\d+)?(?:\s*\/\s*\d+(?:[.,]\d+)?)*\s*[gG]\b/g,
""
);
return withoutWeight
.replace(/\s{2,}/g, " ")
.replace(/\s+([,.;:!?])/g, "$1")
.trim() || t("orders.item_fallback");
}
function loadPersistedItems() {
try {
const raw = localStorage.getItem(getOrderStorageKey());
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function savePersistedItems(items) {
try {
localStorage.setItem(getOrderStorageKey(), JSON.stringify(items));
} catch {
// brak miejsca/tryb prywatny
}
}
function loadGlobalHistory() {
try {
const raw = localStorage.getItem(historyKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveGlobalHistory(entries) {
const now = Date.now();
const cleaned = entries
.filter((e) => e && e.name && Number.isFinite(e.archivedAt))
.filter((e) => now - e.archivedAt <= SIX_MONTHS_MS);
try {
localStorage.setItem(historyKey, JSON.stringify(cleaned));
} catch {
// ignore storage errors
}
}
function addItemsToGlobalHistory(items, sourceTable) {
if (!items.length) return;
const now = Date.now();
const existing = loadGlobalHistory();
// zapobiegamy dokładnym duplikatom (ta sama nazwa + qty + stół blisko czasu)
const dedupWindowMs = 2 * 60 * 1000;
const toAdd = items
.filter((item) => {
return !existing.some(
(h) =>
h.name === item.name &&
Number(h.qty) === Number(item.qty) &&
String(h.sourceTable || "") === String(sourceTable || "") &&
Math.abs((h.archivedAt || 0) - now) <= dedupWindowMs
);
})
.map((item) => ({
name: item.name,
qty: item.qty,
sourceTable: sourceTable || "?",
archivedAt: now,
}));
if (!toAdd.length) return;
saveGlobalHistory([...existing, ...toAdd]);
}
export function renderGlobalHistory() {
const now = Date.now();
const lang = getLang();
const history = loadGlobalHistory()
.filter((e) => now - (e.archivedAt || 0) <= SIX_MONTHS_MS)
.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0));
saveGlobalHistory(history);
if (!history.length) {
historySection.classList.add("hidden");
historyList.innerHTML = "";
return;
}
historySection.classList.remove("hidden");
historyList.innerHTML = "";
history.forEach((entry) => {
const dt = new Date(entry.archivedAt || Date.now());
const div = document.createElement("div");
div.className = "item-card archived ready";
div.innerHTML = `
<div class="item-info">
<span class="item-name">${escapeHtml(entry.name)}</span>
<span class="item-meta">${escapeHtml(t("history.entry_meta", {
table: formatTableLabel(entry.sourceTable || "?"),
date: dt.toLocaleDateString(lang),
time: dt.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }),
}))}</span>
</div>
<div class="item-qty">x${escapeHtml(entry.qty)}</div>
`;
historyList.appendChild(div);
});
}
export function clearGlobalHistory(e) {
if (e) e.preventDefault();
if (confirm(t("history.clear_confirm"))) {
localStorage.removeItem(historyKey);
renderGlobalHistory();
}
}
function mergeWithPersistedItems(articles) {
const current = new Map();
articles.forEach((a) => {
const name = normalizeArticleName(a.Name);
const todo = parseFloat(String(a.QuantityToDo || a.QuantitySet || "0").replace(",", "."));
const done = parseFloat(String(a.QuantityDone || "0").replace(",", "."));
if (!current.has(name)) {
current.set(name, { name, qty: 0, done: 0, present: true, completedByDisappear: false });
}
const curr = current.get(name);
curr.qty += Number.isFinite(todo) ? todo : 0;
curr.done += Number.isFinite(done) ? done : 0;
});
const persisted = loadPersistedItems();
const persistedMap = new Map(persisted.map((i) => [i.name, i]));
const merged = [];
// Aktualnie obecne pozycje z WS
current.forEach((item) => {
merged.push({
...item,
present: true,
completedByDisappear: false,
updatedAt: Date.now(),
});
});
// Pozycje, które były wcześniej, ale zniknęły z API -> przesyłamy od razu do historii globalnej
persistedMap.forEach((oldItem, name) => {
if (current.has(name)) return;
const qty = Number.isFinite(oldItem?.qty) ? oldItem.qty : 0;
// Zniknęło z bieżącego rachunku (np. rachunek został zamknięty), od razu leci do osobnego bloku historii!
addItemsToGlobalHistory([{ name, qty }], getTableParam());
});
// Aktywne na górze, gotowe (zniknięte) na dole
merged.sort((a, b) => {
if (a.present !== b.present) return a.present ? -1 : 1;
return a.name.localeCompare(b.name, getLang());
});
savePersistedItems(merged);
return merged;
}
function renderItems(items) {
const title = document.getElementById("ordersTitle");
if (title) title.classList.remove("hidden");
emptyState.classList.add("hidden");
itemsList.innerHTML = "";
items.forEach((item) => {
const isReady = item.done >= item.qty && item.qty > 0;
const div = document.createElement("div");
div.className = `item-card ${isReady ? "ready" : ""} ${item.present ? "" : "archived"}`;
let meta = t("orders.item_preparing");
if (isReady && item.completedByDisappear) {
meta = t("orders.item_ready_done");
} else if (isReady) {
meta = t("orders.item_ready");
}
div.innerHTML = `
<div class="item-info">
<span class="item-name">${escapeHtml(item.name)}</span>
<span class="item-meta">${escapeHtml(meta)}</span>
</div>
<div class="item-qty">x${escapeHtml(item.qty)}</div>
`;
itemsList.appendChild(div);
});
renderGlobalHistory();
}
function updateStatus(bills, items) {
let total = 0;
let done = 0;
items.forEach((i) => {
total += Number.isFinite(i.qty) ? i.qty : 0;
done += Number.isFinite(i.done) ? i.done : 0;
});
const pct = total > 0 ? (done / total) * 100 : 0;
progressBar.style.width = `${pct}%`;
if (pct >= 100) {
prepStatus.textContent = t("status.ready");
statusIcon.innerHTML = "😋";
statusMeta.textContent = t("status.ready_meta");
} else if (pct > 0) {
prepStatus.textContent = t("status.partial");
statusIcon.innerHTML = "🍳";
statusMeta.textContent = t("status.partial_meta");
} else {
prepStatus.textContent = t("status.preparing");
if (!window.selectedAnimationHtml) {
window.selectedAnimationHtml =
window.kitchenAnimations[Math.floor(Math.random() * window.kitchenAnimations.length)];
}
statusIcon.innerHTML = window.selectedAnimationHtml;
statusMeta.textContent = t("status.preparing_meta");
}
// Footer meta
const newest = [...bills].sort((a, b) => new Date(b?.Date || 0) - new Date(a?.Date || 0))[0];
const time = newest?.Date
? new Date(newest.Date).toLocaleTimeString(getLang(), { hour: "2-digit", minute: "2-digit" })
: "--:--";
metaFooter.textContent = t("status.footer", { time, table: getTableParam() });
}
export async function fetchOrders() {
try {
const hashParam = getHashParam();
if (!hashParam) {
updateUI([]);
return;
}
const response = await fetch(`${endpoints.kds}?h=${encodeURIComponent(hashParam)}`);
const result = await response.json();
if (result.status === "success") {
if (result.tableName && result.tableName !== "") {
tableLabel.textContent = formatTableLabel(result.tableName);
setTableParam(result.tableName); // Aktualizacja do właściwej nazwy na poczet innych zapytań
refreshGuestPendingActions();
startGuestPendingPoll();
}
// API teraz samo filtruje i zwraca tylko to co nas interesuje (za pomocą mocnego wyrażenia regularnego)
const matches = result.data;
// Grupowanie składników w główne dania
const groups = {};
matches.forEach((item) => {
const groupId = item.GrupaZestawuID || item.PozycjaID;
if (!groups[groupId]) {
groups[groupId] = {
Name: item.NazwaZestawu || item.NazwaTowaru,
QuantitySet: item.GrupaZestawuID ? 1 : parseFloat(item.Ilosc),
Done: 0,
};
}
// StatusRealizacji >= 2 oznacza, że kucharz wcisnął "Gotowe" na swoim ekranie
if (parseInt(item.StatusRealizacji, 10) >= 2) {
groups[groupId].Done = groups[groupId].QuantitySet;
}
});
const transformedArticles = Object.values(groups).map((g) => ({
Name: g.Name,
QuantitySet: g.QuantitySet,
QuantityDone: g.Done,
}));
// Najnowszy czas dodania (do pokazania w stopce)
const latestDate =
matches.length > 0
? matches.sort((a, b) => new Date(b.DataDodania) - new Date(a.DataDodania))[0].DataDodania
: null;
// Przekazanie do dotychczasowej logiki aktualizującej UI (w odpowiednim formacie)
updateUI([
{
Articles: transformedArticles,
Date: latestDate,
},
]);
} else {
loaderMsg.textContent = t("loader.api_error", { message: result.message });
}
} catch (err) {
loaderMsg.textContent = t("loader.connection");
}
}