Menu builder and portal updates

- Menu builder UI improvements
- Portal CSS and JS updates
- Station assignment updates
- Add business tabs update endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-03 17:08:54 -08:00
parent e21a7f7266
commit d7632c5d35
10 changed files with 326 additions and 182 deletions

View file

@ -112,6 +112,7 @@ if (len(request._api_path)) {
if (findNoCase("/api/businesses/getChildren.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/getChildren.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/update.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/update.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/updateHours.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/updateHours.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/updateTabs.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/servicepoints/list.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/servicepoints/list.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/servicepoints/get.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/servicepoints/get.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/servicepoints/save.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/servicepoints/save.cfm", request._api_path)) request._api_isPublic = true;

View file

@ -43,7 +43,10 @@ try {
IsHiring, IsHiring,
HeaderImageExtension, HeaderImageExtension,
TaxRate, TaxRate,
BrandColor BrandColor,
SessionEnabled,
SessionLockMinutes,
SessionPaymentStrategy
FROM Businesses FROM Businesses
WHERE ID = :businessID WHERE ID = :businessID
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
@ -140,7 +143,10 @@ try {
"IsHiring": q.IsHiring == 1, "IsHiring": q.IsHiring == 1,
"TaxRate": taxRate, "TaxRate": taxRate,
"TaxRatePercent": taxRate * 100, "TaxRatePercent": taxRate * 100,
"BrandColor": len(q.BrandColor) ? (left(q.BrandColor, 1) == chr(35) ? q.BrandColor : chr(35) & q.BrandColor) : "" "BrandColor": len(q.BrandColor) ? (left(q.BrandColor, 1) == chr(35) ? q.BrandColor : chr(35) & q.BrandColor) : "",
"SessionEnabled": isNumeric(q.SessionEnabled) ? q.SessionEnabled : 0,
"SessionLockMinutes": isNumeric(q.SessionLockMinutes) ? q.SessionLockMinutes : 30,
"SessionPaymentStrategy": len(q.SessionPaymentStrategy) ? q.SessionPaymentStrategy : "A"
}; };
// Add header image URL if extension exists // Add header image URL if extension exists

View file

@ -0,0 +1,71 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
function apiAbort(obj) {
writeOutput(serializeJSON(obj));
abort;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) {
apiAbort({ OK = false, ERROR = "missing_body" });
}
try {
parsed = deserializeJSON(raw);
} catch (any e) {
apiAbort({ OK = false, ERROR = "bad_json", MESSAGE = "Invalid JSON body" });
}
if (!isStruct(parsed)) {
apiAbort({ OK = false, ERROR = "bad_json", MESSAGE = "JSON must be an object" });
}
return parsed;
}
data = readJsonBody();
if (!structKeyExists(data, "BusinessID") || !isNumeric(data.BusinessID) || int(data.BusinessID) LTE 0) {
apiAbort({ OK = false, ERROR = "missing_BusinessID" });
}
BusinessID = int(data.BusinessID);
SessionEnabled = structKeyExists(data, "SessionEnabled") && isNumeric(data.SessionEnabled) ? int(data.SessionEnabled) : 0;
SessionLockMinutes = structKeyExists(data, "SessionLockMinutes") && isNumeric(data.SessionLockMinutes) ? int(data.SessionLockMinutes) : 30;
SessionPaymentStrategy = structKeyExists(data, "SessionPaymentStrategy") ? left(trim(data.SessionPaymentStrategy), 1) : "A";
// Validate
if (SessionLockMinutes < 5) SessionLockMinutes = 5;
if (SessionLockMinutes > 480) SessionLockMinutes = 480;
if (SessionPaymentStrategy != "A" && SessionPaymentStrategy != "P") SessionPaymentStrategy = "A";
</cfscript>
<cftry>
<cfquery datasource="payfrit">
UPDATE Businesses
SET SessionEnabled = <cfqueryparam cfsqltype="cf_sql_tinyint" value="#SessionEnabled#">,
SessionLockMinutes = <cfqueryparam cfsqltype="cf_sql_integer" value="#SessionLockMinutes#">,
SessionPaymentStrategy = <cfqueryparam cfsqltype="cf_sql_char" value="#SessionPaymentStrategy#">
WHERE ID = <cfqueryparam cfsqltype="cf_sql_integer" value="#BusinessID#">
</cfquery>
<cfoutput>#serializeJSON({
"OK" = true,
"ERROR" = "",
"BusinessID" = BusinessID,
"SessionEnabled" = SessionEnabled,
"SessionLockMinutes" = SessionLockMinutes,
"SessionPaymentStrategy" = SessionPaymentStrategy
})#</cfoutput>
<cfcatch>
<cfoutput>#serializeJSON({
"OK" = false,
"ERROR" = "server_error",
"MESSAGE" = cfcatch.message
})#</cfoutput>
</cfcatch>
</cftry>

