2210 lines
107 KiB
JavaScript
2210 lines
107 KiB
JavaScript
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 '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>';
|
||
}
|
||
|
||
function iconTrash() {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14M9 7V4h6v3M8 7l1 13h6l1-13M10 11v6M14 11v6"/></svg>';
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value).replace(/[&<>'"]/g, character => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]);
|
||
}
|
||
|
||
function roomTypeLabel(type) {
|
||
const definition = ROOM_TYPE_DEFINITIONS[type];
|
||
if (!definition) return type;
|
||
return `${type} — ${definition[currentLanguage] || definition.en}`;
|
||
}
|
||
|
||
function roomTypeOption(type) {
|
||
return `<option value="${escapeHtml(type)}">${escapeHtml(roomTypeLabel(type))}</option>`;
|
||
}
|
||
|
||
function displayValue(value) {
|
||
return value === null || value === undefined || value === "" ? "—" : String(value);
|
||
}
|
||
|
||
function ownerFor(id) {
|
||
return owners.find(owner => String(owner.id) === String(id));
|
||
}
|
||
|
||
function shortDate(isoDate) {
|
||
if (!isoDate) return "—";
|
||
return new Intl.DateTimeFormat(locale(), { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(new Date(`${isoDate}T00:00:00Z`));
|
||
}
|
||
|
||
function shortDateNoYear(isoDate) {
|
||
if (!isoDate) return "—";
|
||
return new Intl.DateTimeFormat(locale(), { month: "short", day: "numeric", timeZone: "UTC" }).format(new Date(`${isoDate}T00:00:00Z`));
|
||
}
|
||
|
||
function plural(value, word) {
|
||
if (!Number.isFinite(value)) return "—";
|
||
const units = {
|
||
en: { night: "night", room: "room", record: "record", account: "account" },
|
||
zh: { night: "晚", room: "间房", record: "条记录", account: "个账户" },
|
||
th: { night: "คืน", room: "ห้อง", record: "รายการ", account: "บัญชี" }
|
||
}[currentLanguage] || {};
|
||
const unit = units[word] || word;
|
||
return currentLanguage === "en"
|
||
? `${formatNumber(value)} ${unit}${value === 1 ? "" : "s"}`
|
||
: `${formatNumber(value)} ${unit}`;
|
||
}
|
||
|
||
function getTier(type) {
|
||
if (["RM1", "RM2", "RM3", "RM4", "UG1", "UG2"].includes(type)) return 1;
|
||
if (["SU1", "SU2", "SU6"].includes(type)) return 2;
|
||
if (type === "SU3") return 3;
|
||
return null;
|
||
}
|
||
|
||
function getAutomaticMultiplier(purchasedType, usedType) {
|
||
const purchasedTier = getTier(purchasedType);
|
||
const usedTier = getTier(usedType);
|
||
if (purchasedTier === null || usedTier === null) return null;
|
||
return Math.max(1, usedTier - purchasedTier + 1);
|
||
}
|
||
|
||
function calculateNights(checkin, checkout) {
|
||
if (!checkin || !checkout) return 0;
|
||
const start = Date.parse(`${checkin}T00:00:00Z`);
|
||
const end = Date.parse(`${checkout}T00:00:00Z`);
|
||
return Math.round((end - start) / 86400000);
|
||
}
|
||
|
||
function workspaceRouteHash(page = activePage, ownerView = activeOwnerView) {
|
||
if (page !== "owners") return "#dashboard";
|
||
return ownerView === "usage" ? "#owners/usage" : "#owners";
|
||
}
|
||
|
||
function updateWorkspaceRoute({ replace = false } = {}) {
|
||
const nextHash = workspaceRouteHash();
|
||
if (window.location.hash === nextHash) return;
|
||
if (!replace) {
|
||
window.location.hash = nextHash;
|
||
return;
|
||
}
|
||
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`;
|
||
window.history.replaceState({}, "", nextUrl);
|
||
}
|
||
|
||
function readWorkspaceRoute() {
|
||
const hash = window.location.hash.slice(1).toLowerCase();
|
||
if (hash === "owners/usage") return { page: "owners", ownerView: "usage", hash: "#owners/usage" };
|
||
if (hash === "owners" || hash === "owners/accounts") return { page: "owners", ownerView: "accounts", hash: "#owners" };
|
||
return { page: "dashboard", ownerView: "accounts", hash: "#dashboard" };
|
||
}
|
||
|
||
function applyWorkspaceRoute() {
|
||
const route = readWorkspaceRoute();
|
||
switchPage(route.page, { syncRoute: false });
|
||
setOwnerWorkspaceView(route.ownerView, { syncRoute: false });
|
||
if (window.location.hash && window.location.hash !== route.hash) {
|
||
updateWorkspaceRoute({ replace: true });
|
||
}
|
||
}
|
||
|
||
function handleWorkspaceRouteChange() {
|
||
if (!workspaceInitialized || $("#appShell").hidden) return;
|
||
applyWorkspaceRoute();
|
||
}
|
||
|
||
function switchPage(pageName, { syncRoute = true } = {}) {
|
||
const safePage = pageName === "owners" ? "owners" : "dashboard";
|
||
activePage = safePage;
|
||
$$(".page").forEach(page => page.classList.toggle("active", page.dataset.pageContent === safePage));
|
||
$$(".nav-item").forEach(item => item.classList.toggle("active", item.dataset.page === safePage));
|
||
closeMobileMenu();
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
if (syncRoute) updateWorkspaceRoute();
|
||
}
|
||
|
||
function setOwnerWorkspaceView(viewName, { focus = false, syncRoute = true } = {}) {
|
||
const nextView = viewName === "usage" ? "usage" : "accounts";
|
||
activeOwnerView = nextView;
|
||
$$("[data-owner-view]").forEach(tab => {
|
||
const isActive = tab.dataset.ownerView === nextView;
|
||
tab.classList.toggle("active", isActive);
|
||
tab.setAttribute("aria-selected", String(isActive));
|
||
tab.tabIndex = isActive ? 0 : -1;
|
||
if (isActive && focus) tab.focus();
|
||
});
|
||
$$("[data-owner-view-panel]").forEach(panel => {
|
||
const isActive = panel.dataset.ownerViewPanel === nextView;
|
||
panel.classList.toggle("active", isActive);
|
||
panel.hidden = !isActive;
|
||
});
|
||
if (nextView === "accounts") renderOwners();
|
||
else renderStays();
|
||
if (syncRoute && activePage === "owners") updateWorkspaceRoute();
|
||
}
|
||
|
||
function bindTableRowActivation(row, activate) {
|
||
let pointerStart = null;
|
||
|
||
row.addEventListener("pointerdown", event => {
|
||
if (event.button !== 0) return;
|
||
pointerStart = { x: event.clientX, y: event.clientY };
|
||
});
|
||
|
||
row.addEventListener("pointercancel", () => {
|
||
pointerStart = null;
|
||
});
|
||
|
||
row.addEventListener("click", event => {
|
||
const pointerMoved = pointerStart
|
||
&& Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) > 4;
|
||
const selection = window.getSelection();
|
||
const hasTextSelection = selection
|
||
&& !selection.isCollapsed
|
||
&& selection.toString().trim().length > 0;
|
||
const explicitAction = event.target.closest(".row-action");
|
||
pointerStart = null;
|
||
|
||
if (!explicitAction && (pointerMoved || hasTextSelection)) return;
|
||
activate();
|
||
});
|
||
|
||
row.addEventListener("keydown", event => {
|
||
if (event.target !== row || !["Enter", " "].includes(event.key)) return;
|
||
event.preventDefault();
|
||
activate();
|
||
});
|
||
}
|
||
|
||
function bindDeleteActions(root) {
|
||
$$(".delete-usage-action", root).forEach(button => {
|
||
button.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
void requestDeleteUsage(button.dataset.usageId);
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderOwners() {
|
||
const query = $("#ownerSearch").value.trim().toLowerCase();
|
||
const type = $("#roomTypeFilter").value;
|
||
const filtered = owners.filter(owner => {
|
||
const haystack = `${owner.sourceNo} ${owner.transferDate} ${owner.name} ${owner.room} ${owner.purchasedType} ${owner.unit} ${owner.member}`.toLowerCase();
|
||
return haystack.includes(query) && (!type || owner.purchasedType === type);
|
||
});
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / OWNER_PAGE_SIZE));
|
||
ownerPage = Math.min(Math.max(ownerPage, 1), totalPages);
|
||
const pageOffset = (ownerPage - 1) * OWNER_PAGE_SIZE;
|
||
const visibleOwners = filtered.slice(pageOffset, pageOffset + OWNER_PAGE_SIZE);
|
||
const pageFrom = filtered.length ? pageOffset + 1 : 0;
|
||
const pageTo = Math.min(pageOffset + OWNER_PAGE_SIZE, filtered.length);
|
||
|
||
$("#ownerCount").textContent = t("accountsShown", { count: visibleOwners.length });
|
||
$("#accountTabCount").textContent = formatNumber(owners.length);
|
||
const databaseEmpty = IS_API_MODE && connectionState === "ready" && owners.length === 0;
|
||
$("#ownerTableBody").innerHTML = visibleOwners.length ? visibleOwners.map(owner => `
|
||
<tr class="clickable-row" data-owner-id="${owner.id}" tabindex="0">
|
||
<td data-label="${t("no")}"><strong class="mono">${escapeHtml(displayValue(owner.sourceNo))}</strong></td>
|
||
<td data-label="${t("name")}"><strong class="owner-name">${escapeHtml(owner.name)}</strong></td>
|
||
<td data-label="${t("roomNo")}"><strong class="mono">${escapeHtml(owner.room)}</strong></td>
|
||
<td data-label="${t("roomType")}"><span class="neutral-badge">${escapeHtml(owner.purchasedType)}</span></td>
|
||
<td data-label="${t("unitNo")}"><span class="mono">${escapeHtml(owner.unit)}</span></td>
|
||
<td data-label="${t("memberNo")}"><span class="mono">${escapeHtml(owner.member)}</span></td>
|
||
<td data-label="${t("transferDate")}">${shortDate(owner.transferDate)}</td>
|
||
<td data-label="${t("remainingStayPrivileges")}" class="align-right"><strong>${plural(owner.remaining, "night")}</strong></td>
|
||
<td><button class="row-action" type="button" aria-label="${escapeHtml(t("viewAccount", { name: owner.name }))}">${iconChevron()}</button></td>
|
||
</tr>`).join("") : `<tr class="empty-table-row"><td colspan="9"><div class="table-empty"><strong>${t(databaseEmpty ? "noDatabaseOwners" : "noOwnerAccountsFound")}</strong><span>${t(databaseEmpty ? "noDatabaseOwnersDescription" : "tryOwnerFilter")}</span></div></td></tr>`;
|
||
|
||
const pagination = $("#ownerPagination");
|
||
if (pagination) {
|
||
pagination.hidden = totalPages <= 1;
|
||
$("#ownerPageStatus").textContent = filtered.length
|
||
? t("ownerPageStatus", { from: pageFrom, to: pageTo, total: filtered.length, page: ownerPage, totalPages })
|
||
: "";
|
||
const pageNumbers = $("#ownerPageNumbers");
|
||
if (pageNumbers) {
|
||
pageNumbers.hidden = totalPages <= 1;
|
||
pageNumbers.innerHTML = totalPages > 1
|
||
? Array.from({ length: totalPages }, (_, index) => {
|
||
const page = index + 1;
|
||
const current = page === ownerPage;
|
||
return `<button class="pagination-number" type="button" data-owner-page="${page}" aria-label="${escapeHtml(t("goToPage", { page }))}"${current ? ' aria-current="page"' : ""}>${formatNumber(page)}</button>`;
|
||
}).join("")
|
||
: "";
|
||
$$("#ownerPageNumbers [data-owner-page]").forEach(button => button.addEventListener("click", () => {
|
||
ownerPage = Number(button.dataset.ownerPage);
|
||
renderOwners();
|
||
}));
|
||
}
|
||
}
|
||
$("#ownerTableWrap").scrollTop = 0;
|
||
|
||
$$("#ownerTableBody tr[data-owner-id]").forEach(row => {
|
||
bindTableRowActivation(row, () => openOwnerDrawer(row.dataset.ownerId));
|
||
});
|
||
}
|
||
|
||
function renderStays() {
|
||
const query = $("#staySearch").value.trim().toLowerCase();
|
||
const type = $("#stayTypeFilter").value;
|
||
const filtered = usageRecordsInDisplayOrder(stayRecords.filter(record => {
|
||
const owner = ownerFor(record.ownerId);
|
||
if (!owner) return false;
|
||
const haystack = `${record.confirmation} ${owner.name} ${owner.room} ${owner.member} ${owner.unit} ${record.usedType} ${record.remark || ""}`.toLowerCase();
|
||
return haystack.includes(query) && (!type || record.usedType === type);
|
||
}));
|
||
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 `<tr class="clickable-row" data-owner-id="${owner.id}" tabindex="0">
|
||
<td data-label="${t("confirmationNo")}"><strong class="mono">${record.confirmation}</strong></td>
|
||
<td class="owner-cell" data-label="${t("ownerRoom")}"><span><strong>${escapeHtml(owner.name)}</strong><small class="table-subline">${escapeHtml(t("roomMemberLine", { room: owner.room, member: owner.member }))}</small></span></td>
|
||
<td data-label="${t("checkIn")}">${shortDate(record.checkin)}</td>
|
||
<td data-label="${t("checkOut")}">${shortDate(record.checkout)}</td>
|
||
<td data-label="${t("night")}">${record.nights}</td>
|
||
<td data-label="${t("use")}">${record.deducted}</td>
|
||
<td data-label="${t("balance")}" class="align-right"><strong>${plural(record.balance, "night")}</strong></td>
|
||
<td data-label="${t("usedRoomType")}"><span class="history-room-type" title="${escapeHtml(record.usedType)}">${escapeHtml(record.usedType)}</span></td>
|
||
<td data-label="${t("remark")}"><span class="record-remark">${escapeHtml(record.remark || t("noRemark"))}</span></td>
|
||
<td class="usage-row-actions"><div class="row-actions">
|
||
<button class="row-action" type="button" aria-label="${escapeHtml(t("openAccount", { name: owner.name }))}">${iconChevron()}</button>
|
||
<button class="row-action delete-usage-action" type="button" data-usage-id="${escapeHtml(record.id)}" aria-label="${escapeHtml(t("deleteUsage", { confirmation: record.confirmation }))}" title="${escapeHtml(t("deleteUsage", { confirmation: record.confirmation }))}">${iconTrash()}</button>
|
||
</div></td>
|
||
</tr>`;
|
||
}).join("") : `<tr class="empty-table-row"><td colspan="10"><div class="table-empty"><strong>${t(databaseEmpty ? "noDatabaseUsage" : "noUsageRecordsFound")}</strong><span>${t(databaseEmpty ? "noDatabaseUsageDescription" : "tryUsageFilter")}</span></div></td></tr>`;
|
||
|
||
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 `<button class="pagination-number" type="button" data-usage-page="${page}" aria-label="${escapeHtml(t("goToPage", { page }))}"${current ? ' aria-current="page"' : ""}>${formatNumber(page)}</button>`;
|
||
}).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 `<div class="room-mix-row" role="listitem">
|
||
<strong class="room-mix-type">${escapeHtml(type)}</strong>
|
||
<div class="room-mix-series-stack">
|
||
<div class="room-mix-series room-mix-series-source" aria-label="${escapeHtml(t("roomTypeSeriesAria", { type, count: formatNumber(ownerCount), percent: ownerPercent }))}">
|
||
<span class="room-mix-track" aria-hidden="true"><span class="room-mix-fill room-mix-fill-source${ownerCount ? " has-value" : ""}" style="--share:${ownerShareWidth}"></span></span>
|
||
<span class="room-mix-value"><strong>${formatNumber(ownerCount)}</strong><small>${ownerPercent}</small></span>
|
||
</div>
|
||
<div class="room-mix-series room-mix-series-used" aria-label="${escapeHtml(t("usedRoomTypeSeriesAria", { type, count: formatNumber(usedCount), percent: usedPercent }))}">
|
||
<span class="room-mix-track" aria-hidden="true"><span class="room-mix-fill room-mix-fill-used${usedCount ? " has-value" : ""}" style="--share:${usedShareWidth}"></span></span>
|
||
<span class="room-mix-value"><strong>${formatNumber(usedCount)}</strong><small>${usedPercent}</small></span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderMonthlyUse() {
|
||
if (!IS_API_MODE || !dashboardData) return;
|
||
const byMonth = new Map(dashboardData.monthlyUse.map(item => [item.month, item.use]));
|
||
const maximum = Math.max(1, ...byMonth.values());
|
||
$$(".bar-item[data-month]").forEach(item => {
|
||
const month = Number(item.dataset.month);
|
||
const use = byMonth.get(month) || 0;
|
||
const height = `${Math.round((use / maximum) * 100)}%`;
|
||
const bar = item.querySelector("span");
|
||
bar.style.setProperty("--height", height);
|
||
item.title = `${item.querySelector("small").textContent}: ${use}`;
|
||
});
|
||
}
|
||
|
||
function updateAggregateUI() {
|
||
$("#ownerRoomCount").textContent = formatNumber(IS_API_MODE && dashboardData ? dashboardData.ownerRooms : owners.length);
|
||
$("#accountTabCount").textContent = formatNumber(owners.length);
|
||
$("#usageTabCount").textContent = formatNumber(stayRecords.length);
|
||
$("#totalRemaining").textContent = formatNumber(aggregateRemaining);
|
||
$("#totalUsed").textContent = formatNumber(aggregateUsed);
|
||
$("#chartUsed").textContent = formatNumber(aggregateUsed);
|
||
}
|
||
|
||
function populateFormOptions() {
|
||
const ownerSelect = $("#ownerSelect");
|
||
const usedTypeSelect = $("#usedTypeSelect");
|
||
[
|
||
[$("#roomTypeFilter"), "allRoomTypes"],
|
||
[$("#stayTypeFilter"), "allUsedRoomTypes"]
|
||
].forEach(([select, allKey]) => {
|
||
const selected = select.value;
|
||
select.innerHTML = `<option value="">${escapeHtml(t(allKey))}</option>${ROOM_TYPES.map(roomTypeOption).join("")}`;
|
||
select.value = ROOM_TYPES.includes(selected) ? selected : "";
|
||
});
|
||
const selectedOwner = ownerSelect.value || String(owners[0]?.id || "");
|
||
const selectedType = usedTypeSelect.value || owners[0]?.purchasedType || ROOM_TYPES[0] || "";
|
||
ownerSelect.innerHTML = owners.length
|
||
? owners.map(owner => `<option value="${owner.id}">${escapeHtml(owner.name)} · ${t("room")} ${escapeHtml(owner.room)}</option>`).join("")
|
||
: `<option value="">${escapeHtml(t("noAccountsForUsage"))}</option>`;
|
||
usedTypeSelect.innerHTML = ROOM_TYPES.map(roomTypeOption).join("");
|
||
ownerSelect.value = owners.some(owner => String(owner.id) === selectedOwner) ? selectedOwner : String(owners[0]?.id || "");
|
||
ownerSelect.disabled = owners.length === 0 || stayOwnerLocked;
|
||
usedTypeSelect.value = ROOM_TYPES.includes(selectedType) ? selectedType : owners[0]?.purchasedType || ROOM_TYPES[0] || "";
|
||
}
|
||
|
||
function renderAccountContext() {
|
||
const owner = ownerFor($("#ownerSelect").value);
|
||
if (!owner) {
|
||
$("#accountContext").innerHTML = `<div class="context-empty">${escapeHtml(t("noAccountsForUsage"))}</div>`;
|
||
return;
|
||
}
|
||
$("#accountContext").innerHTML = `
|
||
<div class="context-item"><span>${t("room")}</span><strong>${owner.room}</strong></div>
|
||
<div class="context-item"><span>${t("purchasedType")}</span><strong>${owner.purchasedType}</strong></div>
|
||
<div class="context-item"><span>${t("memberNo")}</span><strong>${owner.member}</strong></div>
|
||
<div class="context-item"><span>${t("remaining")}</span><strong>${plural(owner.remaining, "night")}</strong></div>`;
|
||
}
|
||
|
||
function calculateForm() {
|
||
const owner = ownerFor($("#ownerSelect").value) || owners[0] || null;
|
||
const checkin = $("#checkinInput").value;
|
||
const checkout = $("#checkoutInput").value;
|
||
const nights = calculateNights(checkin, checkout);
|
||
const usedType = $("#usedTypeSelect").value;
|
||
const confirmation = $("#confirmationInput").value.trim();
|
||
if (!owner) {
|
||
$("#manualMultiplierField").hidden = true;
|
||
$("#manualMultiplier").required = false;
|
||
$("#ruleSource").textContent = t("automaticRule");
|
||
$("#nightsInput").value = nights > 0 ? plural(nights, "night") : t("notCalculated");
|
||
$("#calculationFormula").textContent = t("notCalculated");
|
||
$("#deductionOutput").textContent = t("pending");
|
||
$("#currentBalance").textContent = "—";
|
||
$("#afterBalance").textContent = "—";
|
||
$("#formError").textContent = t("noAccountsForUsage");
|
||
$("#formError").hidden = false;
|
||
$("#saveStayButton").disabled = true;
|
||
return { owner: null, checkin, checkout, nights, usedType, multiplier: 0, manualMultiplier: null, deduction: 0, confirmation, valid: false };
|
||
}
|
||
const autoMultiplier = getAutomaticMultiplier(owner.purchasedType, usedType);
|
||
const needsManual = autoMultiplier === null;
|
||
const multiplier = needsManual ? Number.parseInt($("#manualMultiplier").value, 10) || 0 : autoMultiplier;
|
||
const deduction = nights > 0 && multiplier > 0 ? nights * multiplier : 0;
|
||
const hasBalance = Number.isFinite(owner.remaining);
|
||
const after = hasBalance ? owner.remaining - deduction : null;
|
||
|
||
$("#manualMultiplierField").hidden = !needsManual;
|
||
$("#manualMultiplier").required = needsManual;
|
||
$("#ruleSource").textContent = needsManual ? t("ac2ManualRule") : t("automaticRule");
|
||
$("#nightsInput").value = nights > 0 ? plural(nights, "night") : t("notCalculated");
|
||
$("#calculationFormula").textContent = `${plural(Math.max(nights, 0), "night")} × ${multiplier || t("pending")}`;
|
||
$("#deductionOutput").textContent = deduction ? plural(deduction, "night") : t("pending");
|
||
$("#currentBalance").textContent = plural(owner.remaining, "night");
|
||
$("#afterBalance").textContent = deduction ? plural(after, "night") : t("pending");
|
||
|
||
let error = "";
|
||
if (!hasBalance) error = t("entitlementUnavailable");
|
||
else if (checkin && checkout && nights <= 0) error = t("checkoutLater");
|
||
else if (needsManual && !multiplier) error = t("selectAc2Multiplier");
|
||
else if (deduction > owner.remaining) error = t("insufficientPrivileges", { required: plural(deduction, "night"), available: plural(owner.remaining, "night") });
|
||
else if (confirmation && stayRecords.some(record => record.confirmation === confirmation)) error = t("duplicateConfirmation");
|
||
|
||
$("#formError").textContent = error;
|
||
$("#formError").hidden = !error;
|
||
|
||
const valid = /^\d+$/.test(confirmation) && nights > 0 && multiplier > 0 && hasBalance && deduction <= owner.remaining && !error && !isSubmittingStay;
|
||
$("#saveStayButton").disabled = !valid;
|
||
return {
|
||
owner,
|
||
checkin,
|
||
checkout,
|
||
nights,
|
||
usedType,
|
||
multiplier,
|
||
manualMultiplier: needsManual ? multiplier : null,
|
||
deduction,
|
||
confirmation,
|
||
valid
|
||
};
|
||
}
|
||
|
||
function resetStayForm(ownerId = owners[0]?.id || "") {
|
||
$("#stayForm").reset();
|
||
stayIdempotencyKey = IS_API_MODE ? 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 = `
|
||
<section class="detail-balance">
|
||
<div class="detail-balance-main"><span>${t("balance")}</span><strong>${formatNumber(owner.remaining)}</strong><small>${t("nightsAvailable")}</small></div>
|
||
<div class="detail-summary-grid">
|
||
<div><span>${t("annualGrant")}</span><strong>15</strong></div>
|
||
<div><span>${t("usedIn2026")}</span><strong>${owner.used}</strong></div>
|
||
</div>
|
||
</section>
|
||
<div class="detail-section-heading"><h3>${t("accountInformation")}</h3><span>${t(IS_API_MODE ? "databaseSource" : "workbookSource")}</span></div>
|
||
<section class="detail-grid">
|
||
<div><span>${t("roomNo")}</span><strong>${escapeHtml(owner.room)}</strong></div>
|
||
<div><span>${t("purchasedRoomType")}</span><strong>${escapeHtml(owner.purchasedType)}</strong></div>
|
||
<div><span>${t("unitNo")}</span><strong>${escapeHtml(owner.unit)}</strong></div>
|
||
<div><span>${t("memberNo")}</span><strong>${escapeHtml(owner.member)}</strong></div>
|
||
</section>
|
||
<section class="detail-section">
|
||
<div class="detail-section-heading"><h3>${t("usageHistory")}</h3><span>${plural(records.length, "record")}</span></div>
|
||
${records.length ? `<div class="detail-history-wrap"><table class="detail-history-table">
|
||
<thead><tr><th>${t("confirmationNo")}</th><th>${t("checkIn")}</th><th>${t("checkOut")}</th><th>${t("night")}</th><th>${t("drawerUsingNights")}</th><th>${t("usedRoomType")}</th><th>${t("balance")}</th><th>${t("remark")}</th><th><span class="sr-only">${t("actions")}</span></th></tr></thead>
|
||
<tbody>${records.map(record => `<tr>
|
||
<td data-label="${t("confirmationNo")}"><strong class="mono">${record.confirmation}</strong></td>
|
||
<td data-label="${t("checkIn")}">${shortDate(record.checkin)}</td>
|
||
<td data-label="${t("checkOut")}">${shortDate(record.checkout)}</td>
|
||
<td data-label="${t("night")}">${record.nights}</td>
|
||
<td data-label="${t("drawerUsingNights")}">${record.deducted}</td>
|
||
<td data-label="${t("usedRoomType")}"><span class="history-room-type" title="${escapeHtml(record.usedType)}">${escapeHtml(record.usedType)}</span></td>
|
||
<td data-label="${t("balance")}"><strong>${plural(record.balance, "night")}</strong></td>
|
||
<td data-label="${t("remark")}"><span class="detail-history-remark">${escapeHtml(record.remark || t("noRemark"))}</span></td>
|
||
<td class="detail-history-actions"><button class="row-action delete-usage-action" type="button" data-usage-id="${escapeHtml(record.id)}" aria-label="${escapeHtml(t("deleteUsage", { confirmation: record.confirmation }))}" title="${escapeHtml(t("deleteUsage", { confirmation: record.confirmation }))}">${iconTrash()}</button></td>
|
||
</tr>`).join("")}</tbody>
|
||
</table></div>` : `<div class="detail-empty"><strong>${t("noUsage2026")}</strong><span>${t("addFirstUsage")}</span></div>`}
|
||
</section>`;
|
||
bindDeleteActions($("#ownerDrawerBody"));
|
||
updateUsageEntryAvailability();
|
||
}
|
||
|
||
function openOwnerDrawer(ownerId) {
|
||
if (!ownerFor(ownerId)) return;
|
||
detailOwnerId = ownerId;
|
||
renderOwnerDrawer(ownerId);
|
||
$("#drawerScrim").hidden = false;
|
||
$("#ownerDrawer").classList.add("open");
|
||
$("#ownerDrawer").setAttribute("aria-hidden", "false");
|
||
document.body.style.overflow = "hidden";
|
||
updateUsageEntryAvailability();
|
||
}
|
||
|
||
function closeOwnerDrawer(hideScrim = true) {
|
||
$("#ownerDrawer").classList.remove("open");
|
||
$("#ownerDrawer").setAttribute("aria-hidden", "true");
|
||
if (hideScrim && !$("#stayDrawer").classList.contains("open")) {
|
||
$("#drawerScrim").hidden = true;
|
||
document.body.style.overflow = "";
|
||
}
|
||
updateUsageEntryAvailability();
|
||
}
|
||
|
||
function renderToast() {
|
||
if (!lastToast) return;
|
||
$("#toast strong").textContent = t(lastToast.titleKey, lastToast.params);
|
||
$("#toast span").textContent = t(lastToast.messageKey, lastToast.params);
|
||
}
|
||
|
||
function showToast(titleKey, messageKey, params = {}) {
|
||
window.clearTimeout(toastTimer);
|
||
lastToast = { titleKey, messageKey, params };
|
||
renderToast();
|
||
$("#toast").hidden = false;
|
||
toastTimer = window.setTimeout(() => { $("#toast").hidden = true; }, 3600);
|
||
}
|
||
|
||
function renderConfirmDialog(record) {
|
||
const dialog = $("#confirmDialog");
|
||
if (!dialog || !record) return;
|
||
$("#confirmDialogTitle").textContent = t("deleteUsageTitle");
|
||
$("#confirmDialogMessage").textContent = t("deleteUsageDescription", { confirmation: record.confirmation });
|
||
$("#confirmDialogRecord").textContent = record.confirmation;
|
||
}
|
||
|
||
function closeConfirmDialog(result = false) {
|
||
const dialog = $("#confirmDialog");
|
||
const scrim = $("#confirmScrim");
|
||
const resolver = confirmDialogResolver;
|
||
const previousFocus = confirmDialogPreviousFocus;
|
||
const previousOverflow = confirmDialogPreviousOverflow;
|
||
|
||
confirmDialogResolver = null;
|
||
confirmDialogPreviousFocus = null;
|
||
confirmDialogPreviousOverflow = "";
|
||
confirmDialogRecord = null;
|
||
|
||
if (dialog) {
|
||
dialog.classList.remove("open");
|
||
dialog.setAttribute("aria-hidden", "true");
|
||
dialog.hidden = true;
|
||
}
|
||
if (scrim) scrim.hidden = true;
|
||
document.body.style.overflow = previousOverflow;
|
||
if (previousFocus?.isConnected) previousFocus.focus();
|
||
resolver?.(result);
|
||
}
|
||
|
||
function openDeleteConfirm(record) {
|
||
return new Promise(resolve => {
|
||
const dialog = $("#confirmDialog");
|
||
const scrim = $("#confirmScrim");
|
||
if (!dialog || !scrim) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
if (confirmDialogResolver) closeConfirmDialog(false);
|
||
|
||
confirmDialogResolver = resolve;
|
||
confirmDialogRecord = record;
|
||
confirmDialogPreviousFocus = document.activeElement;
|
||
confirmDialogPreviousOverflow = document.body.style.overflow;
|
||
renderConfirmDialog(record);
|
||
$("#confirmDialogAction").disabled = false;
|
||
dialog.hidden = false;
|
||
dialog.classList.add("open");
|
||
dialog.setAttribute("aria-hidden", "false");
|
||
scrim.hidden = false;
|
||
document.body.style.overflow = "hidden";
|
||
window.requestAnimationFrame(() => {
|
||
if (confirmDialogResolver && dialog.classList.contains("open")) $("#cancelConfirmDialog")?.focus();
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateDashboardAfterCreatedUsage(record) {
|
||
if (!dashboardData) return;
|
||
dashboardData.remainingPrivileges = Math.max(0, dashboardData.remainingPrivileges - record.deducted);
|
||
dashboardData.used += record.deducted;
|
||
const usedType = dashboardData.usedRoomTypes.find(item => item.roomType === record.usedType);
|
||
if (usedType) usedType.count += record.nights;
|
||
else dashboardData.usedRoomTypes.push({ roomType: record.usedType, count: record.nights });
|
||
const month = Number(record.checkin.slice(5, 7));
|
||
const monthly = dashboardData.monthlyUse.find(item => item.month === month);
|
||
if (monthly) monthly.use += record.deducted;
|
||
else dashboardData.monthlyUse.push({ month, use: record.deducted });
|
||
}
|
||
|
||
function updateDashboardAfterDeletedUsage(record) {
|
||
if (!dashboardData) return;
|
||
dashboardData.remainingPrivileges += record.deducted;
|
||
dashboardData.used = Math.max(0, dashboardData.used - record.deducted);
|
||
const usedTypeIndex = dashboardData.usedRoomTypes.findIndex(item => item.roomType === record.usedType);
|
||
if (usedTypeIndex >= 0) {
|
||
const usedType = dashboardData.usedRoomTypes[usedTypeIndex];
|
||
usedType.count = Math.max(0, usedType.count - record.nights);
|
||
if (usedType.count === 0) dashboardData.usedRoomTypes.splice(usedTypeIndex, 1);
|
||
}
|
||
const month = Number(record.checkin.slice(5, 7));
|
||
const monthlyIndex = dashboardData.monthlyUse.findIndex(item => item.month === month);
|
||
if (monthlyIndex >= 0) {
|
||
const monthly = dashboardData.monthlyUse[monthlyIndex];
|
||
monthly.use = Math.max(0, monthly.use - record.deducted);
|
||
if (monthly.use === 0) dashboardData.monthlyUse.splice(monthlyIndex, 1);
|
||
}
|
||
}
|
||
|
||
function usageSaveErrorKey(error) {
|
||
return {
|
||
INSUFFICIENT_BALANCE: "usageSaveInsufficient",
|
||
CONFLICT: "usageSaveConflict",
|
||
NOT_FOUND: "usageSaveNotFound",
|
||
BUSINESS_RULE_VIOLATION: "usageSaveRuleViolation",
|
||
VALIDATION_ERROR: "usageSaveRuleViolation",
|
||
NETWORK_ERROR: "usageSaveNetwork",
|
||
REQUEST_ABORTED: "usageSaveNetwork"
|
||
}[error?.code] || "usageSaveFailed";
|
||
}
|
||
|
||
function usageDeleteErrorKey(error) {
|
||
return {
|
||
NOT_FOUND: "usageDeleteNotFound",
|
||
CONFLICT: "usageDeleteConflict",
|
||
NETWORK_ERROR: "usageDeleteNetwork",
|
||
REQUEST_ABORTED: "usageDeleteNetwork"
|
||
}[error?.code] || "usageDeleteFailed";
|
||
}
|
||
|
||
async function requestDeleteUsage(recordId) {
|
||
const record = stayRecords.find(candidate => String(candidate.id) === String(recordId));
|
||
if (!record || deletingUsageIds.has(recordId)) return;
|
||
if (!await openDeleteConfirm(record)) return;
|
||
|
||
deletingUsageIds.add(recordId);
|
||
updateUsageEntryAvailability();
|
||
let errorKey = null;
|
||
let authenticationRequired = false;
|
||
try {
|
||
if (IS_API_MODE) {
|
||
await API_CLIENT.deleteUsageRecord(recordId);
|
||
await loadApiData();
|
||
} else {
|
||
const owner = ownerFor(record.ownerId);
|
||
if (owner && Number.isFinite(owner.remaining)) owner.remaining += record.deducted;
|
||
aggregateRemaining += record.deducted;
|
||
aggregateUsed = Math.max(0, aggregateUsed - record.deducted);
|
||
updateDashboardAfterDeletedUsage(record);
|
||
stayRecords = stayRecords.filter(candidate => candidate.id !== record.id);
|
||
updateOwnerUsageTotals();
|
||
refreshDataViews();
|
||
}
|
||
} catch (error) {
|
||
if (isUnauthorized(error)) authenticationRequired = true;
|
||
else errorKey = usageDeleteErrorKey(error);
|
||
} finally {
|
||
deletingUsageIds.delete(recordId);
|
||
updateUsageEntryAvailability();
|
||
}
|
||
|
||
if (authenticationRequired) {
|
||
showLoginScreen("sessionExpired");
|
||
return;
|
||
}
|
||
if (errorKey) {
|
||
showToast("usageDeleteFailed", errorKey);
|
||
return;
|
||
}
|
||
|
||
showToast("usageDeleted", "usageDeletedMessage", {
|
||
confirmation: record.confirmation,
|
||
restored: plural(record.deducted, "night")
|
||
});
|
||
}
|
||
|
||
async function submitStay(event) {
|
||
event.preventDefault();
|
||
const calculation = calculateForm();
|
||
if (!calculation.valid) return;
|
||
|
||
let createdRecord;
|
||
let errorKey = null;
|
||
let authenticationRequired = false;
|
||
isSubmittingStay = true;
|
||
$("#saveStayButton").textContent = t("savingRecord");
|
||
$("#saveStayButton").disabled = true;
|
||
updateUsageEntryAvailability();
|
||
|
||
try {
|
||
if (IS_API_MODE) {
|
||
const response = await API_CLIENT.createUsageRecord({
|
||
ownerAccountId: calculation.owner.id,
|
||
confirmationNo: calculation.confirmation,
|
||
checkIn: calculation.checkin,
|
||
checkOut: calculation.checkout,
|
||
usedRoomType: calculation.usedType,
|
||
manualMultiplier: calculation.manualMultiplier,
|
||
remark: $("#remarkInput").value.trim(),
|
||
idempotencyKey: stayIdempotencyKey || (stayIdempotencyKey = window.crypto.randomUUID())
|
||
});
|
||
createdRecord = mapApiUsage(response);
|
||
calculation.owner.remaining = createdRecord.balance;
|
||
stayRecords = [createdRecord, ...stayRecords.filter(record => record.id !== createdRecord.id)];
|
||
updateOwnerUsageTotals();
|
||
aggregateRemaining = Math.max(0, aggregateRemaining - createdRecord.deducted);
|
||
aggregateUsed += createdRecord.deducted;
|
||
updateDashboardAfterCreatedUsage(createdRecord);
|
||
} else {
|
||
calculation.owner.remaining -= calculation.deduction;
|
||
calculation.owner.used += calculation.deduction;
|
||
aggregateRemaining -= calculation.deduction;
|
||
aggregateUsed += calculation.deduction;
|
||
createdRecord = {
|
||
id: `manual-${Date.now()}-${nextManualEntrySequence}`,
|
||
confirmation: calculation.confirmation,
|
||
ownerId: calculation.owner.id,
|
||
checkin: calculation.checkin,
|
||
checkout: calculation.checkout,
|
||
nights: calculation.nights,
|
||
usedType: calculation.usedType,
|
||
multiplier: calculation.multiplier,
|
||
deducted: calculation.deduction,
|
||
balance: calculation.owner.remaining,
|
||
remark: $("#remarkInput").value.trim(),
|
||
recordSource: "manual",
|
||
importOrder: null,
|
||
enteredAt: new Date().toISOString(),
|
||
entrySequence: nextManualEntrySequence++
|
||
};
|
||
stayRecords.push(createdRecord);
|
||
}
|
||
} catch (error) {
|
||
if (isUnauthorized(error)) authenticationRequired = true;
|
||
else errorKey = usageSaveErrorKey(error);
|
||
} finally {
|
||
isSubmittingStay = false;
|
||
$("#saveStayButton").textContent = t("saveRecord");
|
||
updateUsageEntryAvailability();
|
||
}
|
||
|
||
if (authenticationRequired) {
|
||
showLoginScreen("sessionExpired");
|
||
return;
|
||
}
|
||
|
||
if (errorKey) {
|
||
calculateForm();
|
||
$("#formError").textContent = t(errorKey);
|
||
$("#formError").hidden = false;
|
||
return;
|
||
}
|
||
|
||
refreshDataViews();
|
||
closeStayDrawer(false);
|
||
setOwnerWorkspaceView("accounts", { syncRoute: false });
|
||
switchPage("owners");
|
||
window.setTimeout(() => openOwnerDrawer(calculation.owner.id), 0);
|
||
showToast("usageSaved", "usageSavedMessage", { deduction: plural(createdRecord.deducted, "night"), room: calculation.owner.room });
|
||
}
|
||
|
||
function updateMenuAria() {
|
||
const isOpen = $("#sidebar").classList.contains("open");
|
||
$("#menuButton").setAttribute("aria-label", t(isOpen ? "closeNavigation" : "openNavigation"));
|
||
}
|
||
|
||
function openMobileMenu() {
|
||
$("#sidebar").classList.add("open");
|
||
$("#mobileScrim").hidden = false;
|
||
$("#menuButton").setAttribute("aria-expanded", "true");
|
||
updateMenuAria();
|
||
}
|
||
|
||
function closeMobileMenu() {
|
||
$("#sidebar").classList.remove("open");
|
||
$("#mobileScrim").hidden = true;
|
||
$("#menuButton").setAttribute("aria-expanded", "false");
|
||
updateMenuAria();
|
||
}
|
||
|
||
function applyStaticTranslations() {
|
||
$$("[data-i18n]").forEach(element => {
|
||
element.textContent = t(element.dataset.i18n);
|
||
});
|
||
$$("[data-i18n-placeholder]").forEach(element => {
|
||
element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
|
||
});
|
||
$$("[data-i18n-aria-label]").forEach(element => {
|
||
element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel));
|
||
});
|
||
}
|
||
|
||
function applyLanguage(language) {
|
||
currentLanguage = SUPPORTED_LANGUAGES.includes(language) ? language : "en";
|
||
document.documentElement.lang = LANGUAGE_CONFIG[currentLanguage].html;
|
||
document.documentElement.dataset.locale = currentLanguage;
|
||
document.title = t("appTitle");
|
||
const metaDescription = $('meta[name="description"]');
|
||
if (metaDescription) metaDescription.setAttribute("content", t("metaDescription"));
|
||
|
||
applyStaticTranslations();
|
||
$$("[data-language]").forEach(button => {
|
||
const isActive = button.dataset.language === currentLanguage;
|
||
button.classList.toggle("active", isActive);
|
||
button.setAttribute("aria-pressed", String(isActive));
|
||
});
|
||
$$("[data-login-language]").forEach(button => {
|
||
const isActive = button.dataset.loginLanguage === currentLanguage;
|
||
button.classList.toggle("active", isActive);
|
||
button.setAttribute("aria-pressed", String(isActive));
|
||
});
|
||
updatePasswordToggle();
|
||
updateAuthenticationControls();
|
||
if (loginErrorKey) setLoginError(loginErrorKey);
|
||
if (sessionErrorKey && $("#sessionError")) $("#sessionError").textContent = t(sessionErrorKey);
|
||
|
||
if (!workspaceInitialized) return;
|
||
renderConnectionState();
|
||
refreshDataViews();
|
||
updateStayDrawerCopy();
|
||
updateMenuAria();
|
||
|
||
if ($("#ownerDrawer").classList.contains("open") && detailOwnerId) renderOwnerDrawer(detailOwnerId);
|
||
if (!$("#toast").hidden) renderToast();
|
||
if (confirmDialogRecord && $("#confirmDialog").classList.contains("open")) renderConfirmDialog(confirmDialogRecord);
|
||
}
|
||
|
||
function bindEvents() {
|
||
$$(".nav-item").forEach(button => button.addEventListener("click", () => {
|
||
if (button.dataset.page === "owners") setOwnerWorkspaceView("accounts", { syncRoute: false });
|
||
switchPage(button.dataset.page);
|
||
}));
|
||
$$("[data-owner-view]").forEach((tab, index, tabs) => {
|
||
tab.addEventListener("click", () => setOwnerWorkspaceView(tab.dataset.ownerView));
|
||
tab.addEventListener("keydown", event => {
|
||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||
event.preventDefault();
|
||
let nextIndex = index;
|
||
if (event.key === "ArrowLeft") nextIndex = (index - 1 + tabs.length) % tabs.length;
|
||
if (event.key === "ArrowRight") nextIndex = (index + 1) % tabs.length;
|
||
if (event.key === "Home") nextIndex = 0;
|
||
if (event.key === "End") nextIndex = tabs.length - 1;
|
||
setOwnerWorkspaceView(tabs[nextIndex].dataset.ownerView, { focus: true });
|
||
});
|
||
});
|
||
bindLanguageGroup("[data-language]");
|
||
$$(".open-stay-drawer").forEach(button => button.addEventListener("click", () => {
|
||
if (owners[0]) openStayDrawer(owners[0].id, false);
|
||
}));
|
||
$("#closeDrawer").addEventListener("click", () => closeStayDrawer(true));
|
||
$("#cancelDrawer").addEventListener("click", () => closeStayDrawer(true));
|
||
$("#drawerScrim").addEventListener("click", () => {
|
||
closeStayDrawer(false);
|
||
closeOwnerDrawer();
|
||
});
|
||
$("#closeOwnerDrawer").addEventListener("click", () => closeOwnerDrawer());
|
||
$("#cancelConfirmDialog").addEventListener("click", () => closeConfirmDialog(false));
|
||
$("#confirmDialogAction").addEventListener("click", () => closeConfirmDialog(true));
|
||
$("#confirmScrim").addEventListener("click", () => closeConfirmDialog(false));
|
||
$("#detailAddStay").addEventListener("click", () => {
|
||
const ownerId = detailOwnerId || owners[0]?.id;
|
||
if (ownerId) openStayDrawer(ownerId, true);
|
||
});
|
||
$("#stayForm").addEventListener("submit", submitStay);
|
||
$("#retryApiButton").addEventListener("click", () => { void loadApiData(); });
|
||
$("#ownerSelect").addEventListener("change", () => {
|
||
renderAccountContext();
|
||
calculateForm();
|
||
});
|
||
["#confirmationInput", "#checkinInput", "#checkoutInput", "#usedTypeSelect", "#manualMultiplier"].forEach(selector => $(selector).addEventListener("input", calculateForm));
|
||
$("#confirmationInput").addEventListener("input", event => {
|
||
event.target.value = event.target.value.replace(/\D/g, "");
|
||
});
|
||
$("#ownerSearch").addEventListener("input", () => {
|
||
ownerPage = 1;
|
||
renderOwners();
|
||
});
|
||
$("#roomTypeFilter").addEventListener("change", () => {
|
||
ownerPage = 1;
|
||
renderOwners();
|
||
});
|
||
$("#staySearch").addEventListener("input", () => {
|
||
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();
|