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)} |
|
- |
+
+
+
+ |
`;
}).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 ? `
- | ${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")} |
${records.map(record => `
| ${record.confirmation} |
${shortDate(record.checkin)} |
${shortDate(record.checkout)} |
${record.nights} |
${record.deducted} |
- ${escapeHtml(record.usedType)} |
+ ${escapeHtml(record.usedType)} |
${plural(record.balance, "night")} |
|
+ |
`).join("")}
` : `${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
-
+
@@ -106,6 +106,14 @@
+
+
@@ -130,21 +138,6 @@
-
- Current period
- 2026
-
-
-
- Signed in as
- —
-
-
-
-
@@ -158,14 +151,11 @@
-
-
+
+
WEDNESDAY, JULY 29
Condon
-
-
-
-
+
Owner rooms
@@ -244,12 +234,16 @@
190 accounts shown
-
+
- | No | Transfer Date | Name | Room No. | Room Type | Unit No. | Member No. | Remaining stay privileges | |
+ | No | Name | Room No. | Room Type | Unit No. | Member No. | Transfer Date | Remaining stay privileges | |
+
- | Confirmation No. | Owner / room | Check-in | Check-out | Night | Use | Balance | Used Room Type | Remark | |
+ | Confirmation No. | Owner / room | Check-in | Check-out | Night | Use | Balance | Used Room Type | Remark | Actions |
@@ -276,6 +270,19 @@
+
+
+
+
+ Confirmation No.
+
+