View file

@ -172,7 +172,7 @@ try {
// Get direct modifiers (items with ParentItemID pointing to menu items, not categories) // Get direct modifiers (items with ParentItemID pointing to menu items, not categories)
qDirectModifiers = queryTimed(" qDirectModifiers = queryTimed("
SELECT SELECT
m.ItemID, m.ID as ItemID,
m.ParentItemID as ParentItemID, m.ParentItemID as ParentItemID,
m.Name, m.Name,
m.Price, m.Price,
@ -192,16 +192,16 @@ try {
// NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children // NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children
qCategories = queryTimed(" qCategories = queryTimed("
SELECT DISTINCT SELECT DISTINCT
p.ItemID as CategoryID, p.ID as CategoryID,
p.Name as Name, p.Name as Name,
p.SortOrder p.SortOrder
FROM Items p FROM Items p
INNER JOIN Items c ON c.ParentItemID = p.ItemID INNER JOIN Items c ON c.ParentItemID = p.ID
WHERE p.BusinessID = :businessID WHERE p.BusinessID = :businessID
AND p.ParentItemID = 0 AND p.ParentItemID = 0
AND p.IsActive = 1 AND p.IsActive = 1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = p.ItemID SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = p.ID
) )
ORDER BY p.SortOrder, p.Name ORDER BY p.SortOrder, p.Name
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
@ -228,7 +228,7 @@ try {
qDirectModifiers = queryTimed(" qDirectModifiers = queryTimed("
SELECT SELECT
m.ItemID, m.ID as ItemID,
m.ParentItemID as ParentItemID, m.ParentItemID as ParentItemID,
m.Name, m.Name,
m.Price, m.Price,
@ -267,7 +267,7 @@ try {
// Get templates for this business only // Get templates for this business only
qTemplates = queryTimed(" qTemplates = queryTimed("
SELECT DISTINCT SELECT DISTINCT
t.ItemID, t.ID as ItemID,
t.Name, t.Name,
t.Price, t.Price,
t.IsCheckedByDefault as IsDefault, t.IsCheckedByDefault as IsDefault,
@ -292,7 +292,7 @@ try {
if (arrayLen(templateIds) > 0) { if (arrayLen(templateIds) > 0) {
qTemplateChildren = queryTimed(" qTemplateChildren = queryTimed("
SELECT SELECT
c.ItemID, c.ID as ItemID,
c.ParentItemID as ParentItemID, c.ParentItemID as ParentItemID,
c.Name, c.Name,
c.Price, c.Price,

View file

@ -31,7 +31,7 @@ function saveOptionsRecursive(options, parentID, businessID) {
RequiresChildSelection = :requiresSelection, RequiresChildSelection = :requiresSelection,
MaxNumSelectionReq = :maxSelections, MaxNumSelectionReq = :maxSelections,
ParentItemID = :parentID ParentItemID = :parentID
WHERE ItemID = :optID WHERE ID = :optID
", { ", {
optID: optDbId, optID: optDbId,
parentID: parentID, parentID: parentID,
@ -122,7 +122,7 @@ try {
UPDATE Items UPDATE Items
SET Name = :name, SET Name = :name,
SortOrder = :sortOrder SortOrder = :sortOrder
WHERE ItemID = :categoryID WHERE ID = :categoryID
AND BusinessID = :businessID AND BusinessID = :businessID
", { ", {
categoryID: categoryID, categoryID: categoryID,
@ -203,7 +203,7 @@ try {
Price = :price, Price = :price,
ParentItemID = :categoryID, ParentItemID = :categoryID,
SortOrder = :sortOrder SortOrder = :sortOrder
WHERE ItemID = :itemID WHERE ID = :itemID
", { ", {
itemID: itemID, itemID: itemID,
name: item.name, name: item.name,
@ -220,7 +220,7 @@ try {
Price = :price, Price = :price,
CategoryID = :categoryID, CategoryID = :categoryID,
SortOrder = :sortOrder SortOrder = :sortOrder
WHERE ItemID = :itemID WHERE ID = :itemID
", { ", {
itemID: itemID, itemID: itemID,
name: item.name, name: item.name,
@ -299,7 +299,7 @@ try {
UPDATE Items UPDATE Items
SET RequiresChildSelection = :requiresSelection, SET RequiresChildSelection = :requiresSelection,
MaxNumSelectionReq = :maxSelections MaxNumSelectionReq = :maxSelections
WHERE ItemID = :modID WHERE ID = :modID
", { ", {
modID: modDbId, modID: modDbId,
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
@ -324,7 +324,7 @@ try {
RequiresChildSelection = :requiresSelection, RequiresChildSelection = :requiresSelection,
MaxNumSelectionReq = :maxSelections, MaxNumSelectionReq = :maxSelections,
ParentItemID = :parentID ParentItemID = :parentID
WHERE ItemID = :modID WHERE ID = :modID
", { ", {
modID: modDbId, modID: modDbId,
parentID: itemID, parentID: itemID,

View file

@ -51,7 +51,7 @@
<cfset queryTimed(" <cfset queryTimed("
UPDATE Items UPDATE Items
SET StationID = ? SET StationID = ?
WHERE ItemID = ? WHERE ID = ?
", [ ", [
{ value = assignment.StationID, cfsqltype = "cf_sql_integer" }, { value = assignment.StationID, cfsqltype = "cf_sql_integer" },
{ value = assignment.ItemID, cfsqltype = "cf_sql_integer" } { value = assignment.ItemID, cfsqltype = "cf_sql_integer" }

View file

@ -19,27 +19,20 @@
<div class="app"> <div class="app">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-header"> <!-- Business Card -->
<div class="logo"> <div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200); display: flex; align-items: center; gap: 12px;">
<span class="logo-icon">P</span> <div class="business-avatar" id="businessAvatar">B</div>
<span class="logo-text">Payfrit</span> <div class="business-details" style="flex: 1;">
<div class="business-name" id="businessName">Loading...</div>
<div class="business-status online">Online</div>
</div> </div>
<button class="sidebar-toggle" id="sidebarToggle"> <button class="sidebar-toggle" id="sidebarToggle" style="background: none; border: none; cursor: pointer; padding: 4px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/> <path d="M3 12h18M3 6h18M3 18h18"/>
</svg> </svg>
</button> </button>
</div> </div>
<!-- Business Card -->
<div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200);">
<div class="business-avatar" id="businessAvatar">B</div>
<div class="business-details">
<div class="business-name" id="businessName">Loading...</div>
<div class="business-status online">Online</div>
</div>
</div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="#dashboard" class="nav-item active" data-page="dashboard"> <a href="#dashboard" class="nav-item active" data-page="dashboard">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -76,14 +69,6 @@
</svg> </svg>
<span>Team</span> <span>Team</span>
</a> </a>
<a href="#beacons" class="nav-item" data-page="beacons">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<span>Beacons</span>
</a>
<a href="#services" class="nav-item" data-page="services"> <a href="#services" class="nav-item" data-page="services">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/> <path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>
@ -91,11 +76,13 @@
</svg> </svg>
<span>Services</span> <span>Services</span>
</a> </a>
<a href="#sp-sharing" class="nav-item" data-page="sp-sharing"> <a href="#service-points" class="nav-item" data-page="service-points">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/> <circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg> </svg>
<span>SP Sharing</span> <span>Service Points</span>
</a> </a>
<a href="#admin-tasks" class="nav-item" data-page="admin-tasks"> <a href="#admin-tasks" class="nav-item" data-page="admin-tasks">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -397,23 +384,10 @@
</div> </div>
</section> </section>
<!-- Beacons Page --> <!-- Service Points Page (combined: Service Points, Beacons, Sharing) -->
<section class="page" id="page-beacons"> <section class="page" id="page-service-points">
<div class="beacons-layout"> <div class="service-points-layout">
<!-- Left side: Beacons list --> <!-- Service Points list -->
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Beacons</h3>
<button class="btn btn-sm btn-primary" onclick="Portal.showBeaconModal()">+ Add Beacon</button>
</div>
<div class="card-body">
<div class="list-group" id="beaconsList">
<div class="empty-state">Loading beacons...</div>
</div>
</div>
</div>
<!-- Middle: Service Points list -->
<div class="card"> <div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;"> <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Service Points</h3> <h3>Service Points</h3>
@ -426,7 +400,20 @@
</div> </div>
</div> </div>
<!-- Right side: Assignments --> <!-- Beacons list -->
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Beacons</h3>
<button class="btn btn-sm btn-primary" onclick="Portal.showBeaconModal()">+ Add Beacon</button>
</div>
<div class="card-body">
<div class="list-group" id="beaconsList">
<div class="empty-state">Loading beacons...</div>
</div>
</div>
</div>
<!-- Beacon Assignments -->
<div class="card"> <div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;"> <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>Beacon Assignments</h3> <h3>Beacon Assignments</h3>
@ -438,6 +425,82 @@
</div> </div>
</div> </div>
</div> </div>
<!-- SP Sharing: Grants You've Created -->
<div class="card" id="spSharingOwnerCard">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<h3>Grants You've Created</h3>
<button class="btn btn-primary btn-sm" onclick="Portal.showInviteBusinessModal()">Invite Business</button>
</div>
<div class="card-body" id="ownerGrantsList">
<div class="loading-text">Loading...</div>
</div>
</div>
<!-- SP Sharing: Invites & Active Grants -->
<div class="card" id="spSharingGuestCard">
<div class="card-header">
<h3>Invites &amp; Active Grants</h3>
</div>
<div class="card-body" id="guestGrantsList">
<div class="loading-text">Loading...</div>
</div>
</div>
</div>
<!-- Invite Business Modal -->
<div class="modal-overlay" id="inviteBusinessModal" style="display:none">
<div class="modal" style="max-width:500px">
<div class="modal-header">
<h3>Invite Business to Service Point</h3>
<button class="modal-close" onclick="Portal.closeInviteModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Search Business</label>
<input type="text" id="inviteBizSearch" class="form-input" placeholder="Business name or ID..." oninput="Portal.searchBusinessForInvite()">
<div id="inviteBizResults" style="margin-top:8px"></div>
</div>
<div class="form-group" id="inviteBizSelected" style="display:none">
<label>Selected Business</label>
<div id="inviteBizSelectedName" class="form-input" style="background:var(--bg-secondary)"></div>
</div>
<div class="form-group">
<label>Service Point</label>
<select id="inviteSPSelect" class="form-input"></select>
</div>
<div class="form-group">
<label>Economics</label>
<select id="inviteEconType" class="form-input" onchange="Portal.toggleEconValue()">
<option value="none">None</option>
<option value="flat_fee">Flat Fee ($/order)</option>
<option value="percent_of_orders">Percentage of Orders</option>
</select>
<input type="number" id="inviteEconValue" class="form-input" placeholder="0" step="0.01" min="0" style="display:none;margin-top:4px">
</div>
<div class="form-group">
<label>Eligibility</label>
<select id="inviteEligibility" class="form-input">
<option value="public">Public (anyone)</option>
<option value="employees">Employees only</option>
<option value="guests">Guests only</option>
<option value="internal">Internal (owner's employees)</option>
</select>
</div>
<div class="form-group">
<label>Time Policy</label>
<select id="inviteTimePolicy" class="form-input">
<option value="always">Always</option>
<option value="schedule">Schedule</option>
<option value="date_range">Date Range</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="Portal.closeInviteModal()">Cancel</button>
<button class="btn btn-primary" onclick="Portal.submitGrantInvite()">Send Invite</button>
</div>
</div>
</div> </div>
</section> </section>
@ -519,87 +582,6 @@
</div> </div>
</section> </section>
<!-- SP Sharing Page -->
<section class="page" id="page-sp-sharing">
<div class="sp-sharing-container">
<!-- Owner Section -->
<div class="card" id="spSharingOwnerCard">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<h3>Grants You've Created</h3>
<button class="btn btn-primary btn-sm" onclick="Portal.showInviteBusinessModal()">Invite Business</button>
</div>
<div class="card-body" id="ownerGrantsList">
<div class="loading-text">Loading...</div>
</div>
</div>
<!-- Guest Section -->
<div class="card" id="spSharingGuestCard">
<div class="card-header">
<h3>Invites &amp; Active Grants</h3>
</div>
<div class="card-body" id="guestGrantsList">
<div class="loading-text">Loading...</div>
</div>
</div>
</div>
<!-- Invite Business Modal -->
<div class="modal-overlay" id="inviteBusinessModal" style="display:none">
<div class="modal" style="max-width:500px">
<div class="modal-header">
<h3>Invite Business to Service Point</h3>
<button class="modal-close" onclick="Portal.closeInviteModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Search Business</label>
<input type="text" id="inviteBizSearch" class="form-input" placeholder="Business name or ID..." oninput="Portal.searchBusinessForInvite()">
<div id="inviteBizResults" style="margin-top:8px"></div>
</div>
<div class="form-group" id="inviteBizSelected" style="display:none">
<label>Selected Business</label>
<div id="inviteBizSelectedName" class="form-input" style="background:var(--bg-secondary)"></div>
</div>
<div class="form-group">
<label>Service Point</label>
<select id="inviteSPSelect" class="form-input"></select>
</div>
<div class="form-group">
<label>Economics</label>
<select id="inviteEconType" class="form-input" onchange="Portal.toggleEconValue()">
<option value="none">None</option>
<option value="flat_fee">Flat Fee ($/order)</option>
<option value="percent_of_orders">Percentage of Orders</option>
</select>
<input type="number" id="inviteEconValue" class="form-input" placeholder="0" step="0.01" min="0" style="display:none;margin-top:4px">
</div>
<div class="form-group">
<label>Eligibility</label>
<select id="inviteEligibility" class="form-input">
<option value="public">Public (anyone)</option>
<option value="employees">Employees only</option>
<option value="guests">Guests only</option>
<option value="internal">Internal (owner's employees)</option>
</select>
</div>
<div class="form-group">
<label>Time Policy</label>
<select id="inviteTimePolicy" class="form-input">
<option value="always">Always</option>
<option value="schedule">Schedule</option>
<option value="date_range">Date Range</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="Portal.closeInviteModal()">Cancel</button>
<button class="btn btn-primary" onclick="Portal.submitGrantInvite()">Send Invite</button>
</div>
</div>
</div>
</section>
<!-- Settings Page --> <!-- Settings Page -->
<section class="page" id="page-settings"> <section class="page" id="page-settings">
<div class="settings-grid"> <div class="settings-grid">
@ -713,6 +695,37 @@
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">
<h3>Tabs / Running Checks</h3>
</div>
<div class="card-body">
<p style="color: #666; font-size: 13px; margin-bottom: 16px;">Allow customers to keep a tab open and order multiple rounds before closing out.</p>
<div class="form-group" style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<label class="toggle">
<input type="checkbox" id="tabsEnabled" onchange="Portal.saveTabSettings()">
<span class="toggle-slider"></span>
</label>
<span>Enable Tabs</span>
</div>
<div id="tabSettingsDetails" style="display: none;">
<div class="form-group">
<label>Tab Lock Duration (minutes)</label>
<input type="number" id="tabLockMinutes" class="form-input" value="30" min="5" max="480" style="width: 120px;">
<small style="color:#666;font-size:12px;display:block;margin-top:4px;">How long a tab stays open without activity</small>
</div>
<div class="form-group" style="margin-top: 12px;">
<label>Payment Strategy</label>
<select id="tabPaymentStrategy" class="form-input" style="width: 200px;">
<option value="A">Pay at end (single charge)</option>
<option value="P">Pre-authorize card</option>
</select>
</div>
<button class="btn btn-primary" onclick="Portal.saveTabSettings()" style="margin-top: 12px;">Save Tab Settings</button>
</div>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Customer Preview</h3> <h3>Customer Preview</h3>
@ -749,7 +762,7 @@
<!-- Toast Container --> <!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<script src="portal.js?v=9"></script> <script src="portal.js?v=13"></script>
<!-- Close dropdown when clicking outside --> <!-- Close dropdown when clicking outside -->
<script> <script>

View file

@ -763,20 +763,8 @@
</head> </head>
<body> <body>
<div class="app"> <div class="app">
<!-- Sidebar (same as portal) --> <!-- Sidebar (without Payfrit header) -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="logo">
<span class="logo-icon">P</span>
<span class="logo-text">Payfrit</span>
</div>
<button class="sidebar-toggle" id="sidebarToggle">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
</button>
</div>
<div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200);"> <div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200);">
<div class="business-avatar" id="businessAvatar">B</div> <div class="business-avatar" id="businessAvatar">B</div>
<div class="business-details"> <div class="business-details">
@ -821,14 +809,6 @@
</svg> </svg>
<span>Team</span> <span>Team</span>
</a> </a>
<a href="index.html#beacons" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"/>
<path d="M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<span>Beacons</span>
</a>
<a href="index.html#services" class="nav-item"> <a href="index.html#services" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/> <path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/>

View file

@ -587,7 +587,7 @@ body {
gap: 24px; gap: 24px;
} }
/* Beacons Page Layout */ /* Beacons Page Layout (legacy) */
.beacons-layout { .beacons-layout {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@ -600,6 +600,19 @@ body {
} }
} }
/* Service Points Page Layout */
.service-points-layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
}
@media (max-width: 768px) {
.service-points-layout {
grid-template-columns: 1fr;
}
}
.list-group { .list-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -974,6 +987,14 @@ body {
overflow-y: auto; overflow-y: auto;
} }
.modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--gray-200);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Toast */ /* Toast */
.toast-container { .toast-container {
position: fixed; position: fixed;

View file

@ -215,10 +215,9 @@ const Portal = {
menu: 'Menu Management', menu: 'Menu Management',
reports: 'Reports', reports: 'Reports',
team: 'Team', team: 'Team',
beacons: 'Beacons',
services: 'Service Requests', services: 'Service Requests',
'service-points': 'Service Points',
'admin-tasks': 'Task Admin', 'admin-tasks': 'Task Admin',
'sp-sharing': 'SP Sharing',
settings: 'Settings' settings: 'Settings'
}; };
document.getElementById('pageTitle').textContent = titles[page] || page; document.getElementById('pageTitle').textContent = titles[page] || page;
@ -250,18 +249,15 @@ const Portal = {
case 'team': case 'team':
await this.loadTeam(); await this.loadTeam();
break; break;
case 'beacons':
await this.loadBeaconsPage();
break;
case 'services': case 'services':
await this.loadServicesPage(); await this.loadServicesPage();
break; break;
case 'service-points':
await this.loadServicePointsPage();
break;
case 'admin-tasks': case 'admin-tasks':
await this.loadAdminTasksPage(); await this.loadAdminTasksPage();
break; break;
case 'sp-sharing':
await this.loadSPSharingPage();
break;
case 'settings': case 'settings':
await this.loadSettings(); await this.loadSettings();
break; break;
@ -732,10 +728,10 @@ const Portal = {
this.currentBusiness = biz; this.currentBusiness = biz;
// Populate form fields (Lucee serializes all keys as uppercase) // Populate form fields (Lucee serializes all keys as uppercase)
document.getElementById('settingName').value = biz.BUSINESSNAME || biz.Name || ''; document.getElementById('settingBusinessName').value = biz.BUSINESSNAME || biz.Name || '';
document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.Phone || ''; document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.Phone || '';
document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || ''; document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || '';
document.getElementById('settingLine1').value = biz.ADDRESSLINE1 || biz.Line1 || ''; document.getElementById('settingAddressLine1').value = biz.ADDRESSLINE1 || biz.Line1 || '';
document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.City || ''; document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.City || '';
document.getElementById('settingState').value = biz.ADDRESSSTATE || biz.AddressState || ''; document.getElementById('settingState').value = biz.ADDRESSSTATE || biz.AddressState || '';
document.getElementById('settingZip').value = biz.ADDRESSZIP || biz.AddressZip || ''; document.getElementById('settingZip').value = biz.ADDRESSZIP || biz.AddressZip || '';
@ -757,12 +753,62 @@ const Portal = {
// Render hours editor // Render hours editor
this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.HoursDetail || []); this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.HoursDetail || []);
// Load tab settings
const tabsEnabled = biz.SESSIONENABLED || biz.SessionEnabled || 0;
const tabLockMinutes = biz.SESSIONLOCKMINUTES || biz.SessionLockMinutes || 30;
const tabPaymentStrategy = biz.SESSIONPAYMENTSTRATEGY || biz.SessionPaymentStrategy || 'A';
const tabsCheckbox = document.getElementById('tabsEnabled');
const tabDetails = document.getElementById('tabSettingsDetails');
if (tabsCheckbox) {
tabsCheckbox.checked = tabsEnabled == 1;
if (tabDetails) tabDetails.style.display = tabsEnabled == 1 ? 'block' : 'none';
}
const lockInput = document.getElementById('tabLockMinutes');
if (lockInput) lockInput.value = tabLockMinutes;
const strategySelect = document.getElementById('tabPaymentStrategy');
if (strategySelect) strategySelect.value = tabPaymentStrategy;
} }
} catch (err) { } catch (err) {
console.error('[Portal] Error loading business info:', err); console.error('[Portal] Error loading business info:', err);
} }
}, },
// Save tab settings
async saveTabSettings() {
const tabsEnabled = document.getElementById('tabsEnabled').checked ? 1 : 0;
const tabLockMinutes = parseInt(document.getElementById('tabLockMinutes').value) || 30;
const tabPaymentStrategy = document.getElementById('tabPaymentStrategy').value || 'A';
// Show/hide details based on toggle
const tabDetails = document.getElementById('tabSettingsDetails');
if (tabDetails) tabDetails.style.display = tabsEnabled ? 'block' : 'none';
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateTabs.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
SessionEnabled: tabsEnabled,
SessionLockMinutes: tabLockMinutes,
SessionPaymentStrategy: tabPaymentStrategy
})
});
const data = await response.json();
if (data.OK) {
this.showToast('Tab settings saved!', 'success');
} else {
this.showToast(data.ERROR || 'Failed to save tab settings', 'error');
}
} catch (err) {
console.error('[Portal] Error saving tab settings:', err);
this.showToast('Error saving tab settings', 'error');
}
},
// Render hours editor // Render hours editor
renderHoursEditor(hours) { renderHoursEditor(hours) {
const container = document.getElementById('hoursEditor'); const container = document.getElementById('hoursEditor');
@ -845,10 +891,10 @@ const Portal = {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
BusinessID: this.config.businessId, BusinessID: this.config.businessId,
Name: document.getElementById('settingName').value, Name: document.getElementById('settingBusinessName').value,
Phone: document.getElementById('settingPhone').value, Phone: document.getElementById('settingPhone').value,
TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0, TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0,
Line1: document.getElementById('settingLine1').value, Line1: document.getElementById('settingAddressLine1').value,
City: document.getElementById('settingCity').value, City: document.getElementById('settingCity').value,
State: document.getElementById('settingState').value, State: document.getElementById('settingState').value,
Zip: document.getElementById('settingZip').value Zip: document.getElementById('settingZip').value
@ -1540,14 +1586,20 @@ const Portal = {
assignments: [], assignments: [],
// Load beacons page data // Load beacons page data
async loadBeaconsPage() { async loadServicePointsPage() {
await Promise.all([ await Promise.all([
this.loadBeacons(),
this.loadServicePoints(), this.loadServicePoints(),
this.loadAssignments() this.loadBeacons(),
this.loadAssignments(),
this.loadSPSharingPage()
]); ]);
}, },
// Legacy alias
async loadBeaconsPage() {
await this.loadServicePointsPage();
},
// Load beacons list // Load beacons list
async loadBeacons() { async loadBeacons() {
const container = document.getElementById('beaconsList'); const container = document.getElementById('beaconsList');
@ -1681,11 +1733,11 @@ const Portal = {
container.innerHTML = this.assignments.map(a => ` container.innerHTML = this.assignments.map(a => `
<div class="list-group-item"> <div class="list-group-item">
<div class="item-info"> <div class="item-info">
<div class="item-name">${this.escapeHtml(a.Name)} ${this.escapeHtml(a.Name)}</div> <div class="item-name">${this.escapeHtml(a.BeaconName)} ${this.escapeHtml(a.ServicePointName)}</div>
<div class="item-detail">${a.lt_Beacon_Businesses_ServicePointNotes || ''}</div> <div class="item-detail">UUID: ${a.UUID || ''}</div>
</div> </div>
<div class="item-actions"> <div class="item-actions">
<button class="btn btn-sm btn-danger" onclick="Portal.deleteAssignment(${a.lt_Beacon_Businesses_ServicePointID})">Remove</button> <button class="btn btn-sm btn-danger" onclick="Portal.deleteAssignment(${a.ServicePointID})">Remove</button>
</div> </div>
</div> </div>
`).join(''); `).join('');
@ -1988,7 +2040,7 @@ const Portal = {
'X-User-Token': this.config.token, 'X-User-Token': this.config.token,
'X-Business-ID': this.config.businessId 'X-Business-ID': this.config.businessId
}, },
body: JSON.stringify({ lt_Beacon_Businesses_ServicePointID: assignmentId }) body: JSON.stringify({ ServicePointID: assignmentId })
}); });
const data = await response.json(); const data = await response.json();