feat: manage owner usage history records
This commit is contained in:
345
app.js
345
app.js
@@ -36,6 +36,7 @@ let stayRecords = IS_API_MODE ? [] : [
|
||||
{ confirmation: "26052136", ownerId: 6, checkin: "2026-05-21", checkout: "2026-05-23", nights: 2, usedType: "RM2", multiplier: 1, deducted: 2, remark: "" }
|
||||
].map((record, importOrder) => ({
|
||||
...record,
|
||||
id: `demo-${record.confirmation}-${record.ownerId}`,
|
||||
recordSource: "imported",
|
||||
importOrder,
|
||||
enteredAt: null,
|
||||
@@ -52,6 +53,7 @@ let connectionState = IS_API_MODE ? "loading" : "demo";
|
||||
let connectionErrorCode = null;
|
||||
let activeLoadController = null;
|
||||
let isSubmittingStay = false;
|
||||
const deletingUsageIds = new Set();
|
||||
let stayIdempotencyKey = null;
|
||||
let currentLanguage = "en";
|
||||
const LANGUAGE_CONFIG = Object.freeze({
|
||||
@@ -60,12 +62,19 @@ const LANGUAGE_CONFIG = Object.freeze({
|
||||
th: Object.freeze({ html: "th", intl: "th-TH-u-ca-gregory-nu-latn", name: "ไทย" })
|
||||
});
|
||||
const SUPPORTED_LANGUAGES = Object.freeze(Object.keys(LANGUAGE_CONFIG));
|
||||
const OWNER_PAGE_SIZE = 50;
|
||||
let detailOwnerId = null;
|
||||
let activePage = "dashboard";
|
||||
let activeOwnerView = "accounts";
|
||||
let ownerPage = 1;
|
||||
let stayReturnOwnerId = null;
|
||||
let stayOwnerLocked = false;
|
||||
let toastTimer;
|
||||
let lastToast = null;
|
||||
let confirmDialogResolver = null;
|
||||
let confirmDialogRecord = null;
|
||||
let confirmDialogPreviousFocus = null;
|
||||
let confirmDialogPreviousOverflow = "";
|
||||
let workspaceInitialized = false;
|
||||
let currentUser = null;
|
||||
let isAuthenticating = false;
|
||||
@@ -236,7 +245,27 @@ const I18N = {
|
||||
closeAccountDetails: "Close account details",
|
||||
usageSaved: "Usage record saved",
|
||||
balanceUpdated: "The privilege balance has been updated.",
|
||||
actions: "Actions",
|
||||
confirmAction: "CONFIRM ACTION",
|
||||
deleteUsageTitle: "Delete usage record",
|
||||
deleteUsageDescription: ({ confirmation }) => `Delete confirmation ${confirmation}? The corresponding stay privileges will be returned to the account balance.`,
|
||||
delete: "Delete",
|
||||
deleteUsage: ({ confirmation }) => `Delete usage record ${confirmation}`,
|
||||
deleteUsageConfirm: ({ confirmation }) => `Delete usage record ${confirmation}? The corresponding stay privileges will be returned to the account balance.`,
|
||||
deletingRecord: "Deleting…",
|
||||
usageDeleted: "Usage record deleted",
|
||||
usageDeletedMessage: ({ confirmation, restored }) => `Confirmation ${confirmation} was deleted. ${restored} returned to the account balance.`,
|
||||
usageDeleteFailed: "The usage record could not be deleted. Try again.",
|
||||
usageDeleteNotFound: "The usage record is no longer available. Refresh and try again.",
|
||||
usageDeleteConflict: "This record changed in the current data. Refresh and try again.",
|
||||
usageDeleteNetwork: "The local API could not be reached. The usage record was not deleted.",
|
||||
accountsShown: ({ count }) => `${formatNumber(count)} account${count === 1 ? "" : "s"} shown`,
|
||||
ownerPaginationAria: "Owner account pages",
|
||||
pageNumbersAria: "Page numbers",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
goToPage: ({ page }) => `Go to page ${formatNumber(page)}`,
|
||||
ownerPageStatus: ({ from, to, total, page, totalPages }) => `${formatNumber(from)}-${formatNumber(to)} of ${formatNumber(total)} · Page ${formatNumber(page)} of ${formatNumber(totalPages)}`,
|
||||
recordsShown: ({ count }) => `${formatNumber(count)} record${count === 1 ? "" : "s"} shown`,
|
||||
viewAccount: ({ name }) => `View ${name} account`,
|
||||
openAccount: ({ name }) => `Open ${name} account`,
|
||||
@@ -433,7 +462,27 @@ const I18N = {
|
||||
closeAccountDetails: "关闭账户详情",
|
||||
usageSaved: "使用记录已保存",
|
||||
balanceUpdated: "入住权益余额已更新。",
|
||||
actions: "操作",
|
||||
confirmAction: "确认操作",
|
||||
deleteUsageTitle: "删除使用记录",
|
||||
deleteUsageDescription: ({ confirmation }) => `确定删除确认号 ${confirmation} 吗?对应的入住权益将返还到账户余额。`,
|
||||
delete: "删除",
|
||||
deleteUsage: ({ confirmation }) => `删除使用记录 ${confirmation}`,
|
||||
deleteUsageConfirm: ({ confirmation }) => `确定删除确认号 ${confirmation} 的使用记录吗?对应的入住权益将返还到账户余额。`,
|
||||
deletingRecord: "正在删除…",
|
||||
usageDeleted: "使用记录已删除",
|
||||
usageDeletedMessage: ({ confirmation, restored }) => `确认号 ${confirmation} 已删除,${restored} 已返还到账户余额。`,
|
||||
usageDeleteFailed: "使用记录删除失败,请重试。",
|
||||
usageDeleteNotFound: "使用记录已不存在,请刷新后重试。",
|
||||
usageDeleteConflict: "当前数据已发生变化,请刷新后重试。",
|
||||
usageDeleteNetwork: "无法连接本地 API,使用记录尚未删除。",
|
||||
accountsShown: ({ count }) => `显示 ${formatNumber(count)} 个账户`,
|
||||
ownerPaginationAria: "业主账户分页",
|
||||
pageNumbersAria: "页码",
|
||||
previousPage: "上一页",
|
||||
nextPage: "下一页",
|
||||
goToPage: ({ page }) => `转到第 ${formatNumber(page)} 页`,
|
||||
ownerPageStatus: ({ from, to, total, page, totalPages }) => `第 ${formatNumber(page)} / ${formatNumber(totalPages)} 页 · 显示 ${formatNumber(from)}-${formatNumber(to)},共 ${formatNumber(total)} 个账户`,
|
||||
recordsShown: ({ count }) => `显示 ${formatNumber(count)} 条记录`,
|
||||
viewAccount: ({ name }) => `查看 ${name} 的账户`,
|
||||
openAccount: ({ name }) => `打开 ${name} 的账户`,
|
||||
@@ -630,7 +679,27 @@ const I18N = {
|
||||
closeAccountDetails: "ปิดรายละเอียดบัญชี",
|
||||
usageSaved: "บันทึกรายการใช้สิทธิ์แล้ว",
|
||||
balanceUpdated: "อัปเดตสิทธิ์คงเหลือแล้ว",
|
||||
actions: "การดำเนินการ",
|
||||
confirmAction: "ยืนยันการดำเนินการ",
|
||||
deleteUsageTitle: "ลบรายการใช้สิทธิ์",
|
||||
deleteUsageDescription: ({ confirmation }) => `ต้องการลบหมายเลขยืนยัน ${confirmation} หรือไม่ สิทธิ์ที่ใช้จะคืนกลับไปยังยอดคงเหลือของบัญชี`,
|
||||
delete: "ลบ",
|
||||
deleteUsage: ({ confirmation }) => `ลบรายการใช้สิทธิ์ ${confirmation}`,
|
||||
deleteUsageConfirm: ({ confirmation }) => `ต้องการลบรายการใช้สิทธิ์หมายเลขยืนยัน ${confirmation} หรือไม่ สิทธิ์ที่ใช้จะถูกคืนกลับไปยังยอดคงเหลือของบัญชี`,
|
||||
deletingRecord: "กำลังลบ…",
|
||||
usageDeleted: "ลบรายการใช้สิทธิ์แล้ว",
|
||||
usageDeletedMessage: ({ confirmation, restored }) => `ลบหมายเลขยืนยัน ${confirmation} แล้ว คืน ${restored} กลับไปยังยอดคงเหลือของบัญชี`,
|
||||
usageDeleteFailed: "ไม่สามารถลบรายการใช้สิทธิ์ได้ กรุณาลองอีกครั้ง",
|
||||
usageDeleteNotFound: "ไม่พบรายการใช้สิทธิ์นี้แล้ว กรุณาโหลดใหม่แล้วลองอีกครั้ง",
|
||||
usageDeleteConflict: "ข้อมูลรายการนี้เปลี่ยนแปลงแล้ว กรุณาโหลดใหม่แล้วลองอีกครั้ง",
|
||||
usageDeleteNetwork: "ไม่สามารถเชื่อมต่อ API ภายในเครื่องได้ รายการยังไม่ได้ลบ",
|
||||
accountsShown: ({ count }) => `แสดง ${formatNumber(count)} บัญชี`,
|
||||
ownerPaginationAria: "หน้าบัญชีเจ้าของห้อง",
|
||||
pageNumbersAria: "หมายเลขหน้า",
|
||||
previousPage: "หน้าก่อนหน้า",
|
||||
nextPage: "หน้าถัดไป",
|
||||
goToPage: ({ page }) => `ไปหน้าที่ ${formatNumber(page)}`,
|
||||
ownerPageStatus: ({ from, to, total, page, totalPages }) => `หน้า ${formatNumber(page)} จาก ${formatNumber(totalPages)} · แสดง ${formatNumber(from)}-${formatNumber(to)} จาก ${formatNumber(total)} บัญชี`,
|
||||
recordsShown: ({ count }) => `แสดง ${formatNumber(count)} รายการ`,
|
||||
viewAccount: ({ name }) => `ดูบัญชีของ ${name}`,
|
||||
openAccount: ({ name }) => `เปิดบัญชีของ ${name}`,
|
||||
@@ -764,6 +833,9 @@ function updateUsageEntryAvailability() {
|
||||
const unavailable = owners.length === 0 || connectionState === "loading" || connectionState === "error" || isSubmittingStay;
|
||||
$$(".open-stay-drawer").forEach(button => { button.disabled = unavailable; });
|
||||
if ($("#detailAddStay")) $("#detailAddStay").disabled = unavailable || !detailOwnerId;
|
||||
$$(".delete-usage-action").forEach(button => {
|
||||
button.disabled = unavailable || deletingUsageIds.has(button.dataset.usageId);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshDataViews() {
|
||||
@@ -878,6 +950,7 @@ function showLoginScreen(errorKey = null, focusPassword = false) {
|
||||
activeLoadController?.abort();
|
||||
activeLoadController = null;
|
||||
currentUser = null;
|
||||
closeConfirmDialog(false);
|
||||
document.body.style.overflow = "";
|
||||
$("#loginScreen").hidden = false;
|
||||
$("#loginScreen").dataset.authState = errorKey ? "error" : "ready";
|
||||
@@ -936,7 +1009,6 @@ function showSessionError(key = null) {
|
||||
|
||||
async function enterWorkspace(session) {
|
||||
currentUser = session?.user?.username || "";
|
||||
$("#sessionUsername").textContent = currentUser;
|
||||
showSessionError(null);
|
||||
$("#loginScreen").hidden = true;
|
||||
$("#appShell").hidden = false;
|
||||
@@ -946,8 +1018,8 @@ async function enterWorkspace(session) {
|
||||
workspaceInitialized = true;
|
||||
bindEvents();
|
||||
resetStayForm(owners[0]?.id || "");
|
||||
setOwnerWorkspaceView(activeOwnerView);
|
||||
}
|
||||
applyWorkspaceRoute();
|
||||
applyLanguage(currentLanguage);
|
||||
|
||||
if (IS_API_MODE) await loadApiData();
|
||||
@@ -996,6 +1068,9 @@ async function signOut() {
|
||||
if (!API_CLIENT) throw new Error("API client unavailable");
|
||||
await API_CLIENT.logout();
|
||||
$("#loginForm").reset();
|
||||
activePage = "dashboard";
|
||||
activeOwnerView = "accounts";
|
||||
updateWorkspaceRoute({ replace: true });
|
||||
showLoginScreen();
|
||||
} catch {
|
||||
showSessionError("signOutFailed");
|
||||
@@ -1063,6 +1138,10 @@ function iconChevron() {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>';
|
||||
}
|
||||
|
||||
function iconTrash() {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14M9 7V4h6v3M8 7l1 13h6l1-13M10 11v6M14 11v6"/></svg>';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>'"]/g, character => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]);
|
||||
}
|
||||
@@ -1129,15 +1208,54 @@ function calculateNights(checkin, checkout) {
|
||||
return Math.round((end - start) / 86400000);
|
||||
}
|
||||
|
||||
function switchPage(pageName) {
|
||||
function workspaceRouteHash(page = activePage, ownerView = activeOwnerView) {
|
||||
if (page !== "owners") return "#dashboard";
|
||||
return ownerView === "usage" ? "#owners/usage" : "#owners";
|
||||
}
|
||||
|
||||
function updateWorkspaceRoute({ replace = false } = {}) {
|
||||
const nextHash = workspaceRouteHash();
|
||||
if (window.location.hash === nextHash) return;
|
||||
if (!replace) {
|
||||
window.location.hash = nextHash;
|
||||
return;
|
||||
}
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`;
|
||||
window.history.replaceState({}, "", nextUrl);
|
||||
}
|
||||
|
||||
function readWorkspaceRoute() {
|
||||
const hash = window.location.hash.slice(1).toLowerCase();
|
||||
if (hash === "owners/usage") return { page: "owners", ownerView: "usage", hash: "#owners/usage" };
|
||||
if (hash === "owners" || hash === "owners/accounts") return { page: "owners", ownerView: "accounts", hash: "#owners" };
|
||||
return { page: "dashboard", ownerView: "accounts", hash: "#dashboard" };
|
||||
}
|
||||
|
||||
function applyWorkspaceRoute() {
|
||||
const route = readWorkspaceRoute();
|
||||
switchPage(route.page, { syncRoute: false });
|
||||
setOwnerWorkspaceView(route.ownerView, { syncRoute: false });
|
||||
if (window.location.hash && window.location.hash !== route.hash) {
|
||||
updateWorkspaceRoute({ replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkspaceRouteChange() {
|
||||
if (!workspaceInitialized || $("#appShell").hidden) return;
|
||||
applyWorkspaceRoute();
|
||||
}
|
||||
|
||||
function switchPage(pageName, { syncRoute = true } = {}) {
|
||||
const safePage = pageName === "owners" ? "owners" : "dashboard";
|
||||
activePage = safePage;
|
||||
$$(".page").forEach(page => page.classList.toggle("active", page.dataset.pageContent === safePage));
|
||||
$$(".nav-item").forEach(item => item.classList.toggle("active", item.dataset.page === safePage));
|
||||
closeMobileMenu();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
if (syncRoute) updateWorkspaceRoute();
|
||||
}
|
||||
|
||||
function setOwnerWorkspaceView(viewName, { focus = false } = {}) {
|
||||
function setOwnerWorkspaceView(viewName, { focus = false, syncRoute = true } = {}) {
|
||||
const nextView = viewName === "usage" ? "usage" : "accounts";
|
||||
activeOwnerView = nextView;
|
||||
$$("[data-owner-view]").forEach(tab => {
|
||||
@@ -1154,6 +1272,7 @@ function setOwnerWorkspaceView(viewName, { focus = false } = {}) {
|
||||
});
|
||||
if (nextView === "accounts") renderOwners();
|
||||
else renderStays();
|
||||
if (syncRoute && activePage === "owners") updateWorkspaceRoute();
|
||||
}
|
||||
|
||||
function bindTableRowActivation(row, activate) {
|
||||
@@ -1189,6 +1308,16 @@ function bindTableRowActivation(row, activate) {
|
||||
});
|
||||
}
|
||||
|
||||
function bindDeleteActions(root) {
|
||||
$$(".delete-usage-action", root).forEach(button => {
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void requestDeleteUsage(button.dataset.usageId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderOwners() {
|
||||
const query = $("#ownerSearch").value.trim().toLowerCase();
|
||||
const type = $("#roomTypeFilter").value;
|
||||
@@ -1196,23 +1325,53 @@ function renderOwners() {
|
||||
const haystack = `${owner.sourceNo} ${owner.transferDate} ${owner.name} ${owner.room} ${owner.purchasedType} ${owner.unit} ${owner.member}`.toLowerCase();
|
||||
return haystack.includes(query) && (!type || owner.purchasedType === type);
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / OWNER_PAGE_SIZE));
|
||||
ownerPage = Math.min(Math.max(ownerPage, 1), totalPages);
|
||||
const pageOffset = (ownerPage - 1) * OWNER_PAGE_SIZE;
|
||||
const visibleOwners = filtered.slice(pageOffset, pageOffset + OWNER_PAGE_SIZE);
|
||||
const pageFrom = filtered.length ? pageOffset + 1 : 0;
|
||||
const pageTo = Math.min(pageOffset + OWNER_PAGE_SIZE, filtered.length);
|
||||
|
||||
$("#ownerCount").textContent = t("accountsShown", { count: filtered.length });
|
||||
$("#ownerCount").textContent = t("accountsShown", { count: visibleOwners.length });
|
||||
$("#accountTabCount").textContent = formatNumber(owners.length);
|
||||
const databaseEmpty = IS_API_MODE && connectionState === "ready" && owners.length === 0;
|
||||
$("#ownerTableBody").innerHTML = filtered.length ? filtered.map(owner => `
|
||||
$("#ownerTableBody").innerHTML = visibleOwners.length ? visibleOwners.map(owner => `
|
||||
<tr class="clickable-row" data-owner-id="${owner.id}" tabindex="0">
|
||||
<td data-label="${t("no")}"><strong class="mono">${escapeHtml(displayValue(owner.sourceNo))}</strong></td>
|
||||
<td data-label="${t("transferDate")}">${shortDate(owner.transferDate)}</td>
|
||||
<td data-label="${t("name")}"><strong class="owner-name">${escapeHtml(owner.name)}</strong></td>
|
||||
<td data-label="${t("roomNo")}"><strong class="mono">${escapeHtml(owner.room)}</strong></td>
|
||||
<td data-label="${t("roomType")}"><span class="neutral-badge">${escapeHtml(owner.purchasedType)}</span></td>
|
||||
<td data-label="${t("unitNo")}"><span class="mono">${escapeHtml(owner.unit)}</span></td>
|
||||
<td data-label="${t("memberNo")}"><span class="mono">${escapeHtml(owner.member)}</span></td>
|
||||
<td data-label="${t("transferDate")}">${shortDate(owner.transferDate)}</td>
|
||||
<td data-label="${t("remainingStayPrivileges")}" class="align-right"><strong>${plural(owner.remaining, "night")}</strong></td>
|
||||
<td><button class="row-action" type="button" aria-label="${escapeHtml(t("viewAccount", { name: owner.name }))}">${iconChevron()}</button></td>
|
||||
</tr>`).join("") : `<tr class="empty-table-row"><td colspan="9"><div class="table-empty"><strong>${t(databaseEmpty ? "noDatabaseOwners" : "noOwnerAccountsFound")}</strong><span>${t(databaseEmpty ? "noDatabaseOwnersDescription" : "tryOwnerFilter")}</span></div></td></tr>`;
|
||||
|
||||
const pagination = $("#ownerPagination");
|
||||
if (pagination) {
|
||||
pagination.hidden = totalPages <= 1;
|
||||
$("#ownerPageStatus").textContent = filtered.length
|
||||
? t("ownerPageStatus", { from: pageFrom, to: pageTo, total: filtered.length, page: ownerPage, totalPages })
|
||||
: "";
|
||||
const pageNumbers = $("#ownerPageNumbers");
|
||||
if (pageNumbers) {
|
||||
pageNumbers.hidden = totalPages <= 1;
|
||||
pageNumbers.innerHTML = totalPages > 1
|
||||
? Array.from({ length: totalPages }, (_, index) => {
|
||||
const page = index + 1;
|
||||
const current = page === ownerPage;
|
||||
return `<button class="pagination-number" type="button" data-owner-page="${page}" aria-label="${escapeHtml(t("goToPage", { page }))}"${current ? ' aria-current="page"' : ""}>${formatNumber(page)}</button>`;
|
||||
}).join("")
|
||||
: "";
|
||||
$$("#ownerPageNumbers [data-owner-page]").forEach(button => button.addEventListener("click", () => {
|
||||
ownerPage = Number(button.dataset.ownerPage);
|
||||
renderOwners();
|
||||
}));
|
||||
}
|
||||
}
|
||||
$("#ownerTableWrap").scrollTop = 0;
|
||||
|
||||
$$("#ownerTableBody tr[data-owner-id]").forEach(row => {
|
||||
bindTableRowActivation(row, () => openOwnerDrawer(row.dataset.ownerId));
|
||||
});
|
||||
@@ -1241,15 +1400,19 @@ function renderStays() {
|
||||
<td data-label="${t("night")}">${record.nights}</td>
|
||||
<td data-label="${t("use")}">${record.deducted}</td>
|
||||
<td data-label="${t("balance")}" class="align-right"><strong>${plural(record.balance, "night")}</strong></td>
|
||||
<td data-label="${t("usedRoomType")}"><span class="neutral-badge history-room-type" title="${escapeHtml(record.usedType)}">${escapeHtml(record.usedType)}</span></td>
|
||||
<td data-label="${t("usedRoomType")}"><span class="history-room-type" title="${escapeHtml(record.usedType)}">${escapeHtml(record.usedType)}</span></td>
|
||||
<td data-label="${t("remark")}"><span class="record-remark">${escapeHtml(record.remark || t("noRemark"))}</span></td>
|
||||
<td><button class="row-action" type="button" aria-label="${escapeHtml(t("openAccount", { name: owner.name }))}">${iconChevron()}</button></td>
|
||||
<td class="usage-row-actions"><div class="row-actions">
|
||||
<button class="row-action" type="button" aria-label="${escapeHtml(t("openAccount", { name: owner.name }))}">${iconChevron()}</button>
|
||||
<button class="row-action delete-usage-action" type="button" data-usage-id="${escapeHtml(record.id)}" aria-label="${escapeHtml(t("deleteUsage", { confirmation: record.confirmation }))}" title="${escapeHtml(t("deleteUsage", { confirmation: record.confirmation }))}">${iconTrash()}</button>
|
||||
</div></td>
|
||||
</tr>`;
|
||||
}).join("") : `<tr class="empty-table-row"><td colspan="10"><div class="table-empty"><strong>${t(databaseEmpty ? "noDatabaseUsage" : "noUsageRecordsFound")}</strong><span>${t(databaseEmpty ? "noDatabaseUsageDescription" : "tryUsageFilter")}</span></div></td></tr>`;
|
||||
|
||||
$$("#stayTableBody tr[data-owner-id]").forEach(row => {
|
||||
bindTableRowActivation(row, () => openOwnerDrawer(row.dataset.ownerId));
|
||||
});
|
||||
bindDeleteActions($("#stayTableBody"));
|
||||
}
|
||||
|
||||
function renderRoomTypeMix() {
|
||||
@@ -1322,7 +1485,6 @@ function updateAggregateUI() {
|
||||
$("#totalRemaining").textContent = formatNumber(aggregateRemaining);
|
||||
$("#totalUsed").textContent = formatNumber(aggregateUsed);
|
||||
$("#chartUsed").textContent = formatNumber(aggregateUsed);
|
||||
$(".period-display strong").textContent = String(PERIOD_YEAR);
|
||||
}
|
||||
|
||||
function populateFormOptions() {
|
||||
@@ -1504,19 +1666,22 @@ function renderOwnerDrawer(ownerId) {
|
||||
<section class="detail-section">
|
||||
<div class="detail-section-heading"><h3>${t("usageHistory")}</h3><span>${plural(records.length, "record")}</span></div>
|
||||
${records.length ? `<div class="detail-history-wrap"><table class="detail-history-table">
|
||||
<thead><tr><th>${t("confirmationNo")}</th><th>${t("checkIn")}</th><th>${t("checkOut")}</th><th>${t("night")}</th><th>${t("drawerUsingNights")}</th><th>${t("usedRoomType")}</th><th>${t("balance")}</th><th>${t("remark")}</th></tr></thead>
|
||||
<thead><tr><th>${t("confirmationNo")}</th><th>${t("checkIn")}</th><th>${t("checkOut")}</th><th>${t("night")}</th><th>${t("drawerUsingNights")}</th><th>${t("usedRoomType")}</th><th>${t("balance")}</th><th>${t("remark")}</th><th><span class="sr-only">${t("actions")}</span></th></tr></thead>
|
||||
<tbody>${records.map(record => `<tr>
|
||||
<td data-label="${t("confirmationNo")}"><strong class="mono">${record.confirmation}</strong></td>
|
||||
<td data-label="${t("checkIn")}">${shortDate(record.checkin)}</td>
|
||||
<td data-label="${t("checkOut")}">${shortDate(record.checkout)}</td>
|
||||
<td data-label="${t("night")}">${record.nights}</td>
|
||||
<td data-label="${t("drawerUsingNights")}">${record.deducted}</td>
|
||||
<td data-label="${t("usedRoomType")}"><span class="neutral-badge history-room-type" title="${escapeHtml(record.usedType)}">${escapeHtml(record.usedType)}</span></td>
|
||||
<td data-label="${t("usedRoomType")}"><span class="history-room-type" title="${escapeHtml(record.usedType)}">${escapeHtml(record.usedType)}</span></td>
|
||||
<td data-label="${t("balance")}"><strong>${plural(record.balance, "night")}</strong></td>
|
||||
<td data-label="${t("remark")}"><span class="detail-history-remark">${escapeHtml(record.remark || t("noRemark"))}</span></td>
|
||||
<td class="detail-history-actions"><button class="row-action delete-usage-action" type="button" data-usage-id="${escapeHtml(record.id)}" aria-label="${escapeHtml(t("deleteUsage", { confirmation: record.confirmation }))}" title="${escapeHtml(t("deleteUsage", { confirmation: record.confirmation }))}">${iconTrash()}</button></td>
|
||||
</tr>`).join("")}</tbody>
|
||||
</table></div>` : `<div class="detail-empty"><strong>${t("noUsage2026")}</strong><span>${t("addFirstUsage")}</span></div>`}
|
||||
</section>`;
|
||||
bindDeleteActions($("#ownerDrawerBody"));
|
||||
updateUsageEntryAvailability();
|
||||
}
|
||||
|
||||
function openOwnerDrawer(ownerId) {
|
||||
@@ -1554,6 +1719,64 @@ function showToast(titleKey, messageKey, params = {}) {
|
||||
toastTimer = window.setTimeout(() => { $("#toast").hidden = true; }, 3600);
|
||||
}
|
||||
|
||||
function renderConfirmDialog(record) {
|
||||
const dialog = $("#confirmDialog");
|
||||
if (!dialog || !record) return;
|
||||
$("#confirmDialogTitle").textContent = t("deleteUsageTitle");
|
||||
$("#confirmDialogMessage").textContent = t("deleteUsageDescription", { confirmation: record.confirmation });
|
||||
$("#confirmDialogRecord").textContent = record.confirmation;
|
||||
}
|
||||
|
||||
function closeConfirmDialog(result = false) {
|
||||
const dialog = $("#confirmDialog");
|
||||
const scrim = $("#confirmScrim");
|
||||
const resolver = confirmDialogResolver;
|
||||
const previousFocus = confirmDialogPreviousFocus;
|
||||
const previousOverflow = confirmDialogPreviousOverflow;
|
||||
|
||||
confirmDialogResolver = null;
|
||||
confirmDialogPreviousFocus = null;
|
||||
confirmDialogPreviousOverflow = "";
|
||||
confirmDialogRecord = null;
|
||||
|
||||
if (dialog) {
|
||||
dialog.classList.remove("open");
|
||||
dialog.setAttribute("aria-hidden", "true");
|
||||
dialog.hidden = true;
|
||||
}
|
||||
if (scrim) scrim.hidden = true;
|
||||
document.body.style.overflow = previousOverflow;
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
resolver?.(result);
|
||||
}
|
||||
|
||||
function openDeleteConfirm(record) {
|
||||
return new Promise(resolve => {
|
||||
const dialog = $("#confirmDialog");
|
||||
const scrim = $("#confirmScrim");
|
||||
if (!dialog || !scrim) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (confirmDialogResolver) closeConfirmDialog(false);
|
||||
|
||||
confirmDialogResolver = resolve;
|
||||
confirmDialogRecord = record;
|
||||
confirmDialogPreviousFocus = document.activeElement;
|
||||
confirmDialogPreviousOverflow = document.body.style.overflow;
|
||||
renderConfirmDialog(record);
|
||||
$("#confirmDialogAction").disabled = false;
|
||||
dialog.hidden = false;
|
||||
dialog.classList.add("open");
|
||||
dialog.setAttribute("aria-hidden", "false");
|
||||
scrim.hidden = false;
|
||||
document.body.style.overflow = "hidden";
|
||||
window.requestAnimationFrame(() => {
|
||||
if (confirmDialogResolver && dialog.classList.contains("open")) $("#cancelConfirmDialog")?.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateDashboardAfterCreatedUsage(record) {
|
||||
if (!dashboardData) return;
|
||||
dashboardData.remainingPrivileges = Math.max(0, dashboardData.remainingPrivileges - record.deducted);
|
||||
@@ -1567,6 +1790,25 @@ function updateDashboardAfterCreatedUsage(record) {
|
||||
else dashboardData.monthlyUse.push({ month, use: record.deducted });
|
||||
}
|
||||
|
||||
function updateDashboardAfterDeletedUsage(record) {
|
||||
if (!dashboardData) return;
|
||||
dashboardData.remainingPrivileges += record.deducted;
|
||||
dashboardData.used = Math.max(0, dashboardData.used - record.deducted);
|
||||
const usedTypeIndex = dashboardData.usedRoomTypes.findIndex(item => item.roomType === record.usedType);
|
||||
if (usedTypeIndex >= 0) {
|
||||
const usedType = dashboardData.usedRoomTypes[usedTypeIndex];
|
||||
usedType.count = Math.max(0, usedType.count - record.nights);
|
||||
if (usedType.count === 0) dashboardData.usedRoomTypes.splice(usedTypeIndex, 1);
|
||||
}
|
||||
const month = Number(record.checkin.slice(5, 7));
|
||||
const monthlyIndex = dashboardData.monthlyUse.findIndex(item => item.month === month);
|
||||
if (monthlyIndex >= 0) {
|
||||
const monthly = dashboardData.monthlyUse[monthlyIndex];
|
||||
monthly.use = Math.max(0, monthly.use - record.deducted);
|
||||
if (monthly.use === 0) dashboardData.monthlyUse.splice(monthlyIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function usageSaveErrorKey(error) {
|
||||
return {
|
||||
INSUFFICIENT_BALANCE: "usageSaveInsufficient",
|
||||
@@ -1579,6 +1821,61 @@ function usageSaveErrorKey(error) {
|
||||
}[error?.code] || "usageSaveFailed";
|
||||
}
|
||||
|
||||
function usageDeleteErrorKey(error) {
|
||||
return {
|
||||
NOT_FOUND: "usageDeleteNotFound",
|
||||
CONFLICT: "usageDeleteConflict",
|
||||
NETWORK_ERROR: "usageDeleteNetwork",
|
||||
REQUEST_ABORTED: "usageDeleteNetwork"
|
||||
}[error?.code] || "usageDeleteFailed";
|
||||
}
|
||||
|
||||
async function requestDeleteUsage(recordId) {
|
||||
const record = stayRecords.find(candidate => String(candidate.id) === String(recordId));
|
||||
if (!record || deletingUsageIds.has(recordId)) return;
|
||||
if (!await openDeleteConfirm(record)) return;
|
||||
|
||||
deletingUsageIds.add(recordId);
|
||||
updateUsageEntryAvailability();
|
||||
let errorKey = null;
|
||||
let authenticationRequired = false;
|
||||
try {
|
||||
if (IS_API_MODE) {
|
||||
await API_CLIENT.deleteUsageRecord(recordId);
|
||||
await loadApiData();
|
||||
} else {
|
||||
const owner = ownerFor(record.ownerId);
|
||||
if (owner && Number.isFinite(owner.remaining)) owner.remaining += record.deducted;
|
||||
aggregateRemaining += record.deducted;
|
||||
aggregateUsed = Math.max(0, aggregateUsed - record.deducted);
|
||||
updateDashboardAfterDeletedUsage(record);
|
||||
stayRecords = stayRecords.filter(candidate => candidate.id !== record.id);
|
||||
updateOwnerUsageTotals();
|
||||
refreshDataViews();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isUnauthorized(error)) authenticationRequired = true;
|
||||
else errorKey = usageDeleteErrorKey(error);
|
||||
} finally {
|
||||
deletingUsageIds.delete(recordId);
|
||||
updateUsageEntryAvailability();
|
||||
}
|
||||
|
||||
if (authenticationRequired) {
|
||||
showLoginScreen("sessionExpired");
|
||||
return;
|
||||
}
|
||||
if (errorKey) {
|
||||
showToast("usageDeleteFailed", errorKey);
|
||||
return;
|
||||
}
|
||||
|
||||
showToast("usageDeleted", "usageDeletedMessage", {
|
||||
confirmation: record.confirmation,
|
||||
restored: plural(record.deducted, "night")
|
||||
});
|
||||
}
|
||||
|
||||
async function submitStay(event) {
|
||||
event.preventDefault();
|
||||
const calculation = calculateForm();
|
||||
@@ -1617,6 +1914,7 @@ async function submitStay(event) {
|
||||
aggregateRemaining -= calculation.deduction;
|
||||
aggregateUsed += calculation.deduction;
|
||||
createdRecord = {
|
||||
id: `manual-${Date.now()}-${nextManualEntrySequence}`,
|
||||
confirmation: calculation.confirmation,
|
||||
ownerId: calculation.owner.id,
|
||||
checkin: calculation.checkin,
|
||||
@@ -1657,8 +1955,8 @@ async function submitStay(event) {
|
||||
|
||||
refreshDataViews();
|
||||
closeStayDrawer(false);
|
||||
setOwnerWorkspaceView("accounts", { syncRoute: false });
|
||||
switchPage("owners");
|
||||
setOwnerWorkspaceView("accounts");
|
||||
window.setTimeout(() => openOwnerDrawer(calculation.owner.id), 0);
|
||||
showToast("usageSaved", "usageSavedMessage", { deduction: plural(createdRecord.deducted, "night"), room: calculation.owner.room });
|
||||
}
|
||||
@@ -1726,12 +2024,13 @@ function applyLanguage(language) {
|
||||
|
||||
if ($("#ownerDrawer").classList.contains("open") && detailOwnerId) renderOwnerDrawer(detailOwnerId);
|
||||
if (!$("#toast").hidden) renderToast();
|
||||
if (confirmDialogRecord && $("#confirmDialog").classList.contains("open")) renderConfirmDialog(confirmDialogRecord);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
$$(".nav-item").forEach(button => button.addEventListener("click", () => {
|
||||
if (button.dataset.page === "owners") setOwnerWorkspaceView("accounts", { syncRoute: false });
|
||||
switchPage(button.dataset.page);
|
||||
if (button.dataset.page === "owners") setOwnerWorkspaceView("accounts");
|
||||
}));
|
||||
$$("[data-owner-view]").forEach((tab, index, tabs) => {
|
||||
tab.addEventListener("click", () => setOwnerWorkspaceView(tab.dataset.ownerView));
|
||||
@@ -1757,6 +2056,9 @@ function bindEvents() {
|
||||
closeOwnerDrawer();
|
||||
});
|
||||
$("#closeOwnerDrawer").addEventListener("click", () => closeOwnerDrawer());
|
||||
$("#cancelConfirmDialog").addEventListener("click", () => closeConfirmDialog(false));
|
||||
$("#confirmDialogAction").addEventListener("click", () => closeConfirmDialog(true));
|
||||
$("#confirmScrim").addEventListener("click", () => closeConfirmDialog(false));
|
||||
$("#detailAddStay").addEventListener("click", () => {
|
||||
const ownerId = detailOwnerId || owners[0]?.id;
|
||||
if (ownerId) openStayDrawer(ownerId, true);
|
||||
@@ -1771,19 +2073,28 @@ function bindEvents() {
|
||||
$("#confirmationInput").addEventListener("input", event => {
|
||||
event.target.value = event.target.value.replace(/\D/g, "");
|
||||
});
|
||||
$("#ownerSearch").addEventListener("input", renderOwners);
|
||||
$("#roomTypeFilter").addEventListener("change", renderOwners);
|
||||
$("#ownerSearch").addEventListener("input", () => {
|
||||
ownerPage = 1;
|
||||
renderOwners();
|
||||
});
|
||||
$("#roomTypeFilter").addEventListener("change", () => {
|
||||
ownerPage = 1;
|
||||
renderOwners();
|
||||
});
|
||||
$("#staySearch").addEventListener("input", renderStays);
|
||||
$("#stayTypeFilter").addEventListener("change", renderStays);
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") {
|
||||
if ($("#stayDrawer").classList.contains("open")) closeStayDrawer(true);
|
||||
if ($("#confirmDialog").classList.contains("open")) closeConfirmDialog(false);
|
||||
else if ($("#stayDrawer").classList.contains("open")) closeStayDrawer(true);
|
||||
else if ($("#ownerDrawer").classList.contains("open")) closeOwnerDrawer();
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
$("#menuButton").addEventListener("click", () => $("#sidebar").classList.contains("open") ? closeMobileMenu() : openMobileMenu());
|
||||
$("#mobileScrim").addEventListener("click", closeMobileMenu);
|
||||
window.addEventListener("hashchange", handleWorkspaceRouteChange);
|
||||
window.addEventListener("popstate", handleWorkspaceRouteChange);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
|
||||
Reference in New Issue
Block a user