diff --git a/api-client.js b/api-client.js index cd812b2..6317325 100644 --- a/api-client.js +++ b/api-client.js @@ -127,6 +127,10 @@ ...options, method: "POST", body: input + }), + deleteUsageRecord: (id, options = {}) => request(`usage-records/${encodeURIComponent(id)}`, { + ...options, + method: "DELETE" }) }); diff --git a/app.js b/app.js index cd5182a..8884d23 100644 --- a/app.js +++ b/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 ''; } +function iconTrash() { + return ''; +} + 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 => ` ${escapeHtml(displayValue(owner.sourceNo))} - ${shortDate(owner.transferDate)} ${escapeHtml(owner.name)} ${escapeHtml(owner.room)} ${escapeHtml(owner.purchasedType)} ${escapeHtml(owner.unit)} ${escapeHtml(owner.member)} + ${shortDate(owner.transferDate)} ${plural(owner.remaining, "night")} `).join("") : `
${t(databaseEmpty ? "noDatabaseOwners" : "noOwnerAccountsFound")}${t(databaseEmpty ? "noDatabaseOwnersDescription" : "tryOwnerFilter")}
`; + 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 ``; + }).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() { ${record.nights} ${record.deducted} ${plural(record.balance, "night")} - ${escapeHtml(record.usedType)} + ${escapeHtml(record.usedType)} ${escapeHtml(record.remark || t("noRemark"))} - +
+ + +
`; }).join("") : `
${t(databaseEmpty ? "noDatabaseUsage" : "noUsageRecordsFound")}${t(databaseEmpty ? "noDatabaseUsageDescription" : "tryUsageFilter")}
`; $$("#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) {

${t("usageHistory")}

${plural(records.length, "record")}
${records.length ? `
- + ${records.map(record => ` - + + `).join("")}
${t("confirmationNo")}${t("checkIn")}${t("checkOut")}${t("night")}${t("drawerUsingNights")}${t("usedRoomType")}${t("balance")}${t("remark")}
${t("confirmationNo")}${t("checkIn")}${t("checkOut")}${t("night")}${t("drawerUsingNights")}${t("usedRoomType")}${t("balance")}${t("remark")}${t("actions")}
${record.confirmation} ${shortDate(record.checkin)} ${shortDate(record.checkout)} ${record.nights} ${record.deducted}${escapeHtml(record.usedType)}${escapeHtml(record.usedType)} ${plural(record.balance, "night")} ${escapeHtml(record.remark || t("noRemark"))}
` : `
${t("noUsage2026")}${t("addFirstUsage")}
`}
`; + 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() { diff --git a/backend/README.md b/backend/README.md index f28b825..44e7e42 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,7 +6,7 @@ TypeScript/Fastify API and PostgreSQL database layer for the current CONDO owner - Database: existing `booking_test`. - Isolated schema: `condon`. -- Migrations: `001_create_condon_schema`, `002_legacy_import_and_bookings`. +- Migrations: `001_create_condon_schema`, `002_legacy_import_and_bookings`, `003_delete_usage_record`. - Reference data: 11 room types. - Imported latest `(2)` batch: 388 owners, 389 periods, 155 bookings, 157 usage records and 547 ledger rows. - Historical data import: `legacy-016dc36d15cc40a5` completed after local preflight and read-only verification. @@ -31,6 +31,7 @@ The database functions are: - `condon.open_entitlement_period` - `condon.create_usage_record` - `condon.create_usage_record_v2` +- `condon.delete_usage_record` `create_usage_record_v2` is the authoritative new-record write path. It calculates Night, Multiplier, Use and Balance, locks the entitlement period and writes the usage row, ledger row and new balance atomically. Historical rows use `legacy-source`, preserve source Use/Balance/Room/raw room type, and may have a null multiplier. @@ -47,6 +48,7 @@ The database functions are: | GET | `/owner-accounts/:id` | Account detail and period balance | | GET | `/usage-records` | Searchable, paginated usage history | | POST | `/usage-records` | Transactional usage deduction | +| DELETE | `/usage-records/:id` | Transactional usage deletion and balance restoration | | GET | `/dashboard` | Annual totals and room-type/month aggregates | Runtime OpenAPI documentation is available at `/docs`. @@ -130,7 +132,7 @@ The database integration test covers all 100 automatic room-type combinations, b ## Rollback -The latest migration rollback is [002_legacy_import_and_bookings.down.sql](./migrations/002_legacy_import_and_bookings.down.sql). Do not run it after business data has been imported. The original empty-schema rollback remains [001_create_condon_schema.down.sql](./migrations/001_create_condon_schema.down.sql). +The latest migration rollback is [003_delete_usage_record.down.sql](./migrations/003_delete_usage_record.down.sql). Do not run it after business data has been imported. The previous legacy rollback remains [002_legacy_import_and_bookings.down.sql](./migrations/002_legacy_import_and_bookings.down.sql), and the original empty-schema rollback remains [001_create_condon_schema.down.sql](./migrations/001_create_condon_schema.down.sql). Do not run rollback after business data has been imported. Rollback is an explicit operator action and is not exposed as a normal npm command. @@ -139,5 +141,5 @@ Do not run rollback after business data has been imported. Rollback is an explic - Importing a newer workbook without a new preflight batch. - Switching the static frontend to the API. - Multi-user identities, roles and backend-restart-persistent sessions. -- Usage cancellation, reversal and manual balance adjustment. +- Manual balance adjustment. - Cross-entitlement-year stays. diff --git a/backend/migrations/003_delete_usage_record.down.sql b/backend/migrations/003_delete_usage_record.down.sql new file mode 100644 index 0000000..e00430b --- /dev/null +++ b/backend/migrations/003_delete_usage_record.down.sql @@ -0,0 +1,2 @@ +REVOKE ALL ON FUNCTION condon.delete_usage_record(uuid) FROM PUBLIC; +DROP FUNCTION condon.delete_usage_record(uuid); diff --git a/backend/migrations/003_delete_usage_record.up.sql b/backend/migrations/003_delete_usage_record.up.sql new file mode 100644 index 0000000..899f712 --- /dev/null +++ b/backend/migrations/003_delete_usage_record.up.sql @@ -0,0 +1,167 @@ +-- Add an atomic usage-record deletion path. The operation restores the +-- entitlement period and reflows stored balance snapshots for later rows. + +CREATE FUNCTION condon.delete_usage_record( + p_usage_record_id uuid +) +RETURNS void +LANGUAGE plpgsql +VOLATILE +SECURITY INVOKER +SET search_path = pg_catalog +AS $function$ +DECLARE + v_usage condon.usage_records%ROWTYPE; + v_period condon.entitlement_periods%ROWTYPE; + v_initial_balance integer; + v_now timestamptz := pg_catalog.clock_timestamp(); +BEGIN + IF p_usage_record_id IS NULL THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'CONDON_USAGE_RECORD_ID_REQUIRED'; + END IF; + + SELECT * + INTO v_usage + FROM condon.usage_records + WHERE id = p_usage_record_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = 'P0002', + MESSAGE = 'CONDON_USAGE_RECORD_NOT_FOUND'; + END IF; + + SELECT * + INTO v_period + FROM condon.entitlement_periods + WHERE id = v_usage.entitlement_period_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = 'P0002', + MESSAGE = 'CONDON_ENTITLEMENT_PERIOD_NOT_FOUND'; + END IF; + + DELETE FROM condon.entitlement_ledger + WHERE usage_record_id = v_usage.id; + + DELETE FROM condon.usage_records + WHERE id = v_usage.id; + + v_initial_balance := v_period.annual_grant + v_period.carry_forward; + + WITH ordered AS ( + SELECT + ur.id, + ( + v_initial_balance + - COALESCE( + SUM(ur.use_nights) OVER ( + ORDER BY + CASE WHEN ur.source_sheet IS NULL THEN 1 ELSE 0 END, + ur.source_sheet NULLS LAST, + ur.source_sequence NULLS LAST, + ur.source_row NULLS LAST, + ur.created_at ASC, + ur.id ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), + 0 + ) + )::integer AS balance_before, + ( + v_initial_balance + - SUM(ur.use_nights) OVER ( + ORDER BY + CASE WHEN ur.source_sheet IS NULL THEN 1 ELSE 0 END, + ur.source_sheet NULLS LAST, + ur.source_sequence NULLS LAST, + ur.source_row NULLS LAST, + ur.created_at ASC, + ur.id ASC + ) + )::integer AS balance_after + FROM condon.usage_records AS ur + WHERE ur.entitlement_period_id = v_period.id + ) + UPDATE condon.usage_records AS ur + SET + balance_before = ordered.balance_before, + balance_after = ordered.balance_after + FROM ordered + WHERE ur.id = ordered.id; + + WITH ordered AS ( + SELECT + ur.id, + ( + v_initial_balance + - COALESCE( + SUM(ur.use_nights) OVER ( + ORDER BY + CASE WHEN ur.source_sheet IS NULL THEN 1 ELSE 0 END, + ur.source_sheet NULLS LAST, + ur.source_sequence NULLS LAST, + ur.source_row NULLS LAST, + ur.created_at ASC, + ur.id ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), + 0 + ) + )::integer AS balance_before, + ( + v_initial_balance + - SUM(ur.use_nights) OVER ( + ORDER BY + CASE WHEN ur.source_sheet IS NULL THEN 1 ELSE 0 END, + ur.source_sheet NULLS LAST, + ur.source_sequence NULLS LAST, + ur.source_row NULLS LAST, + ur.created_at ASC, + ur.id ASC + ) + )::integer AS balance_after + FROM condon.usage_records AS ur + WHERE ur.entitlement_period_id = v_period.id + ) + UPDATE condon.entitlement_ledger AS ledger + SET + balance_before = ordered.balance_before, + balance_after = ordered.balance_after + FROM ordered + WHERE ledger.usage_record_id = ordered.id + AND ledger.entry_type = 'usage'; + + UPDATE condon.entitlement_periods + SET + current_balance = ( + v_initial_balance + - COALESCE( + ( + SELECT SUM(ur.use_nights) + FROM condon.usage_records AS ur + WHERE ur.entitlement_period_id = v_period.id + ), + 0 + ) + )::integer, + row_version = row_version + 1, + updated_at = v_now + WHERE id = v_period.id; + + DELETE FROM condon.bookings AS booking + WHERE booking.confirmation_no = v_usage.confirmation_no + AND NOT EXISTS ( + SELECT 1 + FROM condon.usage_records AS ur + WHERE ur.confirmation_no = booking.confirmation_no + ); +END; +$function$; + +REVOKE ALL ON FUNCTION condon.delete_usage_record(uuid) FROM PUBLIC; diff --git a/backend/scripts/check-migrations.mjs b/backend/scripts/check-migrations.mjs index 6225219..b61e113 100644 --- a/backend/scripts/check-migrations.mjs +++ b/backend/scripts/check-migrations.mjs @@ -26,12 +26,24 @@ const legacyDownPath = path.join( "migrations", "002_legacy_import_and_bookings.down.sql" ); +const deleteUpPath = path.join( + backendDirectory, + "migrations", + "003_delete_usage_record.up.sql" +); +const deleteDownPath = path.join( + backendDirectory, + "migrations", + "003_delete_usage_record.down.sql" +); -const [upSql, downSql, legacyUpSql, legacyDownSql] = await Promise.all([ +const [upSql, downSql, legacyUpSql, legacyDownSql, deleteUpSql, deleteDownSql] = await Promise.all([ readFile(upPath, "utf8"), readFile(downPath, "utf8"), readFile(legacyUpPath, "utf8"), - readFile(legacyDownPath, "utf8") + readFile(legacyDownPath, "utf8"), + readFile(deleteUpPath, "utf8"), + readFile(deleteDownPath, "utf8") ]); const checks = []; @@ -166,6 +178,21 @@ record( && /^DROP FUNCTION condon\.create_usage_record_v2/gm.test(legacyDownSql) && /^DROP INDEX condon\.usage_records_source_location_key;$/gm.test(legacyDownSql) ); +record( + "delete migration is an internal transactional function", + /^CREATE FUNCTION condon\.delete_usage_record\(\s*p_usage_record_id uuid\s*\)/m.test(deleteUpSql) + && /^RETURNS void$/m.test(deleteUpSql) + && /^SECURITY INVOKER$/m.test(deleteUpSql) + && deleteUpSql.includes("FOR UPDATE") + && deleteUpSql.includes("DELETE FROM condon.entitlement_ledger") + && deleteUpSql.includes("UPDATE condon.entitlement_periods") + && /^REVOKE ALL ON FUNCTION condon\.delete_usage_record\(uuid\) FROM PUBLIC;$/m.test(deleteUpSql) +); +record( + "delete migration down reverses its function", + /^DROP FUNCTION condon\.delete_usage_record\(uuid\);$/m.test(deleteDownSql) + && !/\bCASCADE\b/i.test(deleteDownSql) +); for (const check of checks) { process.stdout.write( diff --git a/backend/scripts/import-preview-server.mjs b/backend/scripts/import-preview-server.mjs index e4616f1..50b4552 100644 --- a/backend/scripts/import-preview-server.mjs +++ b/backend/scripts/import-preview-server.mjs @@ -166,6 +166,7 @@ export class ImportSnapshotRepository { )); this.ownerById = new Map(this.owners.map(owner => [owner.id, owner])); this.periodBalances = new Map(); + this.periodInitialBalances = new Map(); this.periodYears = new Set(); this.idempotentResponses = new Map(); @@ -183,6 +184,7 @@ export class ImportSnapshotRepository { const rows2025 = usageByRoomAndYear.get(`${room}|2025`) ?? []; const carryForward = rows2025.length > 0 ? Number(rows2025.at(-1).balance) : 0; this.periodYears.add(periodKey(owner.id, 2026)); + this.periodInitialBalances.set(periodKey(owner.id, 2026), 15 + carryForward); const used2026 = (usageByRoomAndYear.get(`${room}|2026`) ?? []) .reduce((sum, record) => sum + Number(record.use), 0); this.periodBalances.set(periodKey(owner.id, 2026), 15 + carryForward - used2026); @@ -195,6 +197,7 @@ export class ImportSnapshotRepository { for (const year of otherYears) { const rows = usageByRoomAndYear.get(`${room}|${year}`) ?? []; this.periodYears.add(periodKey(owner.id, year)); + this.periodInitialBalances.set(periodKey(owner.id, year), 15); this.periodBalances.set( periodKey(owner.id, year), 15 - rows.reduce((sum, record) => sum + Number(record.use), 0) @@ -252,7 +255,7 @@ export class ImportSnapshotRepository { status: "ok", database: "booking_test", schema: "condon", - migrationVersion: "002_legacy_import_and_bookings:snapshot" + migrationVersion: "003_delete_usage_record:snapshot" }; } @@ -303,6 +306,38 @@ export class ImportSnapshotRepository { return paginate(filtered.map(entry => ({ ...entry.publicRecord })), query); } + rebuildPeriodBalance(ownerAccountId, periodYear) { + const key = periodKey(ownerAccountId, periodYear); + const entries = this.usageEntries + .filter(entry => entry.publicRecord.ownerAccountId === ownerAccountId && entry.periodYear === periodYear) + .sort((left, right) => { + const leftImported = left.sourceSheet !== null; + const rightImported = right.sourceSheet !== null; + if (leftImported !== rightImported) return leftImported ? -1 : 1; + if (leftImported) return sourceOrder(left, right); + return String(left.publicRecord.createdAt).localeCompare(String(right.publicRecord.createdAt)) + || left.publicRecord.id.localeCompare(right.publicRecord.id); + }); + let balance = this.periodInitialBalances.get(key) ?? 15; + for (const entry of entries) { + balance -= entry.publicRecord.use; + entry.publicRecord.balance = balance; + } + this.periodBalances.set(key, balance); + } + + async deleteUsageRecord(id) { + const index = this.usageEntries.findIndex(entry => entry.publicRecord.id === id); + if (index < 0) { + throw new ApiError(404, "NOT_FOUND", "The requested business record was not found"); + } + const [removed] = this.usageEntries.splice(index, 1); + for (const [key, value] of this.idempotentResponses) { + if (value.response.id === id) this.idempotentResponses.delete(key); + } + this.rebuildPeriodBalance(removed.publicRecord.ownerAccountId, removed.periodYear); + } + async createUsageRecord(input) { const previous = this.idempotentResponses.get(input.idempotencyKey); if (previous) { diff --git a/backend/scripts/verify-deployment.mjs b/backend/scripts/verify-deployment.mjs index 9a0c717..e09e981 100644 --- a/backend/scripts/verify-deployment.mjs +++ b/backend/scripts/verify-deployment.mjs @@ -17,6 +17,9 @@ const MIGRATION_CHECKSUM = const MIGRATION_V2 = "002_legacy_import_and_bookings"; const MIGRATION_V2_CHECKSUM = "cfc38024984c883490cb46ab8ab1fca2e72c67f10dc3a43c179b12a8e6f2405b"; +const MIGRATION_V3 = "003_delete_usage_record"; +const MIGRATION_V3_CHECKSUM = + "9110464c03ed18385fbc2da1d4369715fcbf83148d7a0f2550c25710a5f54148"; const expectedColumns = { bookings: [ @@ -334,11 +337,12 @@ try { calculate_multiplier: { volatility: "s" }, create_usage_record: { volatility: "v" }, create_usage_record_v2: { volatility: "v" }, + delete_usage_record: { volatility: "v" }, ensure_booking_for_usage: { volatility: "v" }, open_entitlement_period: { volatility: "v" } }; const functionsValid = - functionResult.rowCount === 5 + functionResult.rowCount === 6 && functionResult.rows.every(row => expectedFunctions[row.function_name]?.volatility === row.volatility && row.security_definer === false @@ -371,13 +375,16 @@ try { && businessCounts.usage_record_count === 157 && businessCounts.ledger_count === 547, migrationVerified: - migrationResult.rowCount === 2 + migrationResult.rowCount === 3 && migrationResult.rows.some(row => row.version === MIGRATION_VERSION && row.checksum === MIGRATION_CHECKSUM ) && migrationResult.rows.some(row => row.version === MIGRATION_V2 && row.checksum === MIGRATION_V2_CHECKSUM ) + && migrationResult.rows.some(row => + row.version === MIGRATION_V3 && row.checksum === MIGRATION_V3_CHECKSUM + ) }; process.stdout.write(`${JSON.stringify({ diff --git a/backend/src/app.ts b/backend/src/app.ts index aed64ec..9963402 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -31,6 +31,9 @@ const ownerQuerySchema = z.object({ const ownerParamsSchema = z.object({ id: z.string().uuid() }); +const usageRecordParamsSchema = z.object({ + id: z.string().uuid() +}); const ownerDetailQuerySchema = z.object({ year: z.coerce.number().int().min(2000).max(9999).default(currentYear) }); @@ -193,7 +196,7 @@ export async function buildApp( "http://localhost:4173" ], credentials: true, - methods: ["GET", "POST"] + methods: ["GET", "POST", "DELETE"] }); const publicAuthPaths = new Set([ @@ -517,6 +520,30 @@ export async function buildApp( return reply.code(201).send(result); }); + app.delete("/usage-records/:id", { + schema: { + tags: ["usage-records"], + params: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", format: "uuid" } + } + }, + response: { + 204: { type: "null" }, + 400: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, + 500: errorResponseSchema + } + } + }, async (request, reply) => { + const params = parse(usageRecordParamsSchema, request.params); + await options.repository.deleteUsageRecord(params.id); + return reply.code(204).send(); + }); + app.get("/dashboard", { schema: { tags: ["dashboard"], diff --git a/backend/src/errors.ts b/backend/src/errors.ts index af9cb0c..4231eb4 100644 --- a/backend/src/errors.ts +++ b/backend/src/errors.ts @@ -43,6 +43,7 @@ export function mapDatabaseError(error: unknown): ApiError { if ( message === "CONDON_OWNER_ACCOUNT_NOT_FOUND" || message === "CONDON_ENTITLEMENT_PERIOD_NOT_FOUND" + || message === "CONDON_USAGE_RECORD_NOT_FOUND" || message.endsWith("_ROOM_TYPE_NOT_FOUND") ) { return new ApiError(404, "NOT_FOUND", "The requested business record was not found"); diff --git a/backend/src/postgres-repository.ts b/backend/src/postgres-repository.ts index 5ede53e..25b2f1c 100644 --- a/backend/src/postgres-repository.ts +++ b/backend/src/postgres-repository.ts @@ -353,6 +353,17 @@ export class PostgresCondoRepository implements CondoRepository { } } + async deleteUsageRecord(id: string): Promise { + try { + await this.pool.query( + "SELECT condon.delete_usage_record($1::uuid)", + [id] + ); + } catch (error) { + throw mapDatabaseError(error); + } + } + async getDashboard(periodYear: number): Promise { const client = await this.pool.connect(); try { diff --git a/backend/src/repository.ts b/backend/src/repository.ts index c9ad806..a484d5d 100644 --- a/backend/src/repository.ts +++ b/backend/src/repository.ts @@ -18,6 +18,7 @@ export interface CondoRepository { getOwnerAccount(id: string, periodYear: number): Promise; listUsageRecords(query: UsageRecordQuery): Promise; createUsageRecord(input: CreateUsageRecordInput): Promise; + deleteUsageRecord(id: string): Promise; getDashboard(periodYear: number): Promise; close(): Promise; } diff --git a/backend/tests/app.test.ts b/backend/tests/app.test.ts index 235726f..531d0de 100644 --- a/backend/tests/app.test.ts +++ b/backend/tests/app.test.ts @@ -84,6 +84,7 @@ function createRepository( createdAt: "2026-07-29T00:00:00.000Z" }; }, + async deleteUsageRecord() {}, async getDashboard(periodYear: number) { return { periodYear, @@ -389,6 +390,34 @@ test("business errors retain safe status and code", async t => { assert.equal(JSON.stringify(response.json()).includes("password"), false); }); +test("usage deletion validates the record id and returns no content", async t => { + let captured: string | undefined; + const repository = createRepository({ + async deleteUsageRecord(id) { + captured = id; + } + }); + const { app, cookie } = await buildAuthenticatedApp(repository); + t.after(() => app.close()); + + const response = await app.inject({ + method: "DELETE", + url: `/usage-records/${usageId}`, + headers: { cookie } + }); + assert.equal(response.statusCode, 204); + assert.equal(response.body, ""); + assert.equal(captured, usageId); + + const malformed = await app.inject({ + method: "DELETE", + url: "/usage-records/not-a-uuid", + headers: { cookie } + }); + assert.equal(malformed.statusCode, 400); + assert.equal(malformed.json().error.code, "VALIDATION_ERROR"); +}); + test("dashboard and OpenAPI expose the planned surface", async t => { const { app, cookie } = await buildAuthenticatedApp(createRepository()); t.after(() => app.close()); @@ -412,6 +441,7 @@ test("dashboard and OpenAPI expose the planned surface", async t => { "/owner-accounts", "/owner-accounts/{id}", "/room-types", - "/usage-records" + "/usage-records", + "/usage-records/{id}" ]); }); diff --git a/index.html b/index.html index c3d0dea..a1b0f65 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Condo - +
-
-
+
+
WEDNESDAY, JULY 29

Condon

-
-
- -
+
Owner rooms @@ -244,12 +234,16 @@ 190 accounts shown
-
+
- +
NoTransfer DateNameRoom No.Room TypeUnit No.Member No.Remaining stay privileges
NoNameRoom No.Room TypeUnit No.Member No.Transfer DateRemaining stay privileges
+