Files
wyndham-Condon/app.js
2026-08-03 16:43:57 +08:00

2118 lines
103 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

let ROOM_TYPES = ["RM1", "RM2", "RM3", "RM4", "SU1", "SU2", "SU3", "SU6", "UG1", "UG2", "AC2"];
const ROOM_TYPE_DEFINITIONS = Object.freeze({
RM1: { en: "No balcony TWN (4+4 F)", zh: "无阳台双床4+4 F" },
RM2: { en: "Superior King (6F)", zh: "高级大床6F" },
RM3: { en: "Superior TWN (4+4 F)", zh: "高级双床4+4 F" },
RM4: { en: "Superior TWN (6+4 F)", zh: "高级双床6+4 F" },
UG1: { en: "Deluxe King (6F)", zh: "豪华大床6F" },
UG2: { en: "Deluxe TWN (6+4 F)", zh: "豪华双床6+4 F" },
SU1: { en: "Junior Suite King (6F)", zh: "初级套房大床6F" },
SU2: { en: "Junior Suite King (6F) · Pool view", zh: "初级套房大床6F· 泳池景" },
SU6: { en: "Junior Suite TWN (4+4 F)", zh: "初级套房双床4+4 F" },
SU3: { en: "Two bedroom / Family room (King + TWN)", zh: "两卧室/家庭房(大床 + 双床)" },
AC1: { en: "Handicap King", zh: "无障碍大床" },
AC2: { en: "Handicap TWN", zh: "无障碍双床" }
});
const API_RUNTIME = window.CONDO_API?.runtime || { mode: "demo", periodYear: new Date().getUTCFullYear() };
const API_CLIENT = window.CONDO_API?.client || null;
const IS_API_MODE = API_RUNTIME.mode === "api";
const PERIOD_YEAR = API_RUNTIME.periodYear;
if (!IS_API_MODE && (!Array.isArray(window.CONDO_OWNER_DATA) || window.CONDO_OWNER_DATA.length !== 190)) {
throw new Error("The complete 190-record owner dataset could not be loaded.");
}
let owners = IS_API_MODE ? [] : window.CONDO_OWNER_DATA.map(record => ({
...record,
used: Math.max(0, 15 - record.remaining)
}));
let stayRecords = IS_API_MODE ? [] : [
{ confirmation: "26081472", ownerId: 3, checkin: "2026-08-04", checkout: "2026-08-06", nights: 2, usedType: "RM3", multiplier: 1, deducted: 2, remark: "" },
{ confirmation: "26082318", ownerId: 4, checkin: "2026-08-09", checkout: "2026-08-12", nights: 3, usedType: "SU1", multiplier: 2, deducted: 6, remark: "" },
{ confirmation: "26082405", ownerId: 5, checkin: "2026-08-14", checkout: "2026-08-16", nights: 2, usedType: "RM2", multiplier: 1, deducted: 2, remark: "" },
{ confirmation: "26071190", ownerId: 1, checkin: "2026-07-11", checkout: "2026-07-13", nights: 2, usedType: "RM3", multiplier: 1, deducted: 2, remark: "" },
{ confirmation: "26062844", ownerId: 2, checkin: "2026-06-28", checkout: "2026-06-30", nights: 2, usedType: "RM3", multiplier: 1, deducted: 2, remark: "" },
{ 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,
entrySequence: null,
balance: owners.find(owner => owner.id === record.ownerId).remaining
}));
let nextManualEntrySequence = 1;
let aggregateRemaining = owners.reduce((total, owner) => total + owner.remaining, 0);
let aggregateUsed = owners.reduce((total, owner) => total + owner.used, 0);
let dashboardData = null;
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({
en: Object.freeze({ html: "en", intl: "en-US", name: "English" }),
zh: Object.freeze({ html: "zh-CN", intl: "zh-CN", name: "中文" }),
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;
let isLoggingOut = false;
let sessionErrorTimer = null;
let loginErrorKey = null;
let sessionErrorKey = null;
function usageRecordsInDisplayOrder(records) {
return [...records].sort((a, b) => {
if (a.recordSource === "api" || b.recordSource === "api") {
const createdDifference = String(b.enteredAt || "").localeCompare(String(a.enteredAt || ""));
return createdDifference || String(b.id || "").localeCompare(String(a.id || ""));
}
const aIsManual = a.recordSource === "manual";
const bIsManual = b.recordSource === "manual";
if (aIsManual !== bIsManual) return aIsManual ? -1 : 1;
if (aIsManual) {
const sequenceDifference = (b.entrySequence || 0) - (a.entrySequence || 0);
return sequenceDifference || String(b.enteredAt || "").localeCompare(String(a.enteredAt || ""));
}
return (a.importOrder ?? Number.MAX_SAFE_INTEGER) - (b.importOrder ?? Number.MAX_SAFE_INTEGER);
});
}
const I18N = {
en: {
appTitle: "Condo",
metaDescription: "Condo owner annual stay privilege management prototype",
ownerOperations: "Owner operations",
privateWorkspace: "PRIVATE WORKSPACE",
loginIntroTitle: "Owner stay privileges, securely managed.",
loginIntroDescription: "A focused workspace for owner accounts, room usage and annual privilege balances.",
authorizedAccessOnly: "Authorized operator access only",
secureAccess: "SECURE ACCESS",
welcomeBack: "Welcome back",
signInDescription: "Sign in to continue to the Condo owner workspace.",
checkingSession: "Checking your secure session…",
username: "Username",
usernamePlaceholder: "Enter your username",
password: "Password",
passwordPlaceholder: "Enter your password",
showPassword: "Show password",
hidePassword: "Hide password",
signIn: "Sign in",
signingIn: "Signing in…",
secureSessionNote: "Your session is protected and expires automatically.",
internalSystem: "Internal operations system",
credentialsRequired: "Enter both your username and password.",
invalidCredentials: "The username or password is incorrect.",
authServiceUnavailable: "The secure login service is unavailable. Start the API and try again.",
sessionExpired: "Your session has expired. Sign in again to continue.",
signedInAs: "Signed in as",
signOut: "Sign out",
signingOut: "Signing out…",
signOutFailed: "Could not sign out. Check the API connection and try again.",
primaryNavigation: "Primary navigation",
dashboard: "Dashboard",
ownerAccounts: "Owner Accounts",
ownerAccountsHeading: "Owner accounts",
language: "Language",
runtimeDemoTitle: "Demo data",
runtimeDemoMessage: "Showing the local prototype dataset. Use ?mode=demo to enter prototype mode explicitly.",
runtimeApiLoadingTitle: "Connecting to local API",
runtimeApiLoadingMessage: "Loading database-backed accounts, usage and dashboard data…",
runtimeApiReadyTitle: "Local API connected",
runtimeApiReadyMessage: ({ count }) => `${formatNumber(count)} database account${count === 1 ? "" : "s"} loaded.`,
runtimeApiErrorTitle: "Local API unavailable",
runtimeApiErrorMessage: "No demo data was substituted. Start the API and retry.",
retry: "Retry",
openNavigation: "Open navigation",
closeNavigation: "Close navigation",
currentPeriod: "Current period",
currentPeriodAria: ({ year }) => `Current period ${year}`,
dashboardDate: "WEDNESDAY, JULY 29",
addUsageRecord: "Add usage record",
ownerRooms: "Owner rooms",
activeAccounts: "Active accounts",
remainingPrivileges: "Remaining privileges",
nightsAvailable: "Nights available",
usedThisYear: "Used this year",
privilegeNightsDeducted: "Privilege nights deducted",
privilegeUsage: "Privilege usage",
deductedByMonth: "Deducted nights by month",
nightsUsed2026: ({ year }) => `nights used in ${year}`,
monthlyChartLabel: "Monthly privilege use chart",
roomTypeMix: "Room type mix",
roomTypeMixDescription: "Owner room mix compared with consumed room nights",
roomTypeMixLegendAria: "Room type chart legend",
sourceRoomType: "Room Type",
roomTypeMixNote: "Bars show each room type as a share of its own series total.",
ownerRoomSeriesTotal: ({ count }) => `${count} owner rooms`,
usedRoomNightSeriesTotal: ({ count }) => `${count} room nights`,
roomTypeMixChartAria: ({ ownerTotal, usedTotal }) => `Room type distribution comparing ${ownerTotal} owner rooms with ${usedTotal} consumed room nights.`,
roomTypeSeriesAria: ({ type, count, percent }) => `Room Type ${type}: ${count} owner rooms, ${percent}.`,
usedRoomTypeSeriesAria: ({ type, count, percent }) => `Used Room Type ${type}: ${count} room nights, ${percent}.`,
monthJan: "Jan",
monthFeb: "Feb",
monthMar: "Mar",
monthApr: "Apr",
monthMay: "May",
monthJun: "Jun",
monthJul: "Jul",
monthAug: "Aug",
monthSep: "Sep",
monthOct: "Oct",
monthNov: "Nov",
monthDec: "Dec",
confirmation: "Confirmation",
owner: "Owner",
stayDates: "Stay dates",
usedRoom: "Used room",
deduction: "Deduction",
ownerWorkspaceAria: "Owner account workspace",
accounts: "Accounts",
usageHistory: "Usage history",
ownerSearchPlaceholder: "Search No., owner, room, unit or member…",
ownerSearchAria: "Search owner accounts",
purchasedTypeFilterAria: "Filter by purchased room type",
allRoomTypes: "All room types",
no: "No",
transferDate: "Transfer Date",
name: "Name",
roomNo: "Room No.",
roomType: "Room Type",
unitNo: "Unit No.",
memberNo: "Member No.",
remainingStayPrivileges: "Remaining stay privileges",
usageSearchPlaceholder: "Search confirmation, owner, room or member…",
usageSearchAria: "Search usage history",
usedTypeFilterAria: "Filter by used room type",
allUsedRoomTypes: "All used room types",
ownerRoom: "Owner / room",
checkIn: "Check-in",
checkOut: "Check-out",
night: "Night",
nights: "Nights",
use: "Use",
drawerUsingNights: "Use",
balance: "Balance (nights)",
usedType: "Used type",
multiplier: "Multiplier",
deducted: "Deducted",
remark: "Remark",
newUsage: "NEW USAGE",
reviewDeduction: "Review the deduction before saving.",
closeForm: "Close form",
ownerAccount: "Owner account",
ownerLockNote: "This account was selected from the owner workspace.",
confirmationNo: "Confirmation No.",
digitsOnlyPlaceholder: "Enter digits only",
usedRoomType: "Used Room Type",
deductionMultiplier: "Deduction multiplier",
selectMultiplier: "Select a multiplier",
ac2RequiredNote: "Required because AC2 is involved in this stay.",
remarkPlaceholder: "Add an optional note",
deductionPreview: "Use preview",
automaticRule: "Automatic rule",
ac2ManualRule: "AC2 manual rule",
currentBalance: "Current balance",
afterSaving: "After saving",
cancel: "Cancel",
saveRecord: "Save record",
ownerAccountUpper: "OWNER ACCOUNT",
accountDetails: "Account details",
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`,
noOwnerAccountsFound: "No owner accounts found",
tryOwnerFilter: "Try a different search or room-type filter.",
noDatabaseOwners: "No database accounts yet",
noDatabaseOwnersDescription: "Import or create owner accounts before recording usage.",
room: "Room",
member: "Member",
roomLine: ({ room }) => `Room ${room}`,
roomMemberLine: ({ room, member }) => `Room ${room} · Member ${member}`,
confirmationResult: ({ confirmation }) => `Confirmation ${confirmation}`,
noUsageRecordsFound: "No usage records found",
tryUsageFilter: "Try a different confirmation, owner or room-type filter.",
noDatabaseUsage: "No database usage records yet",
noDatabaseUsageDescription: "Usage records will appear here after accounts and usage data are added.",
noRemark: "No remark",
to: "to",
purchasedType: "Purchased type",
remaining: "Remaining",
notCalculated: "Not calculated",
pending: "Pending",
checkoutLater: "Check-out must be later than check-in.",
selectAc2Multiplier: "Select a deduction multiplier because AC2 is involved.",
insufficientPrivileges: ({ required, available }) => `Insufficient remaining stay privileges. Required: ${required}; available: ${available}.`,
duplicateConfirmation: "This confirmation number already exists.",
recordingRoom: ({ room }) => `Recording usage for Room ${room}.`,
chooseOwnerReview: "Choose an owner account and review the deduction before saving.",
noAccountsForUsage: "No owner account is available for a usage record.",
entitlementUnavailable: "This account has no entitlement balance for the selected year.",
savingRecord: "Saving…",
usageSaveFailed: "The usage record could not be saved. Review the data and try again.",
usageSaveInsufficient: "The account no longer has enough stay privileges. Reload and review the balance.",
usageSaveConflict: "This request conflicts with current data. Check the confirmation number and try again.",
usageSaveNotFound: "The account, entitlement period or room type is no longer available.",
usageSaveRuleViolation: "The record does not meet the current usage rules.",
usageSaveNetwork: "The local API could not be reached. The record was not saved.",
transferDateSubtitle: ({ date }) => `Transfer Date: ${date}`,
remaining2026: ({ year }) => `${year} remaining stay privileges`,
annualGrant: "Annual grant",
usedIn2026: ({ year }) => `Used in ${year}`,
usageRecords: "Usage records",
accountInformation: "Account information",
workbookSource: "Workbook source",
databaseSource: "Database source",
purchasedRoomType: "Purchased room type",
roomUsed: ({ type }) => `${type} room used`,
amountDeducted: ({ amount }) => `${amount} deducted`,
stay: "Stay",
noUsage2026: ({ year }) => `No usage records in ${year}`,
addFirstUsage: "Add the first usage record for this account.",
usageSavedMessage: ({ deduction, room }) => `${deduction} were deducted from Room ${room}.`,
noGlobalMatches: "No matching owners or usage records."
},
zh: {
appTitle: "Condo 业主管理",
metaDescription: "Condo 业主年度入住权益管理原型",
ownerOperations: "业主运营",
privateWorkspace: "内部工作区",
loginIntroTitle: "安全管理业主入住权益。",
loginIntroDescription: "集中管理业主账户、房间使用记录与年度权益余额。",
authorizedAccessOnly: "仅限授权操作人员访问",
secureAccess: "安全登录",
welcomeBack: "欢迎回来",
signInDescription: "登录后继续使用 Condo 业主工作区。",
checkingSession: "正在检查安全会话…",
username: "用户名",
usernamePlaceholder: "请输入用户名",
password: "密码",
passwordPlaceholder: "请输入密码",
showPassword: "显示密码",
hidePassword: "隐藏密码",
signIn: "登录",
signingIn: "正在登录…",
secureSessionNote: "会话受安全保护,并会自动过期。",
internalSystem: "内部运营系统",
credentialsRequired: "请输入用户名和密码。",
invalidCredentials: "用户名或密码不正确。",
authServiceUnavailable: "安全登录服务暂时不可用,请启动 API 后重试。",
sessionExpired: "登录会话已过期,请重新登录后继续。",
signedInAs: "当前账号",
signOut: "退出登录",
signingOut: "正在退出…",
signOutFailed: "退出失败,请检查 API 连接后重试。",
primaryNavigation: "主导航",
dashboard: "概览",
ownerAccounts: "业主账户",
ownerAccountsHeading: "业主账户",
language: "语言",
runtimeDemoTitle: "演示数据",
runtimeDemoMessage: "当前显示本地原型数据;仅在地址增加 ?mode=demo 时使用原型数据。",
runtimeApiLoadingTitle: "正在连接本地 API",
runtimeApiLoadingMessage: "正在加载数据库账户、使用记录和概览数据…",
runtimeApiReadyTitle: "本地 API 已连接",
runtimeApiReadyMessage: ({ count }) => `已加载 ${formatNumber(count)} 个数据库账户。`,
runtimeApiErrorTitle: "本地 API 不可用",
runtimeApiErrorMessage: "未使用演示数据替代。请启动 API 后重试。",
retry: "重试",
openNavigation: "打开导航",
closeNavigation: "关闭导航",
currentPeriod: "当前周期",
currentPeriodAria: ({ year }) => `当前周期 ${year}`,
dashboardDate: "7月29日 星期三",
addUsageRecord: "新增使用记录",
ownerRooms: "业主房间",
activeAccounts: "有效账户",
remainingPrivileges: "剩余权益",
nightsAvailable: "可用晚数",
usedThisYear: "本年已使用",
privilegeNightsDeducted: "已扣减权益晚数",
privilegeUsage: "权益使用",
deductedByMonth: "每月扣减晚数",
nightsUsed2026: ({ year }) => `${year}年已使用权益晚数`,
monthlyChartLabel: "每月权益使用图",
roomTypeMix: "房型结构",
roomTypeMixDescription: "业主原始房型与实际使用房晚结构对比",
roomTypeMixLegendAria: "房型结构图例",
sourceRoomType: "原始房型",
roomTypeMixNote: "条形长度表示各房型在各自总量中的占比。",
ownerRoomSeriesTotal: ({ count }) => `${count} 间业主房`,
usedRoomNightSeriesTotal: ({ count }) => `${count} 个使用房晚`,
roomTypeMixChartAria: ({ ownerTotal, usedTotal }) => `房型分布:对比 ${ownerTotal} 间业主房与 ${usedTotal} 个实际使用房晚。`,
roomTypeSeriesAria: ({ type, count, percent }) => `原始房型 ${type}${count} 间业主房,占 ${percent}`,
usedRoomTypeSeriesAria: ({ type, count, percent }) => `使用房型 ${type}${count} 个房晚,占 ${percent}`,
monthJan: "1月",
monthFeb: "2月",
monthMar: "3月",
monthApr: "4月",
monthMay: "5月",
monthJun: "6月",
monthJul: "7月",
monthAug: "8月",
monthSep: "9月",
monthOct: "10月",
monthNov: "11月",
monthDec: "12月",
confirmation: "确认号",
owner: "业主",
stayDates: "入住日期",
usedRoom: "使用房型",
deduction: "扣减",
ownerWorkspaceAria: "业主账户工作区",
accounts: "账户",
usageHistory: "使用记录",
ownerSearchPlaceholder: "搜索编号、业主、房间、单元或会员号…",
ownerSearchAria: "搜索业主账户",
purchasedTypeFilterAria: "按购买房型筛选",
allRoomTypes: "所有房型",
no: "编号",
transferDate: "转让日期",
name: "姓名",
roomNo: "房间号",
roomType: "房型",
unitNo: "单元号",
memberNo: "会员号",
remainingStayPrivileges: "剩余入住权益",
usageSearchPlaceholder: "搜索确认号、业主、房间或会员号…",
usageSearchAria: "搜索使用记录",
usedTypeFilterAria: "按使用房型筛选",
allUsedRoomTypes: "所有使用房型",
ownerRoom: "业主 / 房间",
checkIn: "入住日期",
checkOut: "退房日期",
night: "晚数",
nights: "晚数",
use: "使用",
drawerUsingNights: "使用",
balance: "余额(晚数)",
usedType: "使用房型",
multiplier: "扣减倍数",
deducted: "已扣减",
remark: "备注",
newUsage: "新增使用",
reviewDeduction: "保存前请核对扣减结果。",
closeForm: "关闭表单",
ownerAccount: "业主账户",
ownerLockNote: "此账户来自业主账户工作区,已锁定。",
confirmationNo: "确认号",
digitsOnlyPlaceholder: "仅输入数字",
usedRoomType: "使用房型",
deductionMultiplier: "扣减倍数",
selectMultiplier: "请选择倍数",
ac2RequiredNote: "本次使用涉及 AC2必须选择扣减倍数。",
remarkPlaceholder: "添加可选备注",
deductionPreview: "使用预览",
automaticRule: "自动规则",
ac2ManualRule: "AC2 手动规则",
currentBalance: "当前余额",
afterSaving: "保存后",
cancel: "取消",
saveRecord: "保存记录",
ownerAccountUpper: "业主账户",
accountDetails: "账户详情",
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} 的账户`,
noOwnerAccountsFound: "未找到业主账户",
tryOwnerFilter: "请尝试其他搜索内容或房型筛选条件。",
noDatabaseOwners: "数据库中暂无业主账户",
noDatabaseOwnersDescription: "导入或创建业主账户后,才能登记使用记录。",
room: "房间",
member: "会员号",
roomLine: ({ room }) => `房间 ${room}`,
roomMemberLine: ({ room, member }) => `房间 ${room} · 会员号 ${member}`,
confirmationResult: ({ confirmation }) => `确认号 ${confirmation}`,
noUsageRecordsFound: "未找到使用记录",
tryUsageFilter: "请尝试其他确认号、业主或房型筛选条件。",
noDatabaseUsage: "数据库中暂无使用记录",
noDatabaseUsageDescription: "账户和使用数据建立后,记录会显示在这里。",
noRemark: "无备注",
to: "至",
purchasedType: "购买房型",
remaining: "剩余权益",
notCalculated: "尚未计算",
pending: "待计算",
checkoutLater: "退房日期必须晚于入住日期。",
selectAc2Multiplier: "本次使用涉及 AC2请选择扣减倍数。",
insufficientPrivileges: ({ required, available }) => `剩余入住权益不足。需要:${required};可用:${available}`,
duplicateConfirmation: "该确认号已存在。",
recordingRoom: ({ room }) => `正在记录房间 ${room} 的使用情况。`,
chooseOwnerReview: "请选择业主账户,并在保存前核对扣减结果。",
noAccountsForUsage: "当前没有可用于新增使用记录的业主账户。",
entitlementUnavailable: "该账户在所选年度没有可用权益余额。",
savingRecord: "正在保存…",
usageSaveFailed: "使用记录保存失败,请检查数据后重试。",
usageSaveInsufficient: "该账户当前权益不足,请刷新后重新核对余额。",
usageSaveConflict: "请求与当前数据冲突,请检查确认号后重试。",
usageSaveNotFound: "账户、权益期间或房型已不存在。",
usageSaveRuleViolation: "该记录不符合当前使用规则。",
usageSaveNetwork: "无法连接本地 API记录尚未保存。",
transferDateSubtitle: ({ date }) => `转让日期:${date}`,
remaining2026: ({ year }) => `${year}年剩余入住权益`,
annualGrant: "年度新增",
usedIn2026: ({ year }) => `${year}年已使用`,
usageRecords: "使用记录",
accountInformation: "账户信息",
workbookSource: "工作簿来源",
databaseSource: "数据库来源",
purchasedRoomType: "购买房型",
roomUsed: ({ type }) => `使用房型 ${type}`,
amountDeducted: ({ amount }) => `已扣减 ${amount}`,
stay: "入住",
noUsage2026: ({ year }) => `${year}年暂无使用记录`,
addFirstUsage: "为此账户新增第一条使用记录。",
usageSavedMessage: ({ deduction, room }) => `已从房间 ${room} 扣减 ${deduction}`,
noGlobalMatches: "未找到匹配的业主或使用记录。"
},
th: {
appTitle: "Condo การจัดการสิทธิ์เจ้าของห้อง",
metaDescription: "ต้นแบบระบบจัดการสิทธิ์การเข้าพักประจำปีของเจ้าของห้อง Condo",
ownerOperations: "การดำเนินงานเจ้าของห้อง",
privateWorkspace: "พื้นที่ทำงานภายใน",
loginIntroTitle: "จัดการสิทธิ์การเข้าพักของเจ้าของห้องอย่างปลอดภัย",
loginIntroDescription: "พื้นที่ทำงานสำหรับบัญชีเจ้าของห้อง การใช้ห้อง และยอดสิทธิ์ประจำปี",
authorizedAccessOnly: "สำหรับผู้ปฏิบัติงานที่ได้รับอนุญาตเท่านั้น",
secureAccess: "เข้าสู่ระบบอย่างปลอดภัย",
welcomeBack: "ยินดีต้อนรับกลับ",
signInDescription: "เข้าสู่ระบบเพื่อใช้งานพื้นที่ทำงานเจ้าของห้อง Condo ต่อ",
checkingSession: "กำลังตรวจสอบเซสชันที่ปลอดภัย…",
username: "ชื่อผู้ใช้",
usernamePlaceholder: "ป้อนชื่อผู้ใช้",
password: "รหัสผ่าน",
passwordPlaceholder: "ป้อนรหัสผ่าน",
showPassword: "แสดงรหัสผ่าน",
hidePassword: "ซ่อนรหัสผ่าน",
signIn: "เข้าสู่ระบบ",
signingIn: "กำลังเข้าสู่ระบบ…",
secureSessionNote: "เซสชันของคุณได้รับการปกป้องและจะหมดอายุโดยอัตโนมัติ",
internalSystem: "ระบบปฏิบัติการภายใน",
credentialsRequired: "กรุณาป้อนชื่อผู้ใช้และรหัสผ่าน",
invalidCredentials: "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง",
authServiceUnavailable: "บริการเข้าสู่ระบบอย่างปลอดภัยไม่พร้อมใช้งาน กรุณาเริ่ม API แล้วลองอีกครั้ง",
sessionExpired: "เซสชันหมดอายุแล้ว กรุณาเข้าสู่ระบบอีกครั้งเพื่อดำเนินการต่อ",
signedInAs: "เข้าสู่ระบบในชื่อ",
signOut: "ออกจากระบบ",
signingOut: "กำลังออกจากระบบ…",
signOutFailed: "ออกจากระบบไม่สำเร็จ กรุณาตรวจสอบการเชื่อมต่อ API แล้วลองอีกครั้ง",
primaryNavigation: "การนำทางหลัก",
dashboard: "แดชบอร์ด",
ownerAccounts: "บัญชีเจ้าของห้อง",
ownerAccountsHeading: "บัญชีเจ้าของห้อง",
language: "ภาษา",
runtimeDemoTitle: "ข้อมูลสาธิต",
runtimeDemoMessage: "กำลังแสดงชุดข้อมูลต้นแบบภายในเครื่อง ใช้ ?mode=demo เพื่อเข้าสู่โหมดต้นแบบโดยเฉพาะ",
runtimeApiLoadingTitle: "กำลังเชื่อมต่อ API ภายในเครื่อง",
runtimeApiLoadingMessage: "กำลังโหลดบัญชี รายการใช้สิทธิ์ และข้อมูลแดชบอร์ดจากฐานข้อมูล…",
runtimeApiReadyTitle: "เชื่อมต่อ API ภายในเครื่องแล้ว",
runtimeApiReadyMessage: ({ count }) => `โหลดบัญชีจากฐานข้อมูลแล้ว ${formatNumber(count)} บัญชี`,
runtimeApiErrorTitle: "API ภายในเครื่องไม่พร้อมใช้งาน",
runtimeApiErrorMessage: "ไม่มีการใช้ข้อมูลสาธิตแทน กรุณาเริ่ม API แล้วลองอีกครั้ง",
retry: "ลองอีกครั้ง",
openNavigation: "เปิดการนำทาง",
closeNavigation: "ปิดการนำทาง",
currentPeriod: "รอบปัจจุบัน",
currentPeriodAria: ({ year }) => `รอบปัจจุบัน ${year}`,
dashboardDate: "วันพุธที่ 29 กรกฎาคม",
addUsageRecord: "เพิ่มรายการใช้สิทธิ์",
ownerRooms: "ห้องของเจ้าของ",
activeAccounts: "บัญชีที่ใช้งานอยู่",
remainingPrivileges: "สิทธิ์คงเหลือ",
nightsAvailable: "คืนที่ใช้ได้",
usedThisYear: "ใช้ไปในปีนี้",
privilegeNightsDeducted: "คืนสิทธิ์ที่หักแล้ว",
privilegeUsage: "การใช้สิทธิ์",
deductedByMonth: "คืนที่หักแยกตามเดือน",
nightsUsed2026: ({ year }) => `คืนที่ใช้ในปี ${year}`,
monthlyChartLabel: "กราฟการใช้สิทธิ์รายเดือน",
roomTypeMix: "สัดส่วนประเภทห้อง",
roomTypeMixDescription: "เปรียบเทียบสัดส่วนห้องของเจ้าของกับจำนวนคืนห้องที่ใช้",
roomTypeMixLegendAria: "คำอธิบายกราฟประเภทห้อง",
sourceRoomType: "ประเภทห้องที่ซื้อ",
roomTypeMixNote: "แถบแสดงสัดส่วนของแต่ละประเภทห้องเมื่อเทียบกับยอดรวมของชุดข้อมูลนั้น",
ownerRoomSeriesTotal: ({ count }) => `${count} ห้องของเจ้าของ`,
usedRoomNightSeriesTotal: ({ count }) => `${count} คืนห้อง`,
roomTypeMixChartAria: ({ ownerTotal, usedTotal }) => `การกระจายประเภทห้อง เปรียบเทียบ ${ownerTotal} ห้องของเจ้าของกับ ${usedTotal} คืนห้องที่ใช้`,
roomTypeSeriesAria: ({ type, count, percent }) => `ประเภทห้อง ${type}: ${count} ห้องของเจ้าของ คิดเป็น ${percent}`,
usedRoomTypeSeriesAria: ({ type, count, percent }) => `ประเภทห้องที่ใช้ ${type}: ${count} คืนห้อง คิดเป็น ${percent}`,
monthJan: "ม.ค.",
monthFeb: "ก.พ.",
monthMar: "มี.ค.",
monthApr: "เม.ย.",
monthMay: "พ.ค.",
monthJun: "มิ.ย.",
monthJul: "ก.ค.",
monthAug: "ส.ค.",
monthSep: "ก.ย.",
monthOct: "ต.ค.",
monthNov: "พ.ย.",
monthDec: "ธ.ค.",
confirmation: "หมายเลขยืนยัน",
owner: "เจ้าของห้อง",
stayDates: "วันที่เข้าพัก",
usedRoom: "ห้องที่ใช้",
deduction: "การหักสิทธิ์",
ownerWorkspaceAria: "พื้นที่ทำงานบัญชีเจ้าของห้อง",
accounts: "บัญชี",
usageHistory: "ประวัติการใช้สิทธิ์",
ownerSearchPlaceholder: "ค้นหาเลขที่ เจ้าของ ห้อง ยูนิต หรือสมาชิก…",
ownerSearchAria: "ค้นหาบัญชีเจ้าของห้อง",
purchasedTypeFilterAria: "กรองตามประเภทห้องที่ซื้อ",
allRoomTypes: "ทุกประเภทห้อง",
no: "ลำดับ",
transferDate: "วันที่โอน",
name: "ชื่อ",
roomNo: "หมายเลขห้อง",
roomType: "ประเภทห้อง",
unitNo: "หมายเลขยูนิต",
memberNo: "หมายเลขสมาชิก",
remainingStayPrivileges: "สิทธิ์เข้าพักคงเหลือ",
usageSearchPlaceholder: "ค้นหาหมายเลขยืนยัน เจ้าของ ห้อง หรือสมาชิก…",
usageSearchAria: "ค้นหาประวัติการใช้สิทธิ์",
usedTypeFilterAria: "กรองตามประเภทห้องที่ใช้",
allUsedRoomTypes: "ทุกประเภทห้องที่ใช้",
ownerRoom: "เจ้าของ / ห้อง",
checkIn: "เช็กอิน",
checkOut: "เช็กเอาต์",
night: "คืน",
nights: "คืน",
use: "ใช้สิทธิ์",
drawerUsingNights: "ใช้สิทธิ์",
balance: "สิทธิ์คงเหลือ (คืน)",
usedType: "ประเภทที่ใช้",
multiplier: "ตัวคูณ",
deducted: "หักแล้ว",
remark: "หมายเหตุ",
newUsage: "การใช้สิทธิ์ใหม่",
reviewDeduction: "ตรวจสอบยอดหักก่อนบันทึก",
closeForm: "ปิดแบบฟอร์ม",
ownerAccount: "บัญชีเจ้าของห้อง",
ownerLockNote: "เลือกบัญชีนี้จากพื้นที่ทำงานเจ้าของห้องแล้ว",
confirmationNo: "หมายเลขยืนยัน",
digitsOnlyPlaceholder: "ป้อนเฉพาะตัวเลข",
usedRoomType: "ประเภทห้องที่ใช้",
deductionMultiplier: "ตัวคูณการหักสิทธิ์",
selectMultiplier: "เลือกตัวคูณ",
ac2RequiredNote: "จำเป็นต้องเลือกตัวคูณเนื่องจากการเข้าพักนี้เกี่ยวข้องกับ AC2",
remarkPlaceholder: "เพิ่มหมายเหตุ (ไม่บังคับ)",
deductionPreview: "ตัวอย่างการใช้สิทธิ์",
automaticRule: "กฎอัตโนมัติ",
ac2ManualRule: "กฎ AC2 แบบกำหนดเอง",
currentBalance: "สิทธิ์คงเหลือปัจจุบัน",
afterSaving: "หลังบันทึก",
cancel: "ยกเลิก",
saveRecord: "บันทึกรายการ",
ownerAccountUpper: "บัญชีเจ้าของห้อง",
accountDetails: "รายละเอียดบัญชี",
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}`,
noOwnerAccountsFound: "ไม่พบบัญชีเจ้าของห้อง",
tryOwnerFilter: "ลองค้นหาอื่นหรือเปลี่ยนตัวกรองประเภทห้อง",
noDatabaseOwners: "ยังไม่มีบัญชีในฐานข้อมูล",
noDatabaseOwnersDescription: "นำเข้าหรือสร้างบัญชีเจ้าของห้องก่อนบันทึกการใช้สิทธิ์",
room: "ห้อง",
member: "สมาชิก",
roomLine: ({ room }) => `ห้อง ${room}`,
roomMemberLine: ({ room, member }) => `ห้อง ${room} · สมาชิก ${member}`,
confirmationResult: ({ confirmation }) => `หมายเลขยืนยัน ${confirmation}`,
noUsageRecordsFound: "ไม่พบรายการใช้สิทธิ์",
tryUsageFilter: "ลองค้นหาหมายเลขยืนยัน เจ้าของ หรือประเภทห้องอื่น",
noDatabaseUsage: "ยังไม่มีรายการใช้สิทธิ์ในฐานข้อมูล",
noDatabaseUsageDescription: "รายการใช้สิทธิ์จะแสดงที่นี่หลังจากเพิ่มบัญชีและข้อมูลการใช้แล้ว",
noRemark: "ไม่มีหมายเหตุ",
to: "ถึง",
purchasedType: "ประเภทห้องที่ซื้อ",
remaining: "คงเหลือ",
notCalculated: "ยังไม่คำนวณ",
pending: "รอดำเนินการ",
checkoutLater: "วันที่เช็กเอาต์ต้องหลังวันที่เช็กอิน",
selectAc2Multiplier: "การใช้สิทธิ์นี้เกี่ยวข้องกับ AC2 กรุณาเลือกตัวคูณ",
insufficientPrivileges: ({ required, available }) => `สิทธิ์เข้าพักคงเหลือไม่เพียงพอ ต้องใช้: ${required}; ใช้ได้: ${available}`,
duplicateConfirmation: "หมายเลขยืนยันนี้มีอยู่แล้ว",
recordingRoom: ({ room }) => `กำลังบันทึกการใช้สิทธิ์สำหรับห้อง ${room}`,
chooseOwnerReview: "เลือกบัญชีเจ้าของห้องและตรวจสอบยอดหักก่อนบันทึก",
noAccountsForUsage: "ไม่มีบัญชีเจ้าของห้องสำหรับสร้างรายการใช้สิทธิ์",
entitlementUnavailable: "บัญชีนี้ไม่มีสิทธิ์คงเหลือสำหรับปีที่เลือก",
savingRecord: "กำลังบันทึก…",
usageSaveFailed: "ไม่สามารถบันทึกรายการใช้สิทธิ์ได้ กรุณาตรวจสอบข้อมูลแล้วลองอีกครั้ง",
usageSaveInsufficient: "บัญชีนี้มีสิทธิ์เข้าพักไม่เพียงพอแล้ว กรุณาโหลดใหม่และตรวจสอบยอดคงเหลือ",
usageSaveConflict: "คำขอนี้ขัดแย้งกับข้อมูลปัจจุบัน ตรวจสอบหมายเลขยืนยันแล้วลองอีกครั้ง",
usageSaveNotFound: "ไม่พบบัญชี รอบสิทธิ์ หรือประเภทห้องนี้แล้ว",
usageSaveRuleViolation: "รายการนี้ไม่เป็นไปตามกฎการใช้สิทธิ์ปัจจุบัน",
usageSaveNetwork: "ไม่สามารถเชื่อมต่อ API ภายในเครื่องได้ รายการยังไม่ได้บันทึก",
transferDateSubtitle: ({ date }) => `วันที่โอน: ${date}`,
remaining2026: ({ year }) => `สิทธิ์เข้าพักคงเหลือในปี ${year}`,
annualGrant: "สิทธิ์ที่ได้รับประจำปี",
usedIn2026: ({ year }) => `ใช้ไปในปี ${year}`,
usageRecords: "รายการใช้สิทธิ์",
accountInformation: "ข้อมูลบัญชี",
workbookSource: "แหล่งข้อมูลจากเวิร์กบุ๊ก",
databaseSource: "แหล่งข้อมูลจากฐานข้อมูล",
purchasedRoomType: "ประเภทห้องที่ซื้อ",
roomUsed: ({ type }) => `ใช้ห้องประเภท ${type}`,
amountDeducted: ({ amount }) => `หัก ${amount}`,
stay: "การเข้าพัก",
noUsage2026: ({ year }) => `ยังไม่มีรายการใช้สิทธิ์ในปี ${year}`,
addFirstUsage: "เพิ่มรายการใช้สิทธิ์แรกสำหรับบัญชีนี้",
usageSavedMessage: ({ deduction, room }) => `หัก ${deduction} จากห้อง ${room} แล้ว`,
noGlobalMatches: "ไม่พบเจ้าของห้องหรือรายการใช้สิทธิ์ที่ตรงกัน"
}
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
function mapApiOwner(record) {
return {
id: record.id,
sourceNo: record.accountNo,
transferDate: record.transferDate,
name: record.name,
room: record.roomNo,
purchasedType: record.purchasedRoomType,
unit: record.unitNo,
member: record.memberNo,
remaining: record.remainingStayPrivileges,
used: 0
};
}
function mapApiUsage(record) {
return {
id: record.id,
confirmation: record.confirmationNo,
ownerId: record.ownerAccountId,
checkin: record.checkIn,
checkout: record.checkOut,
nights: record.night,
usedType: record.usedRoomType,
multiplier: record.appliedMultiplier,
deducted: record.use,
balance: record.balance,
remark: record.remark,
recordSource: "api",
importOrder: null,
enteredAt: record.createdAt,
entrySequence: null
};
}
function updateOwnerUsageTotals() {
const useByOwner = stayRecords.reduce((totals, record) => {
totals.set(record.ownerId, (totals.get(record.ownerId) || 0) + record.deducted);
return totals;
}, new Map());
owners.forEach(owner => {
owner.used = useByOwner.get(owner.id) || 0;
});
}
function setConnectionState(state, errorCode = null) {
connectionState = state;
connectionErrorCode = errorCode;
renderConnectionState();
updateUsageEntryAvailability();
}
function renderConnectionState() {
const banner = $("#connectionBanner");
if (!banner) return;
banner.hidden = connectionState === "demo" || connectionState === "ready";
banner.dataset.state = connectionState;
document.documentElement.dataset.dataMode = IS_API_MODE ? "api" : "demo";
const copy = {
demo: ["runtimeDemoTitle", "runtimeDemoMessage"],
loading: ["runtimeApiLoadingTitle", "runtimeApiLoadingMessage"],
ready: ["runtimeApiReadyTitle", "runtimeApiReadyMessage"],
error: ["runtimeApiErrorTitle", "runtimeApiErrorMessage"]
}[connectionState] || ["runtimeApiErrorTitle", "runtimeApiErrorMessage"];
$("#connectionTitle").textContent = t(copy[0]);
$("#connectionMessage").textContent = t(copy[1], { count: owners.length, code: connectionErrorCode || "" });
$("#retryApiButton").textContent = t("retry");
$("#retryApiButton").hidden = !IS_API_MODE || connectionState !== "error";
}
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() {
populateFormOptions();
renderOwners();
renderStays();
renderRoomTypeMix();
renderMonthlyUse();
updateAggregateUI();
renderAccountContext();
calculateForm();
updateUsageEntryAvailability();
if ($("#ownerDrawer").classList.contains("open") && detailOwnerId && ownerFor(detailOwnerId)) {
renderOwnerDrawer(detailOwnerId);
}
}
async function loadApiData() {
if (!IS_API_MODE) return;
if (!API_CLIENT) {
setConnectionState("error", "API_CLIENT_MISSING");
return;
}
activeLoadController?.abort();
const controller = new AbortController();
activeLoadController = controller;
setConnectionState("loading");
try {
const [health, roomTypes, ownerResult, usageResult, dashboard] = await Promise.all([
API_CLIENT.health({ signal: controller.signal }),
API_CLIENT.listRoomTypes({ signal: controller.signal }),
API_CLIENT.listAllOwnerAccounts({ year: PERIOD_YEAR, signal: controller.signal }),
API_CLIENT.listAllUsageRecords({ signal: controller.signal }),
API_CLIENT.getDashboard({ year: PERIOD_YEAR, signal: controller.signal })
]);
if (controller !== activeLoadController) return;
if (health.status !== "ok" || health.database !== "booking_test" || health.schema !== "condon") {
throw new Error("API health contract mismatch");
}
ROOM_TYPES = roomTypes.map(roomType => roomType.code);
owners = ownerResult.items.map(mapApiOwner);
stayRecords = usageResult.items.map(mapApiUsage);
dashboardData = dashboard;
aggregateRemaining = dashboard.remainingPrivileges;
aggregateUsed = dashboard.used;
updateOwnerUsageTotals();
setConnectionState("ready");
refreshDataViews();
} catch (error) {
if (controller !== activeLoadController || controller.signal.aborted) return;
if (isUnauthorized(error)) {
showLoginScreen("sessionExpired");
return;
}
owners = [];
stayRecords = [];
dashboardData = null;
aggregateRemaining = 0;
aggregateUsed = 0;
setConnectionState("error", error?.code || "LOAD_FAILED");
refreshDataViews();
} finally {
if (controller === activeLoadController) activeLoadController = null;
}
}
function t(key, params = {}) {
const messages = I18N[currentLanguage] || I18N.en;
const value = messages[key] ?? I18N.en[key] ?? key;
const resolvedParams = { year: PERIOD_YEAR, ...params };
if (typeof value === "function") return value(resolvedParams);
return value.replace(/\{(\w+)\}/g, (_, name) => resolvedParams[name] ?? "");
}
function isUnauthorized(error) {
return error?.status === 401 || error?.code === "UNAUTHORIZED";
}
function updatePasswordToggle() {
const input = $("#loginPassword");
const button = $("#passwordToggle");
if (!input || !button) return;
const isVisible = input.type === "text";
button.setAttribute("aria-label", t(isVisible ? "hidePassword" : "showPassword"));
button.setAttribute("aria-pressed", String(isVisible));
$(".password-eye-open", button).hidden = isVisible;
$(".password-eye-closed", button).hidden = !isVisible;
}
function setLoginError(key = null) {
loginErrorKey = key;
const error = $("#loginError");
if (!error) return;
error.textContent = key ? t(key) : "";
error.hidden = !key;
}
function showLoginChecking() {
$("#loginScreen").hidden = false;
$("#loginScreen").dataset.authState = "checking";
$("#appShell").hidden = true;
$("#appShell").setAttribute("aria-hidden", "true");
$("#loginChecking").hidden = false;
$("#loginForm").hidden = true;
setLoginError(null);
}
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";
$("#appShell").hidden = true;
$("#appShell").setAttribute("aria-hidden", "true");
$("#loginChecking").hidden = true;
$("#loginForm").hidden = false;
$("#loginPassword").value = "";
$("#loginPassword").type = "password";
updatePasswordToggle();
setLoginError(errorKey);
if (workspaceInitialized) {
$("#stayDrawer").classList.remove("open");
$("#stayDrawer").setAttribute("aria-hidden", "true");
$("#ownerDrawer").classList.remove("open");
$("#ownerDrawer").setAttribute("aria-hidden", "true");
$("#drawerScrim").hidden = true;
$("#mobileScrim").hidden = true;
$("#sidebar").classList.remove("open");
$("#menuButton").setAttribute("aria-expanded", "false");
$("#toast").hidden = true;
}
window.requestAnimationFrame(() => {
$(focusPassword ? "#loginPassword" : "#loginUsername").focus();
});
}
function updateAuthenticationControls() {
const loginButton = $("#loginSubmit");
const loginButtonLabel = $("#loginSubmit span");
if (loginButton && loginButtonLabel) {
loginButton.disabled = isAuthenticating;
loginButtonLabel.textContent = t(isAuthenticating ? "signingIn" : "signIn");
}
const logoutButton = $("#logoutButton");
const logoutButtonLabel = $("#logoutButton span");
if (logoutButton && logoutButtonLabel) {
logoutButton.disabled = isLoggingOut;
logoutButtonLabel.textContent = t(isLoggingOut ? "signingOut" : "signOut");
}
}
function showSessionError(key = null) {
window.clearTimeout(sessionErrorTimer);
sessionErrorKey = key;
const error = $("#sessionError");
if (!error) return;
error.textContent = key ? t(key) : "";
error.hidden = !key;
if (key) {
sessionErrorTimer = window.setTimeout(() => showSessionError(null), 5000);
}
}
async function enterWorkspace(session) {
currentUser = session?.user?.username || "";
showSessionError(null);
$("#loginScreen").hidden = true;
$("#appShell").hidden = false;
$("#appShell").setAttribute("aria-hidden", "false");
if (!workspaceInitialized) {
workspaceInitialized = true;
bindEvents();
resetStayForm(owners[0]?.id || "");
}
applyWorkspaceRoute();
applyLanguage(currentLanguage);
if (IS_API_MODE) await loadApiData();
else setConnectionState("demo");
}
async function submitLogin(event) {
event.preventDefault();
if (isAuthenticating) return;
const username = $("#loginUsername").value.trim();
const password = $("#loginPassword").value;
if (!username || !password) {
setLoginError("credentialsRequired");
$(username ? "#loginPassword" : "#loginUsername").focus();
return;
}
if (!API_CLIENT) {
setLoginError("authServiceUnavailable");
return;
}
isAuthenticating = true;
setLoginError(null);
updateAuthenticationControls();
try {
const session = await API_CLIENT.login({ username, password });
if (!session?.authenticated) throw new Error("Authentication contract mismatch");
$("#loginForm").reset();
updatePasswordToggle();
await enterWorkspace(session);
} catch (error) {
const invalidCredentials = error?.status === 401 || error?.code === "INVALID_CREDENTIALS";
showLoginScreen(invalidCredentials ? "invalidCredentials" : "authServiceUnavailable", invalidCredentials);
} finally {
isAuthenticating = false;
updateAuthenticationControls();
}
}
async function signOut() {
if (isLoggingOut) return;
isLoggingOut = true;
showSessionError(null);
updateAuthenticationControls();
try {
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");
} finally {
isLoggingOut = false;
updateAuthenticationControls();
}
}
function bindLanguageGroup(selector) {
const buttons = $$(selector);
buttons.forEach((button, index) => {
button.addEventListener("click", () => applyLanguage(button.dataset.language || button.dataset.loginLanguage));
button.addEventListener("keydown", event => {
if (["Enter", " "].includes(event.key)) {
event.preventDefault();
applyLanguage(button.dataset.language || button.dataset.loginLanguage);
return;
}
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
let nextIndex = index;
if (event.key === "ArrowLeft") nextIndex = (index - 1 + buttons.length) % buttons.length;
if (event.key === "ArrowRight") nextIndex = (index + 1) % buttons.length;
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = buttons.length - 1;
const nextButton = buttons[nextIndex];
applyLanguage(nextButton.dataset.language || nextButton.dataset.loginLanguage);
nextButton.focus();
});
});
}
function bindAuthenticationEvents() {
bindLanguageGroup("[data-login-language]");
$("#loginForm").addEventListener("submit", submitLogin);
$("#passwordToggle").addEventListener("click", () => {
const input = $("#loginPassword");
input.type = input.type === "password" ? "text" : "password";
updatePasswordToggle();
input.focus();
});
["#loginUsername", "#loginPassword"].forEach(selector => {
$(selector).addEventListener("input", () => {
if (loginErrorKey) setLoginError(null);
});
});
$("#logoutButton").addEventListener("click", () => { void signOut(); });
}
function locale() {
return LANGUAGE_CONFIG[currentLanguage]?.intl || LANGUAGE_CONFIG.en.intl;
}
function formatNumber(value) {
if (!Number.isFinite(value)) return "—";
return new Intl.NumberFormat(locale()).format(value);
}
function formatPercent(value) {
return new Intl.NumberFormat(locale(), { style: "percent", maximumFractionDigits: 1 }).format(Number.isFinite(value) ? value : 0);
}
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character]);
}
function roomTypeLabel(type) {
const definition = ROOM_TYPE_DEFINITIONS[type];
if (!definition) return type;
return `${type}${definition[currentLanguage] || definition.en}`;
}
function roomTypeOption(type) {
return `<option value="${escapeHtml(type)}">${escapeHtml(roomTypeLabel(type))}</option>`;
}
function displayValue(value) {
return value === null || value === undefined || value === "" ? "—" : String(value);
}
function ownerFor(id) {
return owners.find(owner => String(owner.id) === String(id));
}
function shortDate(isoDate) {
if (!isoDate) return "—";
return new Intl.DateTimeFormat(locale(), { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(new Date(`${isoDate}T00:00:00Z`));
}
function shortDateNoYear(isoDate) {
if (!isoDate) return "—";
return new Intl.DateTimeFormat(locale(), { month: "short", day: "numeric", timeZone: "UTC" }).format(new Date(`${isoDate}T00:00:00Z`));
}
function plural(value, word) {
if (!Number.isFinite(value)) return "—";
const units = {
en: { night: "night", room: "room", record: "record", account: "account" },
zh: { night: "晚", room: "间房", record: "条记录", account: "个账户" },
th: { night: "คืน", room: "ห้อง", record: "รายการ", account: "บัญชี" }
}[currentLanguage] || {};
const unit = units[word] || word;
return currentLanguage === "en"
? `${formatNumber(value)} ${unit}${value === 1 ? "" : "s"}`
: `${formatNumber(value)} ${unit}`;
}
function getTier(type) {
if (["RM1", "RM2", "RM3", "RM4", "UG1", "UG2"].includes(type)) return 1;
if (["SU1", "SU2", "SU6"].includes(type)) return 2;
if (type === "SU3") return 3;
return null;
}
function getAutomaticMultiplier(purchasedType, usedType) {
const purchasedTier = getTier(purchasedType);
const usedTier = getTier(usedType);
if (purchasedTier === null || usedTier === null) return null;
return Math.max(1, usedTier - purchasedTier + 1);
}
function calculateNights(checkin, checkout) {
if (!checkin || !checkout) return 0;
const start = Date.parse(`${checkin}T00:00:00Z`);
const end = Date.parse(`${checkout}T00:00:00Z`);
return Math.round((end - start) / 86400000);
}
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, syncRoute = true } = {}) {
const nextView = viewName === "usage" ? "usage" : "accounts";
activeOwnerView = nextView;
$$("[data-owner-view]").forEach(tab => {
const isActive = tab.dataset.ownerView === nextView;
tab.classList.toggle("active", isActive);
tab.setAttribute("aria-selected", String(isActive));
tab.tabIndex = isActive ? 0 : -1;
if (isActive && focus) tab.focus();
});
$$("[data-owner-view-panel]").forEach(panel => {
const isActive = panel.dataset.ownerViewPanel === nextView;
panel.classList.toggle("active", isActive);
panel.hidden = !isActive;
});
if (nextView === "accounts") renderOwners();
else renderStays();
if (syncRoute && activePage === "owners") updateWorkspaceRoute();
}
function bindTableRowActivation(row, activate) {
let pointerStart = null;
row.addEventListener("pointerdown", event => {
if (event.button !== 0) return;
pointerStart = { x: event.clientX, y: event.clientY };
});
row.addEventListener("pointercancel", () => {
pointerStart = null;
});
row.addEventListener("click", event => {
const pointerMoved = pointerStart
&& Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) > 4;
const selection = window.getSelection();
const hasTextSelection = selection
&& !selection.isCollapsed
&& selection.toString().trim().length > 0;
const explicitAction = event.target.closest(".row-action");
pointerStart = null;
if (!explicitAction && (pointerMoved || hasTextSelection)) return;
activate();
});
row.addEventListener("keydown", event => {
if (event.target !== row || !["Enter", " "].includes(event.key)) return;
event.preventDefault();
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;
const filtered = owners.filter(owner => {
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: visibleOwners.length });
$("#accountTabCount").textContent = formatNumber(owners.length);
const databaseEmpty = IS_API_MODE && connectionState === "ready" && owners.length === 0;
$("#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("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));
});
}
function renderStays() {
const query = $("#staySearch").value.trim().toLowerCase();
const type = $("#stayTypeFilter").value;
const filtered = usageRecordsInDisplayOrder(stayRecords.filter(record => {
const owner = ownerFor(record.ownerId);
if (!owner) return false;
const haystack = `${record.confirmation} ${owner.name} ${owner.room} ${owner.member} ${owner.unit} ${record.usedType} ${record.remark || ""}`.toLowerCase();
return haystack.includes(query) && (!type || record.usedType === type);
}));
$("#stayCount").textContent = t("recordsShown", { count: filtered.length });
$("#usageTabCount").textContent = formatNumber(stayRecords.length);
const databaseEmpty = IS_API_MODE && connectionState === "ready" && stayRecords.length === 0;
$("#stayTableBody").innerHTML = filtered.length ? filtered.map(record => {
const owner = ownerFor(record.ownerId);
return `<tr class="clickable-row" data-owner-id="${owner.id}" tabindex="0">
<td data-label="${t("confirmationNo")}"><strong class="mono">${record.confirmation}</strong></td>
<td class="owner-cell" data-label="${t("ownerRoom")}"><span><strong>${escapeHtml(owner.name)}</strong><small class="table-subline">${escapeHtml(t("roomMemberLine", { room: owner.room, member: owner.member }))}</small></span></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("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="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 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() {
const ownerCounts = IS_API_MODE && dashboardData
? Object.fromEntries(dashboardData.purchasedRoomTypes.map(item => [item.roomType, item.count]))
: owners.reduce((counts, owner) => {
counts[owner.purchasedType] = (counts[owner.purchasedType] || 0) + 1;
return counts;
}, {});
const usedRoomNights = IS_API_MODE && dashboardData
? Object.fromEntries(dashboardData.usedRoomTypes.map(item => [item.roomType, item.count]))
: stayRecords.reduce((counts, record) => {
counts[record.usedType] = (counts[record.usedType] || 0) + record.nights;
return counts;
}, {});
const ownerTotal = IS_API_MODE && dashboardData ? dashboardData.ownerRooms : owners.length;
const usedTotal = Object.values(usedRoomNights).reduce((total, value) => total + value, 0);
const activeTypes = ROOM_TYPES.filter(type => ownerCounts[type] || usedRoomNights[type]);
$("#roomTypeMixOwnerTotal").textContent = t("ownerRoomSeriesTotal", { count: formatNumber(ownerTotal) });
$("#roomTypeMixUsedTotal").textContent = t("usedRoomNightSeriesTotal", { count: formatNumber(usedTotal) });
$("#roomTypeMixChart").setAttribute("aria-label", t("roomTypeMixChartAria", {
ownerTotal: formatNumber(ownerTotal),
usedTotal: formatNumber(usedTotal)
}));
$("#roomTypeMixChart").innerHTML = activeTypes.map(type => {
const ownerCount = ownerCounts[type] || 0;
const usedCount = usedRoomNights[type] || 0;
const ownerShare = ownerTotal ? ownerCount / ownerTotal : 0;
const usedShare = usedTotal ? usedCount / usedTotal : 0;
const ownerPercent = formatPercent(ownerShare);
const usedPercent = formatPercent(usedShare);
const ownerShareWidth = `${Math.min(100, Math.max(0, ownerShare * 100)).toFixed(2)}%`;
const usedShareWidth = `${Math.min(100, Math.max(0, usedShare * 100)).toFixed(2)}%`;
return `<div class="room-mix-row" role="listitem">
<strong class="room-mix-type">${escapeHtml(type)}</strong>
<div class="room-mix-series-stack">
<div class="room-mix-series room-mix-series-source" aria-label="${escapeHtml(t("roomTypeSeriesAria", { type, count: formatNumber(ownerCount), percent: ownerPercent }))}">
<span class="room-mix-track" aria-hidden="true"><span class="room-mix-fill room-mix-fill-source${ownerCount ? " has-value" : ""}" style="--share:${ownerShareWidth}"></span></span>
<span class="room-mix-value"><strong>${formatNumber(ownerCount)}</strong><small>${ownerPercent}</small></span>
</div>
<div class="room-mix-series room-mix-series-used" aria-label="${escapeHtml(t("usedRoomTypeSeriesAria", { type, count: formatNumber(usedCount), percent: usedPercent }))}">
<span class="room-mix-track" aria-hidden="true"><span class="room-mix-fill room-mix-fill-used${usedCount ? " has-value" : ""}" style="--share:${usedShareWidth}"></span></span>
<span class="room-mix-value"><strong>${formatNumber(usedCount)}</strong><small>${usedPercent}</small></span>
</div>
</div>
</div>`;
}).join("");
}
function renderMonthlyUse() {
if (!IS_API_MODE || !dashboardData) return;
const byMonth = new Map(dashboardData.monthlyUse.map(item => [item.month, item.use]));
const maximum = Math.max(1, ...byMonth.values());
$$(".bar-item[data-month]").forEach(item => {
const month = Number(item.dataset.month);
const use = byMonth.get(month) || 0;
const height = `${Math.round((use / maximum) * 100)}%`;
const bar = item.querySelector("span");
bar.style.setProperty("--height", height);
item.title = `${item.querySelector("small").textContent}: ${use}`;
});
}
function updateAggregateUI() {
$("#ownerRoomCount").textContent = formatNumber(IS_API_MODE && dashboardData ? dashboardData.ownerRooms : owners.length);
$("#accountTabCount").textContent = formatNumber(owners.length);
$("#usageTabCount").textContent = formatNumber(stayRecords.length);
$("#totalRemaining").textContent = formatNumber(aggregateRemaining);
$("#totalUsed").textContent = formatNumber(aggregateUsed);
$("#chartUsed").textContent = formatNumber(aggregateUsed);
}
function populateFormOptions() {
const ownerSelect = $("#ownerSelect");
const usedTypeSelect = $("#usedTypeSelect");
[
[$("#roomTypeFilter"), "allRoomTypes"],
[$("#stayTypeFilter"), "allUsedRoomTypes"]
].forEach(([select, allKey]) => {
const selected = select.value;
select.innerHTML = `<option value="">${escapeHtml(t(allKey))}</option>${ROOM_TYPES.map(roomTypeOption).join("")}`;
select.value = ROOM_TYPES.includes(selected) ? selected : "";
});
const selectedOwner = ownerSelect.value || String(owners[0]?.id || "");
const selectedType = usedTypeSelect.value || owners[0]?.purchasedType || ROOM_TYPES[0] || "";
ownerSelect.innerHTML = owners.length
? owners.map(owner => `<option value="${owner.id}">${escapeHtml(owner.name)} · ${t("room")} ${escapeHtml(owner.room)}</option>`).join("")
: `<option value="">${escapeHtml(t("noAccountsForUsage"))}</option>`;
usedTypeSelect.innerHTML = ROOM_TYPES.map(roomTypeOption).join("");
ownerSelect.value = owners.some(owner => String(owner.id) === selectedOwner) ? selectedOwner : String(owners[0]?.id || "");
ownerSelect.disabled = owners.length === 0 || stayOwnerLocked;
usedTypeSelect.value = ROOM_TYPES.includes(selectedType) ? selectedType : owners[0]?.purchasedType || ROOM_TYPES[0] || "";
}
function renderAccountContext() {
const owner = ownerFor($("#ownerSelect").value);
if (!owner) {
$("#accountContext").innerHTML = `<div class="context-empty">${escapeHtml(t("noAccountsForUsage"))}</div>`;
return;
}
$("#accountContext").innerHTML = `
<div class="context-item"><span>${t("room")}</span><strong>${owner.room}</strong></div>
<div class="context-item"><span>${t("purchasedType")}</span><strong>${owner.purchasedType}</strong></div>
<div class="context-item"><span>${t("memberNo")}</span><strong>${owner.member}</strong></div>
<div class="context-item"><span>${t("remaining")}</span><strong>${plural(owner.remaining, "night")}</strong></div>`;
}
function calculateForm() {
const owner = ownerFor($("#ownerSelect").value) || owners[0] || null;
const checkin = $("#checkinInput").value;
const checkout = $("#checkoutInput").value;
const nights = calculateNights(checkin, checkout);
const usedType = $("#usedTypeSelect").value;
const confirmation = $("#confirmationInput").value.trim();
if (!owner) {
$("#manualMultiplierField").hidden = true;
$("#manualMultiplier").required = false;
$("#ruleSource").textContent = t("automaticRule");
$("#nightsInput").value = nights > 0 ? plural(nights, "night") : t("notCalculated");
$("#calculationFormula").textContent = t("notCalculated");
$("#deductionOutput").textContent = t("pending");
$("#currentBalance").textContent = "—";
$("#afterBalance").textContent = "—";
$("#formError").textContent = t("noAccountsForUsage");
$("#formError").hidden = false;
$("#saveStayButton").disabled = true;
return { owner: null, checkin, checkout, nights, usedType, multiplier: 0, manualMultiplier: null, deduction: 0, confirmation, valid: false };
}
const autoMultiplier = getAutomaticMultiplier(owner.purchasedType, usedType);
const needsManual = autoMultiplier === null;
const multiplier = needsManual ? Number.parseInt($("#manualMultiplier").value, 10) || 0 : autoMultiplier;
const deduction = nights > 0 && multiplier > 0 ? nights * multiplier : 0;
const hasBalance = Number.isFinite(owner.remaining);
const after = hasBalance ? owner.remaining - deduction : null;
$("#manualMultiplierField").hidden = !needsManual;
$("#manualMultiplier").required = needsManual;
$("#ruleSource").textContent = needsManual ? t("ac2ManualRule") : t("automaticRule");
$("#nightsInput").value = nights > 0 ? plural(nights, "night") : t("notCalculated");
$("#calculationFormula").textContent = `${plural(Math.max(nights, 0), "night")} × ${multiplier || t("pending")}`;
$("#deductionOutput").textContent = deduction ? plural(deduction, "night") : t("pending");
$("#currentBalance").textContent = plural(owner.remaining, "night");
$("#afterBalance").textContent = deduction ? plural(after, "night") : t("pending");
let error = "";
if (!hasBalance) error = t("entitlementUnavailable");
else if (checkin && checkout && nights <= 0) error = t("checkoutLater");
else if (needsManual && !multiplier) error = t("selectAc2Multiplier");
else if (deduction > owner.remaining) error = t("insufficientPrivileges", { required: plural(deduction, "night"), available: plural(owner.remaining, "night") });
else if (confirmation && stayRecords.some(record => record.confirmation === confirmation)) error = t("duplicateConfirmation");
$("#formError").textContent = error;
$("#formError").hidden = !error;
const valid = /^\d+$/.test(confirmation) && nights > 0 && multiplier > 0 && hasBalance && deduction <= owner.remaining && !error && !isSubmittingStay;
$("#saveStayButton").disabled = !valid;
return {
owner,
checkin,
checkout,
nights,
usedType,
multiplier,
manualMultiplier: needsManual ? multiplier : null,
deduction,
confirmation,
valid
};
}
function resetStayForm(ownerId = owners[0]?.id || "") {
$("#stayForm").reset();
stayIdempotencyKey = IS_API_MODE ? window.crypto.randomUUID() : null;
$("#ownerSelect").value = String(ownerId);
$("#confirmationInput").value = "";
$("#checkinInput").value = "2026-08-01";
$("#checkoutInput").value = "2026-08-04";
$("#usedTypeSelect").value = ownerFor(ownerId)?.purchasedType || ROOM_TYPES[0] || "";
$("#manualMultiplier").value = "";
renderAccountContext();
calculateForm();
}
function updateStayDrawerCopy() {
const owner = ownerFor($("#ownerSelect").value) || owners[0] || null;
$("#drawerTitle").textContent = t("addUsageRecord");
$("#drawerSubtitle").textContent = !owner
? t("noAccountsForUsage")
: stayOwnerLocked
? t("recordingRoom", { room: owner.room })
: t("chooseOwnerReview");
}
function openStayDrawer(ownerId = owners[0]?.id || "", lockOwner = false) {
if (!owners.length || !ownerFor(ownerId)) return;
stayReturnOwnerId = lockOwner ? ownerId : null;
stayOwnerLocked = lockOwner;
closeOwnerDrawer(false);
resetStayForm(ownerId);
$("#ownerSelect").disabled = lockOwner;
$("#ownerLockNote").hidden = !lockOwner;
if (lockOwner) $("#ownerSelect").setAttribute("aria-describedby", "ownerLockNote");
else $("#ownerSelect").removeAttribute("aria-describedby");
updateStayDrawerCopy();
$("#drawerScrim").hidden = false;
$("#stayDrawer").classList.add("open");
$("#stayDrawer").setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
window.setTimeout(() => $("#confirmationInput").focus(), 220);
}
function closeStayDrawer(restoreOwner = false) {
const ownerToRestore = restoreOwner ? stayReturnOwnerId : null;
$("#stayDrawer").classList.remove("open");
$("#stayDrawer").setAttribute("aria-hidden", "true");
$("#ownerSelect").disabled = owners.length === 0;
$("#ownerSelect").removeAttribute("aria-describedby");
$("#ownerLockNote").hidden = true;
stayReturnOwnerId = null;
stayOwnerLocked = false;
if (!$("#ownerDrawer").classList.contains("open")) {
$("#drawerScrim").hidden = true;
document.body.style.overflow = "";
}
if (ownerToRestore) window.setTimeout(() => openOwnerDrawer(ownerToRestore), 0);
}
function renderOwnerDrawer(ownerId) {
const owner = ownerFor(ownerId);
if (!owner) return;
const records = usageRecordsInDisplayOrder(stayRecords.filter(record => String(record.ownerId) === String(ownerId)));
$("#ownerDrawerTitle").textContent = owner.name;
$("#ownerDrawerSubtitle").textContent = t("transferDateSubtitle", { date: shortDate(owner.transferDate) });
$("#ownerDrawerBody").innerHTML = `
<section class="detail-balance">
<div class="detail-balance-main"><span>${t("balance")}</span><strong>${formatNumber(owner.remaining)}</strong><small>${t("nightsAvailable")}</small></div>
<div class="detail-summary-grid">
<div><span>${t("annualGrant")}</span><strong>15</strong></div>
<div><span>${t("usedIn2026")}</span><strong>${owner.used}</strong></div>
</div>
</section>
<div class="detail-section-heading"><h3>${t("accountInformation")}</h3><span>${t(IS_API_MODE ? "databaseSource" : "workbookSource")}</span></div>
<section class="detail-grid">
<div><span>${t("roomNo")}</span><strong>${escapeHtml(owner.room)}</strong></div>
<div><span>${t("purchasedRoomType")}</span><strong>${escapeHtml(owner.purchasedType)}</strong></div>
<div><span>${t("unitNo")}</span><strong>${escapeHtml(owner.unit)}</strong></div>
<div><span>${t("memberNo")}</span><strong>${escapeHtml(owner.member)}</strong></div>
</section>
<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><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="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) {
if (!ownerFor(ownerId)) return;
detailOwnerId = ownerId;
renderOwnerDrawer(ownerId);
$("#drawerScrim").hidden = false;
$("#ownerDrawer").classList.add("open");
$("#ownerDrawer").setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
updateUsageEntryAvailability();
}
function closeOwnerDrawer(hideScrim = true) {
$("#ownerDrawer").classList.remove("open");
$("#ownerDrawer").setAttribute("aria-hidden", "true");
if (hideScrim && !$("#stayDrawer").classList.contains("open")) {
$("#drawerScrim").hidden = true;
document.body.style.overflow = "";
}
updateUsageEntryAvailability();
}
function renderToast() {
if (!lastToast) return;
$("#toast strong").textContent = t(lastToast.titleKey, lastToast.params);
$("#toast span").textContent = t(lastToast.messageKey, lastToast.params);
}
function showToast(titleKey, messageKey, params = {}) {
window.clearTimeout(toastTimer);
lastToast = { titleKey, messageKey, params };
renderToast();
$("#toast").hidden = false;
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);
dashboardData.used += record.deducted;
const usedType = dashboardData.usedRoomTypes.find(item => item.roomType === record.usedType);
if (usedType) usedType.count += record.nights;
else dashboardData.usedRoomTypes.push({ roomType: record.usedType, count: record.nights });
const month = Number(record.checkin.slice(5, 7));
const monthly = dashboardData.monthlyUse.find(item => item.month === month);
if (monthly) monthly.use += record.deducted;
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",
CONFLICT: "usageSaveConflict",
NOT_FOUND: "usageSaveNotFound",
BUSINESS_RULE_VIOLATION: "usageSaveRuleViolation",
VALIDATION_ERROR: "usageSaveRuleViolation",
NETWORK_ERROR: "usageSaveNetwork",
REQUEST_ABORTED: "usageSaveNetwork"
}[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();
if (!calculation.valid) return;
let createdRecord;
let errorKey = null;
let authenticationRequired = false;
isSubmittingStay = true;
$("#saveStayButton").textContent = t("savingRecord");
$("#saveStayButton").disabled = true;
updateUsageEntryAvailability();
try {
if (IS_API_MODE) {
const response = await API_CLIENT.createUsageRecord({
ownerAccountId: calculation.owner.id,
confirmationNo: calculation.confirmation,
checkIn: calculation.checkin,
checkOut: calculation.checkout,
usedRoomType: calculation.usedType,
manualMultiplier: calculation.manualMultiplier,
remark: $("#remarkInput").value.trim(),
idempotencyKey: stayIdempotencyKey || (stayIdempotencyKey = window.crypto.randomUUID())
});
createdRecord = mapApiUsage(response);
calculation.owner.remaining = createdRecord.balance;
stayRecords = [createdRecord, ...stayRecords.filter(record => record.id !== createdRecord.id)];
updateOwnerUsageTotals();
aggregateRemaining = Math.max(0, aggregateRemaining - createdRecord.deducted);
aggregateUsed += createdRecord.deducted;
updateDashboardAfterCreatedUsage(createdRecord);
} else {
calculation.owner.remaining -= calculation.deduction;
calculation.owner.used += calculation.deduction;
aggregateRemaining -= calculation.deduction;
aggregateUsed += calculation.deduction;
createdRecord = {
id: `manual-${Date.now()}-${nextManualEntrySequence}`,
confirmation: calculation.confirmation,
ownerId: calculation.owner.id,
checkin: calculation.checkin,
checkout: calculation.checkout,
nights: calculation.nights,
usedType: calculation.usedType,
multiplier: calculation.multiplier,
deducted: calculation.deduction,
balance: calculation.owner.remaining,
remark: $("#remarkInput").value.trim(),
recordSource: "manual",
importOrder: null,
enteredAt: new Date().toISOString(),
entrySequence: nextManualEntrySequence++
};
stayRecords.push(createdRecord);
}
} catch (error) {
if (isUnauthorized(error)) authenticationRequired = true;
else errorKey = usageSaveErrorKey(error);
} finally {
isSubmittingStay = false;
$("#saveStayButton").textContent = t("saveRecord");
updateUsageEntryAvailability();
}
if (authenticationRequired) {
showLoginScreen("sessionExpired");
return;
}
if (errorKey) {
calculateForm();
$("#formError").textContent = t(errorKey);
$("#formError").hidden = false;
return;
}
refreshDataViews();
closeStayDrawer(false);
setOwnerWorkspaceView("accounts", { syncRoute: false });
switchPage("owners");
window.setTimeout(() => openOwnerDrawer(calculation.owner.id), 0);
showToast("usageSaved", "usageSavedMessage", { deduction: plural(createdRecord.deducted, "night"), room: calculation.owner.room });
}
function updateMenuAria() {
const isOpen = $("#sidebar").classList.contains("open");
$("#menuButton").setAttribute("aria-label", t(isOpen ? "closeNavigation" : "openNavigation"));
}
function openMobileMenu() {
$("#sidebar").classList.add("open");
$("#mobileScrim").hidden = false;
$("#menuButton").setAttribute("aria-expanded", "true");
updateMenuAria();
}
function closeMobileMenu() {
$("#sidebar").classList.remove("open");
$("#mobileScrim").hidden = true;
$("#menuButton").setAttribute("aria-expanded", "false");
updateMenuAria();
}
function applyStaticTranslations() {
$$("[data-i18n]").forEach(element => {
element.textContent = t(element.dataset.i18n);
});
$$("[data-i18n-placeholder]").forEach(element => {
element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
});
$$("[data-i18n-aria-label]").forEach(element => {
element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel));
});
}
function applyLanguage(language) {
currentLanguage = SUPPORTED_LANGUAGES.includes(language) ? language : "en";
document.documentElement.lang = LANGUAGE_CONFIG[currentLanguage].html;
document.documentElement.dataset.locale = currentLanguage;
document.title = t("appTitle");
const metaDescription = $('meta[name="description"]');
if (metaDescription) metaDescription.setAttribute("content", t("metaDescription"));
applyStaticTranslations();
$$("[data-language]").forEach(button => {
const isActive = button.dataset.language === currentLanguage;
button.classList.toggle("active", isActive);
button.setAttribute("aria-pressed", String(isActive));
});
$$("[data-login-language]").forEach(button => {
const isActive = button.dataset.loginLanguage === currentLanguage;
button.classList.toggle("active", isActive);
button.setAttribute("aria-pressed", String(isActive));
});
updatePasswordToggle();
updateAuthenticationControls();
if (loginErrorKey) setLoginError(loginErrorKey);
if (sessionErrorKey && $("#sessionError")) $("#sessionError").textContent = t(sessionErrorKey);
if (!workspaceInitialized) return;
renderConnectionState();
refreshDataViews();
updateStayDrawerCopy();
updateMenuAria();
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);
}));
$$("[data-owner-view]").forEach((tab, index, tabs) => {
tab.addEventListener("click", () => setOwnerWorkspaceView(tab.dataset.ownerView));
tab.addEventListener("keydown", event => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
let nextIndex = index;
if (event.key === "ArrowLeft") nextIndex = (index - 1 + tabs.length) % tabs.length;
if (event.key === "ArrowRight") nextIndex = (index + 1) % tabs.length;
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = tabs.length - 1;
setOwnerWorkspaceView(tabs[nextIndex].dataset.ownerView, { focus: true });
});
});
bindLanguageGroup("[data-language]");
$$(".open-stay-drawer").forEach(button => button.addEventListener("click", () => {
if (owners[0]) openStayDrawer(owners[0].id, false);
}));
$("#closeDrawer").addEventListener("click", () => closeStayDrawer(true));
$("#cancelDrawer").addEventListener("click", () => closeStayDrawer(true));
$("#drawerScrim").addEventListener("click", () => {
closeStayDrawer(false);
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);
});
$("#stayForm").addEventListener("submit", submitStay);
$("#retryApiButton").addEventListener("click", () => { void loadApiData(); });
$("#ownerSelect").addEventListener("change", () => {
renderAccountContext();
calculateForm();
});
["#confirmationInput", "#checkinInput", "#checkoutInput", "#usedTypeSelect", "#manualMultiplier"].forEach(selector => $(selector).addEventListener("input", calculateForm));
$("#confirmationInput").addEventListener("input", event => {
event.target.value = event.target.value.replace(/\D/g, "");
});
$("#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 ($("#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() {
bindAuthenticationEvents();
applyLanguage("en");
showLoginChecking();
if (!API_CLIENT) {
showLoginScreen("authServiceUnavailable");
return;
}
try {
const session = await API_CLIENT.getSession();
if (session?.authenticated) await enterWorkspace(session);
else showLoginScreen();
} catch {
showLoginScreen("authServiceUnavailable");
}
}
void initialize();