diff --git a/Makefile b/Makefile
index e734abdb..eebde364 100644
--- a/Makefile
+++ b/Makefile
@@ -145,6 +145,23 @@ admin-build:
@node scripts/patch-admin-layout-for-env-label.js
@cd admin-web && VITE_APP_ENV=$(ENV) npm run build
+# --- API Test Harness ---
+harness-install:
+ @echo "--> Installing API Test Harness dependencies..."
+ @cd internal-api-harness && npm install
+
+harness-dev:
+ @echo "--> Starting API Test Harness development server on http://localhost:5175 ..."
+ @cd internal-api-harness && npm run dev -- --port 5175
+
+harness-build:
+ @echo "--> Building API Test Harness for production..."
+ @cd internal-api-harness && npm run build -- --mode $(ENV)
+
+harness-deploy: harness-build
+ @echo "--> Deploying API Test Harness to [$(ENV)] environment..."
+ @firebase deploy --only hosting:api-harness-$(ENV) --project=$(FIREBASE_ALIAS)
+
deploy-admin: admin-build
@echo "--> Building and deploying Admin Console to Cloud Run [$(ENV)]..."
@echo " - Step 1: Building container image..."
diff --git a/firebase.json b/firebase.json
index 360ffbe7..8900d6b8 100644
--- a/firebase.json
+++ b/firebase.json
@@ -44,6 +44,38 @@
"destination": "/index.html"
}
]
+ },
+ {
+ "target": "api-harness-dev",
+ "public": "internal-api-harness/dist",
+ "site": "krow-api-harness-dev",
+ "ignore": [
+ "firebase.json",
+ "**/.*",
+ "**/node_modules/**"
+ ],
+ "rewrites": [
+ {
+ "source": "**",
+ "destination": "/index.html"
+ }
+ ]
+ },
+ {
+ "target": "api-harness-staging",
+ "public": "internal-api-harness/dist",
+ "site": "krow-api-harness-staging",
+ "ignore": [
+ "firebase.json",
+ "**/.*",
+ "**/node_modules/**"
+ ],
+ "rewrites": [
+ {
+ "source": "**",
+ "destination": "/index.html"
+ }
+ ]
}
],
"emulators": {
diff --git a/firebase/internal-launchpad/index.html b/firebase/internal-launchpad/index.html
index 2e3b73f0..9b8e22a3 100644
--- a/firebase/internal-launchpad/index.html
+++ b/firebase/internal-launchpad/index.html
@@ -93,6 +93,35 @@
#diagram-container:active {
cursor: grabbing;
}
+
+ /* Accordion styles */
+ .accordion-button {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 0.75rem 1rem;
+ font-size: 0.875rem;
+ color: #4b5563; /* text-gray-600 */
+ text-align: left;
+ background-color: #f9fafb; /* bg-gray-50 */
+ border-radius: 0.5rem; /* rounded-lg */
+ transition: background-color 0.2s ease;
+ }
+ .accordion-button:hover {
+ background-color: #f3f4f6; /* bg-gray-100 */
+ }
+ .accordion-button .chevron {
+ transition: transform 0.2s ease;
+ }
+ .accordion-button[aria-expanded="true"] .chevron {
+ transform: rotate(90deg);
+ }
+ .accordion-panel {
+ overflow: hidden;
+ transition: max-height 0.3s ease-out;
+ max-height: 0;
+ }
/* Modal styles */
.modal-overlay {
@@ -566,150 +595,96 @@
});
// Build hierarchical structure from paths
- function buildDiagramHierarchy(diagrams) {
- const hierarchy = {};
+ function buildHierarchy(items, pathPrefix) {
+ const hierarchy = { _root: { _items: [], _children: {} } };
- diagrams.forEach(diagram => {
- const parts = diagram.path.split('/');
- const relevantParts = parts.slice(2, -1); // Remove 'assets/diagrams/' and filename
-
- let current = hierarchy;
- relevantParts.forEach(part => {
- if (!current[part]) {
- current[part] = { _items: [], _children: {} };
- }
- current = current[part]._children;
- });
-
- // Add the item to appropriate level
- if (relevantParts.length > 0) {
- let parent = hierarchy[relevantParts[0]];
- for (let i = 1; i < relevantParts.length; i++) {
- parent = parent._children[relevantParts[i]];
- }
- parent._items.push(diagram);
- } else {
- // Root level diagrams
- if (!hierarchy._root) {
- hierarchy._root = { _items: [], _children: {} };
- }
- hierarchy._root._items.push(diagram);
+ items.forEach(item => {
+ let relativePath = item.path;
+ if (relativePath.startsWith('./')) {
+ relativePath = relativePath.substring(2);
}
+ if (relativePath.startsWith(pathPrefix)) {
+ relativePath = relativePath.substring(pathPrefix.length);
+ }
+
+ const parts = relativePath.split('/');
+ const relevantParts = parts.slice(0, -1); // remove filename
+
+ let current = hierarchy._root;
+ relevantParts.forEach(part => {
+ if (!current._children[part]) {
+ current._children[part] = { _items: [], _children: {} };
+ }
+ current = current._children[part];
+ });
+ current._items.push(item);
});
-
return hierarchy;
}
- // Build hierarchical structure from paths (for documents)
- function buildDocumentHierarchy(documents) {
- const hierarchy = {};
-
- documents.forEach(doc => {
- const parts = doc.path.split('/');
- const relevantParts = parts.slice(2, -1); // Remove 'assets/documents/' and filename
-
- let current = hierarchy;
- relevantParts.forEach(part => {
- if (!current[part]) {
- current[part] = { _items: [], _children: {} };
- }
- current = current[part]._children;
- });
-
- // Add the item to appropriate level
- if (relevantParts.length > 0) {
- let parent = hierarchy[relevantParts[0]];
- for (let i = 1; i < relevantParts.length; i++) {
- parent = parent._children[relevantParts[i]];
- }
- parent._items.push(doc);
- } else {
- // Root level documents
- if (!hierarchy._root) {
- hierarchy._root = { _items: [], _children: {} };
- }
- hierarchy._root._items.push(doc);
- }
- });
-
- return hierarchy;
- }
+ // Generic function to create accordion navigation
+ function createAccordionNavigation(hierarchy, parentElement, createLinkFunction, sectionTitle) {
+ const createAccordion = (title, items, children) => {
+ const container = document.createElement('div');
+ container.className = 'mb-1';
- // Create navigation from hierarchy
- function createNavigation(hierarchy, parentElement, level = 0) {
- // First, show root level items if any
- if (hierarchy._root && hierarchy._root._items.length > 0) {
- const mainHeading = document.createElement('div');
- mainHeading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
- mainHeading.textContent = 'Diagrams';
- parentElement.appendChild(mainHeading);
+ const button = document.createElement('button');
+ button.className = 'accordion-button';
+ button.setAttribute('aria-expanded', 'false');
+ button.innerHTML = `
+ ${title.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
+
+ `;
+
+ const panel = document.createElement('div');
+ panel.className = 'accordion-panel pl-4 pt-1';
- hierarchy._root._items.forEach(diagram => {
- createDiagramLink(diagram, parentElement, 0);
- });
- }
-
- // Then process nested categories
- Object.keys(hierarchy).forEach(key => {
- if (key === '_items' || key === '_children' || key === '_root') return;
-
- const section = hierarchy[key];
- const heading = document.createElement('div');
- heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider ' +
- (level === 0 ? 'mt-6 mb-3' : 'mt-4 mb-2 pl-8');
- heading.textContent = key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
- parentElement.appendChild(heading);
-
- // Add items in this section
- if (section._items && section._items.length > 0) {
- section._items.forEach(diagram => {
- createDiagramLink(diagram, parentElement, level);
+ if (items) {
+ items.forEach(item => createLinkFunction(item, panel, 1));
+ }
+
+ if (children) {
+ Object.keys(children).forEach(childKey => {
+ const childSection = children[childKey];
+ const childHeading = document.createElement('div');
+ childHeading.className = 'px-4 pt-2 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider';
+ childHeading.textContent = childKey.replace(/-/g, ' ');
+ panel.appendChild(childHeading);
+ childSection._items.forEach(item => createLinkFunction(item, panel, 2));
});
}
-
- // Recursively add children
- if (section._children && Object.keys(section._children).length > 0) {
- createNavigation(section._children, parentElement, level + 1);
- }
- });
- }
- // Create document navigation from hierarchy
- function createDocumentNavigation(hierarchy, parentElement, level = 0) {
- // First, show root level items if any
- if (hierarchy._root && hierarchy._root._items.length > 0) {
- const mainHeading = document.createElement('div');
- mainHeading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
- mainHeading.textContent = 'Documentation';
- parentElement.appendChild(mainHeading);
-
- hierarchy._root._items.forEach(doc => {
- createDocumentLink(doc, parentElement, 0);
+ button.addEventListener('click', () => {
+ const isExpanded = button.getAttribute('aria-expanded') === 'true';
+ button.setAttribute('aria-expanded', !isExpanded);
+ if (!isExpanded) {
+ panel.style.maxHeight = panel.scrollHeight + 'px';
+ } else {
+ panel.style.maxHeight = '0px';
+ }
});
+
+ container.appendChild(button);
+ container.appendChild(panel);
+ return container;
+ };
+
+ const heading = document.createElement('div');
+ heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
+ heading.textContent = sectionTitle;
+ parentElement.appendChild(heading);
+
+ // Process root items first
+ if (hierarchy._root && hierarchy._root._items.length > 0) {
+ hierarchy._root._items.forEach(item => createLinkFunction(item, parentElement, 0));
}
-
- // Then process nested categories
- Object.keys(hierarchy).forEach(key => {
- if (key === '_items' || key === '_children' || key === '_root') return;
-
- const section = hierarchy[key];
- const heading = document.createElement('div');
- heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider ' +
- (level === 0 ? 'mt-6 mb-3' : 'mt-4 mb-2 pl-8');
- heading.textContent = key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
- parentElement.appendChild(heading);
-
- // Add items in this section
- if (section._items && section._items.length > 0) {
- section._items.forEach(doc => {
- createDocumentLink(doc, parentElement, level);
- });
- }
-
- // Recursively add children
- if (section._children && Object.keys(section._children).length > 0) {
- createDocumentNavigation(section._children, parentElement, level + 1);
- }
+
+ // Process categories as accordions
+ Object.keys(hierarchy._root._children).forEach(key => {
+ if (key.startsWith('_')) return;
+ const section = hierarchy._root._children[key];
+ const accordion = createAccordion(key, section._items, section._children);
+ parentElement.appendChild(accordion);
});
}
@@ -717,8 +692,8 @@
function createDocumentLink(doc, parentElement, level) {
const link = document.createElement('a');
link.href = '#';
- link.className = 'nav-item flex items-center space-x-3 px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 mb-1' +
- (level > 0 ? ' pl-8' : '');
+ link.className = 'nav-item flex items-center space-x-3 px-4 py-2.5 rounded-lg text-gray-600 hover:bg-gray-100 text-sm mb-1' +
+ (level > 0 ? ' ' : '');
link.onclick = (e) => {
e.preventDefault();
showView('document', link, doc.path, doc.title);
@@ -728,7 +703,7 @@
{staff.staff_name}
++ ✓ Rates will be automatically applied from {formData.vendor_name} +
+ )} +Managers:
-{staff.staff_name}
-{staff.position || "john@email.com"}
-Location:
-{shift.location || "848 East Glen Road New York CA, USA"}
++ Assigned Staff +
+Uniform
+{role.uniform}
+Rate
+${role.cost_per_hour}/hr
++ {isRapid ? 'RAPID ORDER ROUTING' : 'Order Routing'} +
++ {routingMode === 'multi' + ? `Sending to ${selectedVendors.length} vendors` + : 'Default vendor routing'} +
++ No vendor selected. Please choose a vendor. +
++ {vendor.doing_business_as || vendor.legal_name} +
+ {isPreferred && ( ++ Multi-Vendor Mode: Order sent to all selected vendors. + First to confirm gets the job. +
++ RAPID Priority: This order will be marked urgent with priority notification. +
+Review your information before completing onboarding
+Name
+{profile.full_name}
+{profile.email}
+Position
+{profile.position}
+Location
+{profile.city}
+No documents uploaded
+ )} ++ {training.completed.length} training modules completed +
+ {training.acknowledged && ( +Upload required documents for compliance
+{doc.description}
+ + {uploadedDoc && ( +Complete required training modules to ensure readiness
+{module.duration} · {module.description}
+Emergency staffing in minutes
+Just describe what you need, I'll handle the rest
++ {msg.content} +
+ + {msg.showConfirm && detectedOrder && ( +Staff Needed
+{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}
+Location
+{detectedOrder.location}
+Time
+{detectedOrder.start_time} → {detectedOrder.end_time}
+{assignment.role}
+Date
++ {event.date ? format(new Date(event.date), "MMM d, yyyy") : "TBD"} +
+Time
++ {assignment.scheduled_start ? format(new Date(assignment.scheduled_start), "h:mm a") : "ASAP"} +
+Location
+{event.event_location || event.hub}
+Attire: {event.shifts[0].uniform_type}
+ )} + {event.addons?.meal_provided && ( +Meal: Provided
+ )} + {event.notes && ( +Notes: {event.notes}
+ )} +Track client engagement and satisfaction metrics
+Total Clients
+{totalClients}
+Avg Satisfaction
+{avgSatisfaction}/5
+Repeat Rate
+{repeatRate}%
+{client.name}
+{client.bookings} bookings
+Create custom reports with selected fields and filters
+{reportConfig.name}
+Track process improvements and automation effectiveness
+Automation Rate
+{automationRate}%
+Avg Time to Fill
+{avgTimeToFill}h
+Response Time
+{avgResponseTime}h
+Completed
+{events.filter(e => e.status === 'Completed').length}
+Manual Work Reduction
+85%
+First-Time Fill Rate
+92%
+Staff Utilization
+88%
+Conflict Detection
+97%
+Reliability, fill rates, and performance tracking
+Avg Reliability
+{avgReliability.toFixed(1)}%
+Avg Fill Rate
+{avgFillRate.toFixed(1)}%
+Total Cancellations
+{totalCancellations}
+Track spending and budget compliance
+Total Spent
+${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+Budget
+${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+Budget Adherence
+{adherence}%
++ Buffer required: {conflict.buffer_required} +
+ )} +{localStaff.length} unassigned
+{s.employee_name}
+{s.position}
+All staff assigned
+ )} +{s.staff_name}
+{s.role}
++ Drag staff here to assign +
+ )} +{staff.position}
+ {staff.position_2 && ( +Also: {staff.position_2}
+ )} +Rating
+{staff.rating || 4.5} ★
+Reliability
++ {staff.reliability_score || 90}% +
+Certifications
+On-Time
++ {staff.shift_coverage_percentage || 95}% +
+No-Shows
++ {staff.no_show_count || 0} +
+Cancels
++ {staff.cancellation_count || 0} +
+Background check pending
++ Pick your go-to vendor for faster ordering and consistent service +
+ +Preferred Vendor
++ {preferredVendor.doing_business_as || preferredVendor.legal_name} +
+All orders route here by default
++ {preferredVendor.workforce_count || 0} +
+Staff Available
+4.9
+Rating
+98%
+Fill Rate
++ Priority Support: Faster response times and dedicated account management +
+Your staffing operations at a glance
+Create New
+Order Now
+ORDER TYPE
+ {rapidOrders.length > 0 ? ( +RAPID
+Click to order
- {format(new Date(), 'EEEE, MMMM d, yyyy')} -
-Today's Orders
+{todayOrders.length}
+In Progress
+{inProgressOrders.length}
+Needs Attention
+{needsAttention.length}
+This month breakdown
-| Position | -Headcount | -Hours | -Total Cost | -Avg/Hour | -
|---|---|---|---|---|
|
-
-
- {item.position}
-
- |
-
- |
- - - {Math.round(item.hours)}h - - | -- - ${Math.round(item.cost).toLocaleString()} - - | -- - ${Math.round(item.cost / item.hours)}/hr - - | -
| - No labor data for this month - | -||||
| TOTAL | -{totalHeadcount} | -{Math.round(totalHours)}h | -- ${Math.round(totalLaborCost).toLocaleString()} - | -- ${Math.round(avgCostPerHour)}/hr - | -
This Month
-- ${Math.round(totalLaborCost / 1000)}k -
-Avg Cost per Hour
-- ${Math.round(avgCostPerHour)} -
-- Across {totalHours.toFixed(0)} hours -
-Total Staff Hired
-- {totalHeadcount} -
-- This month -
-+ {format(new Date(), 'EEEE, MMMM d, yyyy')} +
| BUSINESS | +HUB | +EVENT NAME | +STATUS | +DATE | +TIME | +REQUESTED | +ASSIGNED | +INVOICE | +ACTIONS | +
|---|---|---|---|---|---|---|---|---|---|
| + + {order.business_name || "Primary Location"} + + | ++ + {order.hub || order.event_location || "Main Hub"} + + | ++ + {order.event_name} + + | ++ {getOrderStatusBadge(order)} + | ++ + {order.date ? format(new Date(order.date), 'MM/dd/yy') : "—"} + + | +
+
+
+ |
+ + + {requestedCount} + + | ++ + {assignedCount} + + | ++ — + | +
+
+
+
+
+
+ |
+
One tap to reorder
+- {event.hub || 'No location'} +
+ Stay ahead of your operations — create a new order or schedule one for today to keep your workforce running smoothly +
+ + {/* Action Buttons */} +Smart Insights
+ ++ {pendingInvoices.length} invoice{pendingInvoices.length !== 1 ? 's' : ''} requiring attention
No previous orders
-Your favorites will appear here
-Next 7 days
+ )} + + {/* Order Health Report */} ++ Your fulfillment performance this month +
+No upcoming orders
-Find Vendors
-Browse marketplace
-Messages
-Chat with vendors
-Detailed breakdown for November 2025
Total Amount
++ ${totalLaborCost.toFixed(2)} +
+Total Hours
++ {totalHours.toFixed(2)} hours +
+| Job Title | +Reg Hrs | +OT Hrs | +DT Hrs | +Reg Amt | +OT Amt | +DT Amt | +Total Pay | +
|---|---|---|---|---|---|---|---|
| {item.position} | +{item.regHours.toFixed(1)} | +{item.otHours.toFixed(1)} | +{item.dtHours.toFixed(1)} | +${item.regAmt.toFixed(0)} | +${item.otAmt.toFixed(0)} | +${item.dtAmt.toFixed(0)} | +${item.totalPay.toFixed(0)} | +
| + No labor data for this month + | +|||||||
Find Vendors
+Browse marketplace
+View and manage your event orders
+View and manage all your orders
+TOTAL
+{clientEvents.length}
+ACTIVE
+{activeOrders}
+COMPLETED
+{completedOrders}
+TOTAL SPENT
+${Math.round(totalSpent / 1000)}k
+Total Orders
-{stats.total}
-- {useAI ? "Use AI to create your order naturally" : "Fill out the form to create your order"} + Fill out the details for your planned event
- The form has been pre-filled with information from your conversation. Review and edit as needed. -
++ This event has {pendingEvent.detected_conflicts.length} potential conflict{pendingEvent.detected_conflicts.length !== 1 ? 's' : ''} + with existing bookings. Review the conflicts below and decide how to proceed. +
+Orders scheduled for today only
+No orders scheduled for today
+Event not found
+ + + +