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; const USAGE_PAGE_SIZE = 30; let detailOwnerId = null; let activePage = "dashboard"; let activeOwnerView = "accounts"; let ownerPage = 1; let usagePage = 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 dateOnlyMs(value) { if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ""))) return Number.NaN; const [year, month, day] = String(value).split("-").map(Number); const timestamp = Date.UTC(year, month - 1, day); const date = new Date(timestamp); return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? timestamp : Number.NaN; } function todayDateOnlyMs() { const now = new Date(); return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); } function usageDateDistance(record, todayMs) { const checkinMs = dateOnlyMs(record.checkin); const checkoutMs = dateOnlyMs(record.checkout); if (!Number.isFinite(checkinMs)) return Number.MAX_SAFE_INTEGER; const startMs = Math.min(checkinMs, Number.isFinite(checkoutMs) ? checkoutMs : checkinMs); const endMs = Math.max(checkinMs, Number.isFinite(checkoutMs) ? checkoutMs : checkinMs); if (todayMs < startMs) return startMs - todayMs; if (todayMs > endMs) return todayMs - endMs; return 0; } function compareDateDescending(aValue, bValue) { const aMs = dateOnlyMs(aValue); const bMs = dateOnlyMs(bValue); if (Number.isFinite(aMs) && Number.isFinite(bMs)) return bMs - aMs; if (Number.isFinite(aMs)) return -1; if (Number.isFinite(bMs)) return 1; return 0; } function usageRecordsInDisplayOrder(records) { const todayMs = todayDateOnlyMs(); return [...records].sort((a, b) => { const distanceDifference = usageDateDistance(a, todayMs) - usageDateDistance(b, todayMs); if (distanceDifference) return distanceDifference; const checkinDifference = compareDateDescending(a.checkin, b.checkin); if (checkinDifference) return checkinDifference; const checkoutDifference = compareDateDescending(a.checkout, b.checkout); if (checkoutDifference) return checkoutDifference; return String(b.id || "").localeCompare(String(a.id || "")); }); } 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)}`, usagePaginationAria: "Usage history pages", usagePageStatus: ({ from, to, total, page, totalPages }) => `${formatNumber(from)}-${formatNumber(to)} of ${formatNumber(total)} records · 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)} 个账户`, usagePaginationAria: "使用记录分页", usagePageStatus: ({ 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)} บัญชี`, usagePaginationAria: "หน้าประวัติการใช้สิทธิ์", usagePageStatus: ({ 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 createUuid() { if (typeof window.crypto?.randomUUID === "function") { return window.crypto.randomUUID(); } const bytes = new Uint8Array(16); if (typeof window.crypto?.getRandomValues === "function") { window.crypto.getRandomValues(bytes); } else { for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Math.floor(Math.random() * 256); } } bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } 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 ''; } function iconTrash() { return ''; } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, character => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]); } function roomTypeLabel(type) { const definition = ROOM_TYPE_DEFINITIONS[type]; if (!definition) return type; return `${type} — ${definition[currentLanguage] || definition.en}`; } function roomTypeOption(type) { return ``; } 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 => `
| ${t("confirmationNo")} | ${t("checkIn")} | ${t("checkOut")} | ${t("night")} | ${t("drawerUsingNights")} | ${t("usedRoomType")} | ${t("balance")} | ${t("remark")} | ${t("actions")} |
|---|---|---|---|---|---|---|---|---|
| ${record.confirmation} | ${shortDate(record.checkin)} | ${shortDate(record.checkout)} | ${record.nights} | ${record.deducted} | ${escapeHtml(record.usedType)} | ${plural(record.balance, "night")} | ${escapeHtml(record.remark || t("noRemark"))} |