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 => ` ${escapeHtml(displayValue(owner.sourceNo))} ${escapeHtml(owner.name)} ${escapeHtml(owner.room)} ${escapeHtml(owner.purchasedType)} ${escapeHtml(owner.unit)} ${escapeHtml(owner.member)} ${shortDate(owner.transferDate)} ${plural(owner.remaining, "night")} `).join("") : `
${t(databaseEmpty ? "noDatabaseOwners" : "noOwnerAccountsFound")}${t(databaseEmpty ? "noDatabaseOwnersDescription" : "tryOwnerFilter")}
`; const pagination = $("#ownerPagination"); if (pagination) { pagination.hidden = totalPages <= 1; $("#ownerPageStatus").textContent = filtered.length ? t("ownerPageStatus", { from: pageFrom, to: pageTo, total: filtered.length, page: ownerPage, totalPages }) : ""; const pageNumbers = $("#ownerPageNumbers"); if (pageNumbers) { pageNumbers.hidden = totalPages <= 1; pageNumbers.innerHTML = totalPages > 1 ? Array.from({ length: totalPages }, (_, index) => { const page = index + 1; const current = page === ownerPage; return ``; }).join("") : ""; $$("#ownerPageNumbers [data-owner-page]").forEach(button => button.addEventListener("click", () => { ownerPage = Number(button.dataset.ownerPage); renderOwners(); })); } } $("#ownerTableWrap").scrollTop = 0; $$("#ownerTableBody tr[data-owner-id]").forEach(row => { bindTableRowActivation(row, () => openOwnerDrawer(row.dataset.ownerId)); }); } 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); })); const totalPages = Math.max(1, Math.ceil(filtered.length / USAGE_PAGE_SIZE)); usagePage = Math.min(Math.max(usagePage, 1), totalPages); const pageOffset = (usagePage - 1) * USAGE_PAGE_SIZE; const visibleStays = filtered.slice(pageOffset, pageOffset + USAGE_PAGE_SIZE); const pageFrom = filtered.length ? pageOffset + 1 : 0; const pageTo = Math.min(pageOffset + USAGE_PAGE_SIZE, filtered.length); $("#stayCount").textContent = t("recordsShown", { count: visibleStays.length }); $("#usageTabCount").textContent = formatNumber(stayRecords.length); const databaseEmpty = IS_API_MODE && connectionState === "ready" && stayRecords.length === 0; $("#stayTableBody").innerHTML = visibleStays.length ? visibleStays.map(record => { const owner = ownerFor(record.ownerId); return ` ${record.confirmation} ${escapeHtml(owner.name)}${escapeHtml(t("roomMemberLine", { room: owner.room, member: owner.member }))} ${shortDate(record.checkin)} ${shortDate(record.checkout)} ${record.nights} ${record.deducted} ${plural(record.balance, "night")} ${escapeHtml(record.usedType)} ${escapeHtml(record.remark || t("noRemark"))}
`; }).join("") : `
${t(databaseEmpty ? "noDatabaseUsage" : "noUsageRecordsFound")}${t(databaseEmpty ? "noDatabaseUsageDescription" : "tryUsageFilter")}
`; const pagination = $("#stayPagination"); if (pagination) { pagination.hidden = totalPages <= 1; $("#stayPageStatus").textContent = filtered.length ? t("usagePageStatus", { from: pageFrom, to: pageTo, total: filtered.length, page: usagePage, totalPages }) : ""; const pageNumbers = $("#stayPageNumbers"); if (pageNumbers) { pageNumbers.hidden = totalPages <= 1; pageNumbers.innerHTML = totalPages > 1 ? Array.from({ length: totalPages }, (_, index) => { const page = index + 1; const current = page === usagePage; return ``; }).join("") : ""; $$("#stayPageNumbers [data-usage-page]").forEach(button => button.addEventListener("click", () => { usagePage = Number(button.dataset.usagePage); renderStays(); })); } } $("#stayTableWrap").scrollTop = 0; $$("#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 `
${escapeHtml(type)}
${formatNumber(ownerCount)}${ownerPercent}
${formatNumber(usedCount)}${usedPercent}
`; }).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 = `${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 => ``).join("") : ``; 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 = `
${escapeHtml(t("noAccountsForUsage"))}
`; return; } $("#accountContext").innerHTML = `
${t("room")}${owner.room}
${t("purchasedType")}${owner.purchasedType}
${t("memberNo")}${owner.member}
${t("remaining")}${plural(owner.remaining, "night")}
`; } 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 ? createUuid() : 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 = `
${t("balance")}${formatNumber(owner.remaining)}${t("nightsAvailable")}
${t("annualGrant")}15
${t("usedIn2026")}${owner.used}

${t("accountInformation")}

${t(IS_API_MODE ? "databaseSource" : "workbookSource")}
${t("roomNo")}${escapeHtml(owner.room)}
${t("purchasedRoomType")}${escapeHtml(owner.purchasedType)}
${t("unitNo")}${escapeHtml(owner.unit)}
${t("memberNo")}${escapeHtml(owner.member)}

${t("usageHistory")}

${plural(records.length, "record")}
${records.length ? `
${records.map(record => ``).join("")}
${t("confirmationNo")}${t("checkIn")}${t("checkOut")}${t("night")}${t("drawerUsingNights")}${t("usedRoomType")}${t("balance")}${t("remark")}${t("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"))}
` : `
${t("noUsage2026")}${t("addFirstUsage")}
`}
`; 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", () => { usagePage = 1; renderStays(); }); $("#stayTypeFilter").addEventListener("change", () => { usagePage = 1; 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();