Merge pull request #53 from Oloodi/48-devtool-create-the-api-test-harness-application
48 devtool create the api test harness application
This commit is contained in:
17
Makefile
17
Makefile
@@ -145,6 +145,23 @@ admin-build:
|
|||||||
@node scripts/patch-admin-layout-for-env-label.js
|
@node scripts/patch-admin-layout-for-env-label.js
|
||||||
@cd admin-web && VITE_APP_ENV=$(ENV) npm run build
|
@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
|
deploy-admin: admin-build
|
||||||
@echo "--> Building and deploying Admin Console to Cloud Run [$(ENV)]..."
|
@echo "--> Building and deploying Admin Console to Cloud Run [$(ENV)]..."
|
||||||
@echo " - Step 1: Building container image..."
|
@echo " - Step 1: Building container image..."
|
||||||
|
|||||||
@@ -44,6 +44,38 @@
|
|||||||
"destination": "/index.html"
|
"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": {
|
"emulators": {
|
||||||
|
|||||||
@@ -94,6 +94,35 @@
|
|||||||
cursor: grabbing;
|
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 styles */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
@@ -566,150 +595,96 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build hierarchical structure from paths
|
// Build hierarchical structure from paths
|
||||||
function buildDiagramHierarchy(diagrams) {
|
function buildHierarchy(items, pathPrefix) {
|
||||||
const hierarchy = {};
|
const hierarchy = { _root: { _items: [], _children: {} } };
|
||||||
|
|
||||||
diagrams.forEach(diagram => {
|
items.forEach(item => {
|
||||||
const parts = diagram.path.split('/');
|
let relativePath = item.path;
|
||||||
const relevantParts = parts.slice(2, -1); // Remove 'assets/diagrams/' and filename
|
if (relativePath.startsWith('./')) {
|
||||||
|
relativePath = relativePath.substring(2);
|
||||||
|
}
|
||||||
|
if (relativePath.startsWith(pathPrefix)) {
|
||||||
|
relativePath = relativePath.substring(pathPrefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
let current = hierarchy;
|
const parts = relativePath.split('/');
|
||||||
|
const relevantParts = parts.slice(0, -1); // remove filename
|
||||||
|
|
||||||
|
let current = hierarchy._root;
|
||||||
relevantParts.forEach(part => {
|
relevantParts.forEach(part => {
|
||||||
if (!current[part]) {
|
if (!current._children[part]) {
|
||||||
current[part] = { _items: [], _children: {} };
|
current._children[part] = { _items: [], _children: {} };
|
||||||
}
|
}
|
||||||
current = current[part]._children;
|
current = current._children[part];
|
||||||
});
|
});
|
||||||
|
current._items.push(item);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return hierarchy;
|
return hierarchy;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build hierarchical structure from paths (for documents)
|
// Generic function to create accordion navigation
|
||||||
function buildDocumentHierarchy(documents) {
|
function createAccordionNavigation(hierarchy, parentElement, createLinkFunction, sectionTitle) {
|
||||||
const hierarchy = {};
|
const createAccordion = (title, items, children) => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'mb-1';
|
||||||
|
|
||||||
documents.forEach(doc => {
|
const button = document.createElement('button');
|
||||||
const parts = doc.path.split('/');
|
button.className = 'accordion-button';
|
||||||
const relevantParts = parts.slice(2, -1); // Remove 'assets/documents/' and filename
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
button.innerHTML = `
|
||||||
|
<span class="font-medium">${title.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</span>
|
||||||
|
<svg class="chevron w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||||
|
`;
|
||||||
|
|
||||||
let current = hierarchy;
|
const panel = document.createElement('div');
|
||||||
relevantParts.forEach(part => {
|
panel.className = 'accordion-panel pl-4 pt-1';
|
||||||
if (!current[part]) {
|
|
||||||
current[part] = { _items: [], _children: {} };
|
if (items) {
|
||||||
|
items.forEach(item => createLinkFunction(item, panel, 1));
|
||||||
}
|
}
|
||||||
current = current[part]._children;
|
|
||||||
|
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));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const isExpanded = button.getAttribute('aria-expanded') === 'true';
|
||||||
|
button.setAttribute('aria-expanded', !isExpanded);
|
||||||
|
if (!isExpanded) {
|
||||||
|
panel.style.maxHeight = panel.scrollHeight + 'px';
|
||||||
} else {
|
} else {
|
||||||
// Root level documents
|
panel.style.maxHeight = '0px';
|
||||||
if (!hierarchy._root) {
|
|
||||||
hierarchy._root = { _items: [], _children: {} };
|
|
||||||
}
|
|
||||||
hierarchy._root._items.push(doc);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return hierarchy;
|
container.appendChild(button);
|
||||||
}
|
container.appendChild(panel);
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
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');
|
const heading = document.createElement('div');
|
||||||
heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider ' +
|
heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3';
|
||||||
(level === 0 ? 'mt-6 mb-3' : 'mt-4 mb-2 pl-8');
|
heading.textContent = sectionTitle;
|
||||||
heading.textContent = key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
||||||
parentElement.appendChild(heading);
|
parentElement.appendChild(heading);
|
||||||
|
|
||||||
// Add items in this section
|
// Process root items first
|
||||||
if (section._items && section._items.length > 0) {
|
|
||||||
section._items.forEach(diagram => {
|
|
||||||
createDiagramLink(diagram, parentElement, level);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
if (hierarchy._root && hierarchy._root._items.length > 0) {
|
||||||
const mainHeading = document.createElement('div');
|
hierarchy._root._items.forEach(item => createLinkFunction(item, parentElement, 0));
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then process nested categories
|
// Process categories as accordions
|
||||||
Object.keys(hierarchy).forEach(key => {
|
Object.keys(hierarchy._root._children).forEach(key => {
|
||||||
if (key === '_items' || key === '_children' || key === '_root') return;
|
if (key.startsWith('_')) return;
|
||||||
|
const section = hierarchy._root._children[key];
|
||||||
const section = hierarchy[key];
|
const accordion = createAccordion(key, section._items, section._children);
|
||||||
const heading = document.createElement('div');
|
parentElement.appendChild(accordion);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,8 +692,8 @@
|
|||||||
function createDocumentLink(doc, parentElement, level) {
|
function createDocumentLink(doc, parentElement, level) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = '#';
|
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' +
|
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 ? ' pl-8' : '');
|
(level > 0 ? ' ' : '');
|
||||||
link.onclick = (e) => {
|
link.onclick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showView('document', link, doc.path, doc.title);
|
showView('document', link, doc.path, doc.title);
|
||||||
@@ -728,7 +703,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
link.innerHTML = `${iconSvg}<span class="text-sm">${doc.title}</span>`;
|
link.innerHTML = `${iconSvg}<span class="truncate">${doc.title}</span>`;
|
||||||
parentElement.appendChild(link);
|
parentElement.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,26 +711,18 @@
|
|||||||
function createDiagramLink(diagram, parentElement, level) {
|
function createDiagramLink(diagram, parentElement, level) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = '#';
|
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' +
|
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 ? ' pl-8' : '');
|
(level > 0 ? ' ' : '');
|
||||||
link.onclick = (e) => {
|
link.onclick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showView('diagram', link, diagram.path, diagram.title, diagram.type);
|
showView('diagram', link, diagram.path, diagram.title, diagram.type);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get icon based on type or custom icon
|
const iconSvg = `<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
let iconSvg = '';
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4l2 2h4a2 2 0 012 2v12a2 2 0 01-2 2h-4l-2-2H7z"></path>
|
||||||
if (diagram.type === 'svg') {
|
|
||||||
iconSvg = `<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
|
||||||
</svg>`;
|
</svg>`;
|
||||||
} else {
|
|
||||||
iconSvg = `<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
link.innerHTML = iconSvg + '<span class="text-sm">' + diagram.title + '</span>';
|
link.innerHTML = `${iconSvg}<span class="truncate">${diagram.title}</span>`;
|
||||||
parentElement.appendChild(link);
|
parentElement.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,12 +737,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
console.log('Loaded config:', text);
|
|
||||||
allDiagrams = JSON.parse(text);
|
allDiagrams = JSON.parse(text);
|
||||||
|
|
||||||
if (allDiagrams && allDiagrams.length > 0) {
|
if (allDiagrams && allDiagrams.length > 0) {
|
||||||
const hierarchy = buildDiagramHierarchy(allDiagrams);
|
const hierarchy = buildHierarchy(allDiagrams, 'assets/diagrams/');
|
||||||
createNavigation(hierarchy, dynamicSection);
|
createAccordionNavigation(hierarchy, dynamicSection, createDiagramLink, 'Diagrams');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading diagrams configuration:', error);
|
console.error('Error loading diagrams configuration:', error);
|
||||||
@@ -801,12 +767,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
console.log('Loaded documents config:', text);
|
|
||||||
allDocuments = JSON.parse(text);
|
allDocuments = JSON.parse(text);
|
||||||
|
|
||||||
if (allDocuments && allDocuments.length > 0) {
|
if (allDocuments && allDocuments.length > 0) {
|
||||||
const hierarchy = buildDocumentHierarchy(allDocuments);
|
const hierarchy = buildHierarchy(allDocuments, 'assets/documents/');
|
||||||
createDocumentNavigation(hierarchy, documentationSection);
|
createAccordionNavigation(hierarchy, documentationSection, createDocumentLink, 'Documentation');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading documents configuration:', error);
|
console.error('Error loading documents configuration:', error);
|
||||||
|
|||||||
68
frontend-web/package-lock.json
generated
68
frontend-web/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base44/sdk": "^0.1.2",
|
"@base44/sdk": "^0.1.2",
|
||||||
"@dataconnect/generated": "file:src/dataconnect-generated",
|
"@dataconnect/generated": "file:src/dataconnect-generated",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hookform/resolvers": "^4.1.2",
|
"@hookform/resolvers": "^4.1.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
@@ -1740,6 +1741,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hello-pangea/dnd": {
|
||||||
|
"version": "18.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz",
|
||||||
|
"integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.26.7",
|
||||||
|
"css-box-model": "^1.2.1",
|
||||||
|
"raf-schd": "^4.0.3",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"redux": "^5.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz",
|
||||||
@@ -4491,6 +4509,12 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -5274,6 +5298,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-box-model": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tiny-invariant": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -8375,6 +8408,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/raf-schd": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -8436,6 +8475,29 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -8653,6 +8715,12 @@
|
|||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base44/sdk": "^0.1.2",
|
"@base44/sdk": "^0.1.2",
|
||||||
"@dataconnect/generated": "file:src/dataconnect-generated",
|
"@dataconnect/generated": "file:src/dataconnect-generated",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hookform/resolvers": "^4.1.2",
|
"@hookform/resolvers": "^4.1.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export const TeamMemberInvite = base44.entities.TeamMemberInvite;
|
|||||||
|
|
||||||
export const VendorDocumentReview = base44.entities.VendorDocumentReview;
|
export const VendorDocumentReview = base44.entities.VendorDocumentReview;
|
||||||
|
|
||||||
|
export const Task = base44.entities.Task;
|
||||||
|
|
||||||
|
export const TaskComment = base44.entities.TaskComment;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// auth sdk:
|
// auth sdk:
|
||||||
|
|||||||
396
frontend-web/src/components/dashboard/DashboardCustomizer.jsx
Normal file
396
frontend-web/src/components/dashboard/DashboardCustomizer.jsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
GripVertical,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Info,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Sparkles
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
export default function DashboardCustomizer({
|
||||||
|
user,
|
||||||
|
availableWidgets = [],
|
||||||
|
currentLayout = [],
|
||||||
|
onLayoutChange,
|
||||||
|
dashboardType = "default" // admin, client, vendor, operator, etc
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [showHowItWorks, setShowHowItWorks] = useState(false);
|
||||||
|
const [visibleWidgets, setVisibleWidgets] = useState([]);
|
||||||
|
const [hiddenWidgets, setHiddenWidgets] = useState([]);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Initialize widgets from user's saved layout or defaults
|
||||||
|
useEffect(() => {
|
||||||
|
const layoutKey = `dashboard_layout_${dashboardType}`;
|
||||||
|
const savedLayout = user?.[layoutKey];
|
||||||
|
|
||||||
|
if (savedLayout?.widgets && savedLayout.widgets.length > 0) {
|
||||||
|
const savedVisible = savedLayout.widgets
|
||||||
|
.map(id => availableWidgets.find(w => w.id === id))
|
||||||
|
.filter(Boolean);
|
||||||
|
setVisibleWidgets(savedVisible);
|
||||||
|
|
||||||
|
const savedHidden = savedLayout.hidden_widgets || [];
|
||||||
|
const hiddenWidgetsList = availableWidgets.filter(w =>
|
||||||
|
savedHidden.includes(w.id)
|
||||||
|
);
|
||||||
|
setHiddenWidgets(hiddenWidgetsList);
|
||||||
|
} else {
|
||||||
|
// Default: all widgets visible in provided order
|
||||||
|
setVisibleWidgets(availableWidgets);
|
||||||
|
setHiddenWidgets([]);
|
||||||
|
}
|
||||||
|
}, [user, availableWidgets, isOpen, dashboardType]);
|
||||||
|
|
||||||
|
// Save layout mutation
|
||||||
|
const saveLayoutMutation = useMutation({
|
||||||
|
mutationFn: async (layoutData) => {
|
||||||
|
const layoutKey = `dashboard_layout_${dashboardType}`;
|
||||||
|
await base44.auth.updateMe({
|
||||||
|
[layoutKey]: layoutData
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-layout'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-client'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-vendor'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-operator'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Layout Saved",
|
||||||
|
description: "Your dashboard layout has been updated",
|
||||||
|
});
|
||||||
|
setHasChanges(false);
|
||||||
|
if (onLayoutChange) {
|
||||||
|
onLayoutChange(visibleWidgets);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Save Failed",
|
||||||
|
description: "Could not save your layout. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDragEnd = (result) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const { source, destination } = result;
|
||||||
|
|
||||||
|
if (source.droppableId === "visible" && destination.droppableId === "visible") {
|
||||||
|
const items = Array.from(visibleWidgets);
|
||||||
|
const [reorderedItem] = items.splice(source.index, 1);
|
||||||
|
items.splice(destination.index, 0, reorderedItem);
|
||||||
|
setVisibleWidgets(items);
|
||||||
|
setHasChanges(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideWidget = (widget) => {
|
||||||
|
setVisibleWidgets(visibleWidgets.filter(w => w.id !== widget.id));
|
||||||
|
setHiddenWidgets([...hiddenWidgets, widget]);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowWidget = (widget) => {
|
||||||
|
setHiddenWidgets(hiddenWidgets.filter(w => w.id !== widget.id));
|
||||||
|
setVisibleWidgets([...visibleWidgets, widget]);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const layoutData = {
|
||||||
|
widgets: visibleWidgets.map(w => w.id),
|
||||||
|
hidden_widgets: hiddenWidgets.map(w => w.id),
|
||||||
|
layout_version: "2.0"
|
||||||
|
};
|
||||||
|
saveLayoutMutation.mutate(layoutData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setVisibleWidgets(availableWidgets);
|
||||||
|
setHiddenWidgets([]);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCustomizer = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setShowHowItWorks(true);
|
||||||
|
setHasChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (window.confirm("You have unsaved changes. Are you sure you want to close?")) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Customize Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenCustomizer}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-2 border-blue-200 hover:bg-blue-50 text-blue-600 font-semibold"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Customize
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Customizer Dialog */}
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Sparkles className="w-6 h-6 text-blue-600" />
|
||||||
|
Customize Your Dashboard
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Personalize your workspace by adding, removing, and reordering widgets
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* How It Works Banner */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showHowItWorks && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-4 mb-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Info className="w-5 h-5 text-blue-600" />
|
||||||
|
<h3 className="font-bold text-blue-900">How it works</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
<strong>Drag</strong> widgets to reorder them
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
<strong>Hide</strong> widgets you don't need
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<strong>Show</strong> hidden widgets to bring them back
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHowItWorks(false)}
|
||||||
|
className="text-blue-400 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Visible Widgets */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900">
|
||||||
|
Visible Widgets ({visibleWidgets.length})
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
onClick={handleReset}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<Droppable droppableId="visible">
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
className={`space-y-2 min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
|
||||||
|
snapshot.isDraggingOver
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-slate-200 bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{visibleWidgets.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-400">
|
||||||
|
<Eye className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm font-medium">No visible widgets</p>
|
||||||
|
<p className="text-xs mt-1">Add widgets from the hidden section below!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visibleWidgets.map((widget, index) => (
|
||||||
|
<Draggable key={widget.id} draggableId={widget.id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
className={`bg-white border-2 rounded-lg p-4 transition-all ${
|
||||||
|
snapshot.isDragging
|
||||||
|
? 'border-blue-400 shadow-2xl scale-105 rotate-2'
|
||||||
|
: 'border-slate-200 hover:border-blue-300 hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-slate-400 hover:text-blue-600 transition-colors p-1 hover:bg-blue-50 rounded"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-slate-900">{widget.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={`${widget.categoryColor || 'bg-blue-100 text-blue-700'} border-0 text-xs`}>
|
||||||
|
{widget.category}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
onClick={() => handleHideWidget(widget)}
|
||||||
|
className="text-slate-400 hover:text-red-600 transition-colors p-2 hover:bg-red-50 rounded-lg"
|
||||||
|
title="Hide widget"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden Widgets */}
|
||||||
|
{hiddenWidgets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 mb-3 flex items-center gap-2">
|
||||||
|
Hidden Widgets ({hiddenWidgets.length})
|
||||||
|
<Badge className="bg-slate-200 text-slate-600 text-xs">Click + to add</Badge>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{hiddenWidgets.map((widget) => (
|
||||||
|
<div
|
||||||
|
key={widget.id}
|
||||||
|
className="bg-slate-50 border-2 border-dashed border-slate-300 rounded-lg p-4 opacity-60 hover:opacity-100 transition-all hover:border-green-400 hover:bg-green-50/50 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-semibold text-slate-900">{widget.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleShowWidget(widget)}
|
||||||
|
className="text-slate-400 hover:text-green-600 group-hover:bg-green-100 transition-colors p-2 rounded-lg"
|
||||||
|
title="Show widget"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Hidden Message */}
|
||||||
|
{hiddenWidgets.length === 0 && visibleWidgets.length === availableWidgets.length && (
|
||||||
|
<div className="text-center py-6 bg-green-50 border-2 border-green-200 rounded-lg">
|
||||||
|
<Sparkles className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
||||||
|
<p className="text-sm font-medium text-green-800">
|
||||||
|
All widgets are visible on your dashboard!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t mt-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="text-slate-600"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{hasChanges && (
|
||||||
|
<Badge className="bg-orange-500 text-white animate-pulse">
|
||||||
|
Unsaved Changes
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowHowItWorks(!showHowItWorks)}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={saveLayoutMutation.isPending}
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
{showHowItWorks ? 'Hide' : 'Show'} Help
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveLayoutMutation.isPending || !hasChanges}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{saveLayoutMutation.isPending ? "Saving..." : hasChanges ? "Save Layout" : "No Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
frontend-web/src/components/events/AssignedStaffManager.jsx
Normal file
235
frontend-web/src/components/events/AssignedStaffManager.jsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Edit2, Trash2, ArrowLeftRight, Clock, MapPin, Check, X } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export default function AssignedStaffManager({ event, shift, role }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editTarget, setEditTarget] = useState(null);
|
||||||
|
const [swapTarget, setSwapTarget] = useState(null);
|
||||||
|
const [editTimes, setEditTimes] = useState({ start: "", end: "" });
|
||||||
|
|
||||||
|
// Get assigned staff for this role
|
||||||
|
const assignedStaff = (event.assigned_staff || []).filter(
|
||||||
|
s => s.role === role?.role
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove staff mutation
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: async (staffId) => {
|
||||||
|
const updatedAssignedStaff = (event.assigned_staff || []).filter(
|
||||||
|
s => s.staff_id !== staffId || s.role !== role.role
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedShifts = (event.shifts || []).map(s => {
|
||||||
|
if (s.shift_name === shift.shift_name) {
|
||||||
|
const updatedRoles = (s.roles || []).map(r => {
|
||||||
|
if (r.role === role.role) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
assigned: Math.max((r.assigned || 0) - 1, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return { ...s, roles: updatedRoles };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
assigned_staff: updatedAssignedStaff,
|
||||||
|
shifts: updatedShifts,
|
||||||
|
requested: Math.max((event.requested || 0) - 1, 0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Staff Removed",
|
||||||
|
description: "Staff member has been removed from this assignment",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit times mutation
|
||||||
|
const editMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const updatedShifts = (event.shifts || []).map(s => {
|
||||||
|
if (s.shift_name === shift.shift_name) {
|
||||||
|
const updatedRoles = (s.roles || []).map(r => {
|
||||||
|
if (r.role === role.role) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
start_time: editTimes.start,
|
||||||
|
end_time: editTimes.end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return { ...s, roles: updatedRoles };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
shifts: updatedShifts,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Times Updated",
|
||||||
|
description: "Assignment times have been updated",
|
||||||
|
});
|
||||||
|
setEditTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (staff) => {
|
||||||
|
setEditTarget(staff);
|
||||||
|
setEditTimes({
|
||||||
|
start: role.start_time || "09:00",
|
||||||
|
end: role.end_time || "17:00",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
editMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (staffId) => {
|
||||||
|
if (confirm("Are you sure you want to remove this staff member?")) {
|
||||||
|
removeMutation.mutate(staffId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!assignedStaff.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-6 text-slate-500 text-sm">
|
||||||
|
No staff assigned yet
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assignedStaff.map((staff) => (
|
||||||
|
<div
|
||||||
|
key={staff.staff_id}
|
||||||
|
className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg hover:shadow-sm transition-shadow"
|
||||||
|
>
|
||||||
|
<Avatar className="w-10 h-10 bg-gradient-to-br from-green-600 to-emerald-600">
|
||||||
|
<AvatarFallback className="text-white font-bold">
|
||||||
|
{staff.staff_name?.charAt(0) || 'S'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-slate-900">{staff.staff_name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-600">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{staff.role}
|
||||||
|
</Badge>
|
||||||
|
{role.start_time && role.end_time && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{role.start_time} - {role.end_time}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(staff)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Edit times"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 text-slate-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemove(staff.staff_id)}
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Times Dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Assignment Times</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Staff Member</Label>
|
||||||
|
<p className="text-sm font-medium mt-1">{editTarget?.staff_name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Start Time</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={editTimes.start}
|
||||||
|
onChange={(e) => setEditTimes({ ...editTimes, start: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>End Time</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={editTimes.end}
|
||||||
|
onChange={(e) => setEditTimes({ ...editTimes, end: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={editMutation.isPending}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
{editMutation.isPending ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
date: "",
|
date: "",
|
||||||
include_backup: false,
|
include_backup: false,
|
||||||
backup_staff_count: 0,
|
backup_staff_count: 0,
|
||||||
|
vendor_id: "", // Added vendor_id
|
||||||
|
vendor_name: "", // Added vendor_name
|
||||||
shifts: [{
|
shifts: [{
|
||||||
shift_name: "Shift 1",
|
shift_name: "Shift 1",
|
||||||
location_address: "",
|
location_address: "",
|
||||||
@@ -72,6 +74,7 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
const currentUserData = currentUser || user;
|
const currentUserData = currentUser || user;
|
||||||
const userRole = currentUserData?.user_role || currentUserData?.role || "admin";
|
const userRole = currentUserData?.user_role || currentUserData?.role || "admin";
|
||||||
const isVendor = userRole === "vendor";
|
const isVendor = userRole === "vendor";
|
||||||
|
const isClient = userRole === "client"; // Added isClient
|
||||||
|
|
||||||
const { data: businesses = [] } = useQuery({
|
const { data: businesses = [] } = useQuery({
|
||||||
queryKey: ['businesses'],
|
queryKey: ['businesses'],
|
||||||
@@ -79,6 +82,12 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
initialData: [],
|
initialData: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: vendors = [] } = useQuery({ // Added vendors query
|
||||||
|
queryKey: ['vendors-for-order'],
|
||||||
|
queryFn: () => base44.entities.Vendor.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
const { data: allRates = [] } = useQuery({
|
const { data: allRates = [] } = useQuery({
|
||||||
queryKey: ['vendor-rates-all'],
|
queryKey: ['vendor-rates-all'],
|
||||||
queryFn: () => base44.entities.VendorRate.list(),
|
queryFn: () => base44.entities.VendorRate.list(),
|
||||||
@@ -87,6 +96,22 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
|
|
||||||
const availableRoles = [...new Set(allRates.map(r => r.role_name))].sort();
|
const availableRoles = [...new Set(allRates.map(r => r.role_name))].sort();
|
||||||
|
|
||||||
|
// Auto-select preferred vendor for clients
|
||||||
|
useEffect(() => {
|
||||||
|
if (isClient && currentUserData && !formData.vendor_id) {
|
||||||
|
const preferredVendorId = currentUserData.preferred_vendor_id;
|
||||||
|
const preferredVendorName = currentUserData.preferred_vendor_name;
|
||||||
|
|
||||||
|
if (preferredVendorId) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
vendor_id: preferredVendorId,
|
||||||
|
vendor_name: preferredVendorName || ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isClient, currentUserData, formData.vendor_id]); // Dependency array updated
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (event) {
|
if (event) {
|
||||||
setFormData(event);
|
setFormData(event);
|
||||||
@@ -112,6 +137,38 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVendorChange = (vendorId) => { // Added handleVendorChange
|
||||||
|
const selectedVendor = vendors.find(v => v.id === vendorId);
|
||||||
|
if (selectedVendor) {
|
||||||
|
setFormData(prev => {
|
||||||
|
const updatedShifts = prev.shifts.map(shift => ({
|
||||||
|
...shift,
|
||||||
|
roles: shift.roles.map(role => {
|
||||||
|
if (role.role) {
|
||||||
|
const rate = getRateForRole(role.role, vendorId); // Re-calculate rates with new vendorId
|
||||||
|
return {
|
||||||
|
...role,
|
||||||
|
rate_per_hour: rate,
|
||||||
|
total_value: rate * (role.hours || 0) * (role.count || 1),
|
||||||
|
vendor_id: vendorId,
|
||||||
|
vendor_name: selectedVendor.legal_name || selectedVendor.doing_business_as
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
vendor_id: vendorId,
|
||||||
|
vendor_name: selectedVendor.legal_name || selectedVendor.doing_business_as || "",
|
||||||
|
shifts: updatedShifts
|
||||||
|
};
|
||||||
|
});
|
||||||
|
updateGrandTotal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const calculateHours = (startTime, endTime, breakMinutes = 0) => {
|
const calculateHours = (startTime, endTime, breakMinutes = 0) => {
|
||||||
if (!startTime || !endTime) return 0;
|
if (!startTime || !endTime) return 0;
|
||||||
const [startHour, startMin] = startTime.split(':').map(Number);
|
const [startHour, startMin] = startTime.split(':').map(Number);
|
||||||
@@ -124,9 +181,21 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
return Math.max(0, totalMinutes / 60);
|
return Math.max(0, totalMinutes / 60);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRateForRole = (roleName) => {
|
const getRateForRole = (roleName, vendorId = null) => { // Modified getRateForRole
|
||||||
const rate = allRates.find(r => r.role_name === roleName && r.is_active);
|
const targetVendorId = vendorId || formData.vendor_id;
|
||||||
return rate ? parseFloat(rate.client_rate || 0) : 0;
|
|
||||||
|
if (targetVendorId) {
|
||||||
|
const rate = allRates.find(r =>
|
||||||
|
r.role_name === roleName &&
|
||||||
|
r.vendor_id === targetVendorId &&
|
||||||
|
r.is_active
|
||||||
|
);
|
||||||
|
if (rate) return parseFloat(rate.client_rate || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to any active rate if no specific vendor or vendor-specific rate is found
|
||||||
|
const fallbackRate = allRates.find(r => r.role_name === roleName && r.is_active);
|
||||||
|
return fallbackRate ? parseFloat(fallbackRate.client_rate || 0) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRoleChange = (shiftIndex, roleIndex, field, value) => {
|
const handleRoleChange = (shiftIndex, roleIndex, field, value) => {
|
||||||
@@ -138,6 +207,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
if (field === 'role') {
|
if (field === 'role') {
|
||||||
const rate = getRateForRole(value);
|
const rate = getRateForRole(value);
|
||||||
role.rate_per_hour = rate;
|
role.rate_per_hour = rate;
|
||||||
|
role.vendor_id = prev.vendor_id; // Added vendor_id to role
|
||||||
|
role.vendor_name = prev.vendor_name; // Added vendor_name to role
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field === 'start_time' || field === 'end_time' || field === 'break_minutes') {
|
if (field === 'start_time' || field === 'end_time' || field === 'break_minutes') {
|
||||||
@@ -176,7 +247,9 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
uniform: "Type 1",
|
uniform: "Type 1",
|
||||||
break_minutes: 30, // Default to 30 min non-payable
|
break_minutes: 30, // Default to 30 min non-payable
|
||||||
rate_per_hour: 0,
|
rate_per_hour: 0,
|
||||||
total_value: 0
|
total_value: 0,
|
||||||
|
vendor_id: prev.vendor_id, // Added vendor_id to new role
|
||||||
|
vendor_name: prev.vendor_name // Added vendor_name to new role
|
||||||
});
|
});
|
||||||
return { ...prev, shifts: newShifts };
|
return { ...prev, shifts: newShifts };
|
||||||
});
|
});
|
||||||
@@ -588,6 +661,36 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current
|
|||||||
<CardContent className="p-4 space-y-3">
|
<CardContent className="p-4 space-y-3">
|
||||||
<Label className="text-xs font-bold">Event Details</Label>
|
<Label className="text-xs font-bold">Event Details</Label>
|
||||||
|
|
||||||
|
{/* Vendor Selection for Clients */}
|
||||||
|
{isClient && (
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg border-2 border-blue-200">
|
||||||
|
<Label className="text-xs font-semibold mb-2 block flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-blue-600" />
|
||||||
|
Select Vendor *
|
||||||
|
</Label>
|
||||||
|
<Select value={formData.vendor_id || ""} onValueChange={handleVendorChange}>
|
||||||
|
<SelectTrigger className="h-9 text-sm bg-white">
|
||||||
|
<SelectValue placeholder="Choose vendor for this order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vendors.filter(v => v.approval_status === 'approved').map((vendor) => (
|
||||||
|
<SelectItem key={vendor.id} value={vendor.id} className="text-sm">
|
||||||
|
{vendor.legal_name || vendor.doing_business_as}
|
||||||
|
{currentUserData?.preferred_vendor_id === vendor.id && (
|
||||||
|
<Badge className="ml-2 bg-blue-500 text-white text-xs">Preferred</Badge>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formData.vendor_id && (
|
||||||
|
<p className="text-xs text-blue-600 mt-2">
|
||||||
|
✓ Rates will be automatically applied from {formData.vendor_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 1. Hub (first) */}
|
{/* 1. Hub (first) */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Hub *</Label>
|
<Label className="text-xs">Hub *</Label>
|
||||||
|
|||||||
@@ -1,84 +1,160 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Clock, MapPin, Users, DollarSign, UserPlus } from "lucide-react";
|
||||||
import { MapPin, Plus } from "lucide-react";
|
import SmartAssignModal from "./SmartAssignModal";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import AssignedStaffManager from "./AssignedStaffManager";
|
||||||
|
|
||||||
|
const convertTo12Hour = (time24) => {
|
||||||
|
if (!time24 || time24 === "—") return time24;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = time24.split(':');
|
||||||
|
if (!parts || parts.length < 2) return time24;
|
||||||
|
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return time24;
|
||||||
|
|
||||||
|
const period = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const hours12 = hours % 12 || 12;
|
||||||
|
const minutesStr = minutes.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${hours12}:${minutesStr} ${period}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting time:', error);
|
||||||
|
return time24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ShiftCard({ shift, event }) {
|
||||||
|
const [assignModal, setAssignModal] = useState({ open: false, role: null });
|
||||||
|
|
||||||
|
const roles = shift?.roles || [];
|
||||||
|
|
||||||
export default function ShiftCard({ shift, onNotifyStaff }) {
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-slate-200">
|
<>
|
||||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100 pb-4">
|
<Card className="bg-white border-2 border-slate-200 shadow-sm">
|
||||||
|
<CardHeader className="border-b border-slate-100 bg-slate-50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold">{shift.shift_name || "Shift 1"}</CardTitle>
|
|
||||||
<Button onClick={onNotifyStaff} className="bg-blue-600 hover:bg-blue-700 text-white text-sm">
|
|
||||||
Notify Staff
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-6 mt-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500 mb-2">Managers:</p>
|
<CardTitle className="text-lg font-bold text-slate-900">
|
||||||
<div className="flex items-center gap-2">
|
{shift.shift_name || "Shift"}
|
||||||
{shift.assigned_staff?.slice(0, 3).map((staff, idx) => (
|
</CardTitle>
|
||||||
<div key={idx} className="flex items-center gap-2">
|
{shift.location && (
|
||||||
<Avatar className="w-8 h-8 bg-slate-300">
|
<div className="flex items-center gap-2 text-sm text-slate-600 mt-1">
|
||||||
<AvatarFallback className="text-xs">{staff.staff_name?.charAt(0)}</AvatarFallback>
|
<MapPin className="w-4 h-4" />
|
||||||
</Avatar>
|
{shift.location}
|
||||||
<div className="text-xs">
|
|
||||||
<p className="font-medium">{staff.staff_name}</p>
|
|
||||||
<p className="text-slate-500">{staff.position || "john@email.com"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2 text-xs">
|
|
||||||
<MapPin className="w-4 h-4 text-slate-400 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Location:</p>
|
|
||||||
<p className="text-slate-600">{shift.location || "848 East Glen Road New York CA, USA"}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Badge className="bg-[#0A39DF] text-white font-semibold px-3 py-1.5">
|
||||||
|
{roles.length} Role{roles.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
<CardContent className="p-6">
|
||||||
<TableHeader>
|
<div className="space-y-4">
|
||||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
{roles.map((role, idx) => {
|
||||||
<TableHead className="text-xs">Unpaid break</TableHead>
|
const requiredCount = role.count || 1;
|
||||||
<TableHead className="text-xs">Count</TableHead>
|
const assignedCount = event?.assigned_staff?.filter(s => s.role === role.role)?.length || 0;
|
||||||
<TableHead className="text-xs">Assigned</TableHead>
|
const remainingCount = Math.max(requiredCount - assignedCount, 0);
|
||||||
<TableHead className="text-xs">Uniform type</TableHead>
|
|
||||||
<TableHead className="text-xs">Price</TableHead>
|
// Consistent status color logic
|
||||||
<TableHead className="text-xs">Amount</TableHead>
|
const statusColor = remainingCount === 0
|
||||||
<TableHead className="text-xs">Actions</TableHead>
|
? "bg-green-100 text-green-700 border-green-300"
|
||||||
</TableRow>
|
: assignedCount > 0
|
||||||
</TableHeader>
|
? "bg-blue-100 text-blue-700 border-blue-300"
|
||||||
<TableBody>
|
: "bg-slate-100 text-slate-700 border-slate-300";
|
||||||
{(shift.assigned_staff || []).length > 0 ? (
|
|
||||||
shift.assigned_staff.map((staff, idx) => (
|
return (
|
||||||
<TableRow key={idx}>
|
<div
|
||||||
<TableCell className="text-xs">{shift.unpaid_break || 0}</TableCell>
|
key={idx}
|
||||||
<TableCell className="text-xs">1</TableCell>
|
className="border-2 border-slate-200 rounded-xl p-4 hover:shadow-sm transition-shadow bg-white"
|
||||||
<TableCell className="text-xs">0</TableCell>
|
>
|
||||||
<TableCell className="text-xs">{shift.uniform_type || "uniform type"}</TableCell>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<TableCell className="text-xs">${shift.price || 23}</TableCell>
|
<div className="flex-1">
|
||||||
<TableCell className="text-xs">{shift.amount || 120}</TableCell>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<TableCell>
|
<h4 className="font-bold text-slate-900 text-lg">{role.role}</h4>
|
||||||
<Button variant="ghost" size="sm" className="text-xs">⋮</Button>
|
<Badge className={`${statusColor} border-2 font-bold px-3 py-1`}>
|
||||||
</TableCell>
|
{assignedCount} / {requiredCount} Assigned
|
||||||
</TableRow>
|
</Badge>
|
||||||
))
|
</div>
|
||||||
) : (
|
|
||||||
<TableRow>
|
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||||
<TableCell colSpan={7} className="text-center py-4 text-slate-500 text-xs">
|
{role.start_time && role.end_time && (
|
||||||
No staff assigned yet
|
<span className="flex items-center gap-1.5">
|
||||||
</TableCell>
|
<Clock className="w-4 h-4" />
|
||||||
</TableRow>
|
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
{role.department && (
|
||||||
</Table>
|
<Badge variant="outline" className="text-xs border-slate-300">
|
||||||
|
{role.department}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setAssignModal({ open: true, role })}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700 gap-2 font-semibold"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
Assign Staff ({remainingCount} needed)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show assigned staff */}
|
||||||
|
{assignedCount > 0 && (
|
||||||
|
<div className="border-t border-slate-200 pt-4 mt-4">
|
||||||
|
<p className="text-xs font-bold text-slate-700 mb-3 uppercase tracking-wide">
|
||||||
|
Assigned Staff
|
||||||
|
</p>
|
||||||
|
<AssignedStaffManager event={event} shift={shift} role={role} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional role details */}
|
||||||
|
{(role.uniform || role.cost_per_hour) && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-slate-200">
|
||||||
|
{role.uniform && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Uniform</p>
|
||||||
|
<p className="text-sm font-medium text-slate-900">{role.uniform}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{role.cost_per_hour && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4 text-[#0A39DF]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Rate</p>
|
||||||
|
<p className="text-sm font-bold text-slate-900">${role.cost_per_hour}/hr</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Smart Assignment Modal */}
|
||||||
|
<SmartAssignModal
|
||||||
|
open={assignModal.open}
|
||||||
|
onClose={() => setAssignModal({ open: false, role: null })}
|
||||||
|
event={event}
|
||||||
|
shift={shift}
|
||||||
|
role={assignModal.role}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
878
frontend-web/src/components/events/SmartAssignModal.jsx
Normal file
878
frontend-web/src/components/events/SmartAssignModal.jsx
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
|
||||||
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
AlertTriangle,
|
||||||
|
Star,
|
||||||
|
MapPin,
|
||||||
|
Sparkles,
|
||||||
|
Check,
|
||||||
|
Calendar,
|
||||||
|
Sliders,
|
||||||
|
TrendingUp,
|
||||||
|
Shield,
|
||||||
|
DollarSign,
|
||||||
|
Zap,
|
||||||
|
Bell,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
|
||||||
|
// Helper to check time overlap with buffer
|
||||||
|
function hasTimeOverlap(start1, end1, start2, end2, bufferMinutes = 30) {
|
||||||
|
const s1 = new Date(start1).getTime();
|
||||||
|
const e1 = new Date(end1).getTime() + bufferMinutes * 60 * 1000;
|
||||||
|
const s2 = new Date(start2).getTime();
|
||||||
|
const e2 = new Date(end2).getTime() + bufferMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
return s1 < e2 && s2 < e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SmartAssignModal({ open, onClose, event, shift, role }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [sortMode, setSortMode] = useState("smart");
|
||||||
|
const [selectedRole, setSelectedRole] = useState(null); // New state to manage current selected role for assignment
|
||||||
|
|
||||||
|
// Smart assignment priorities
|
||||||
|
const [priorities, setPriorities] = useState({
|
||||||
|
skill: 100, // Skill is implied by position match, not a slider
|
||||||
|
reliability: 80,
|
||||||
|
fatigue: 60,
|
||||||
|
compliance: 70,
|
||||||
|
proximity: 50,
|
||||||
|
cost: 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelected(new Set());
|
||||||
|
setSearchQuery("");
|
||||||
|
|
||||||
|
// Auto-select first role if available or the one passed in props
|
||||||
|
if (event && !role) {
|
||||||
|
// If no specific role is passed, find roles that need assignment
|
||||||
|
const initialRoles = [];
|
||||||
|
(event.shifts || []).forEach(s => {
|
||||||
|
(s.roles || []).forEach(r => {
|
||||||
|
const currentAssignedCount = event.assigned_staff?.filter(staff =>
|
||||||
|
staff.role === r.role && staff.shift_name === s.shift_name
|
||||||
|
)?.length || 0;
|
||||||
|
if ((r.count || 0) > currentAssignedCount) {
|
||||||
|
initialRoles.push({ shift: s, role: r });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (initialRoles.length > 0) {
|
||||||
|
setSelectedRole(initialRoles[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedRole(null); // No roles need assignment
|
||||||
|
}
|
||||||
|
} else if (shift && role) {
|
||||||
|
setSelectedRole({ shift, role });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, event, shift, role]);
|
||||||
|
|
||||||
|
const { data: allStaff = [] } = useQuery({
|
||||||
|
queryKey: ['staff-for-assignment'],
|
||||||
|
queryFn: () => base44.entities.Staff.list(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allEvents = [] } = useQuery({
|
||||||
|
queryKey: ['events-for-conflict-check'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: vendorRates = [] } = useQuery({
|
||||||
|
queryKey: ['vendor-rates-assignment'],
|
||||||
|
queryFn: () => base44.entities.VendorRate.list(),
|
||||||
|
enabled: open,
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all roles that need assignment for display in the header
|
||||||
|
const allRoles = useMemo(() => {
|
||||||
|
if (!event) return [];
|
||||||
|
const roles = [];
|
||||||
|
(event.shifts || []).forEach(s => {
|
||||||
|
(s.roles || []).forEach(r => {
|
||||||
|
const currentAssignedCount = event.assigned_staff?.filter(staff =>
|
||||||
|
staff.role === r.role && staff.shift_name === s.shift_name
|
||||||
|
)?.length || 0;
|
||||||
|
const remaining = Math.max((r.count || 0) - currentAssignedCount, 0);
|
||||||
|
if (remaining > 0) {
|
||||||
|
roles.push({
|
||||||
|
shift: s,
|
||||||
|
role: r,
|
||||||
|
currentAssigned: currentAssignedCount,
|
||||||
|
remaining,
|
||||||
|
label: `${r.role} (${remaining} needed)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return roles;
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
|
// Use selectedRole for current assignment context
|
||||||
|
const currentRole = selectedRole?.role;
|
||||||
|
const currentShift = selectedRole?.shift;
|
||||||
|
|
||||||
|
const requiredCount = currentRole?.count || 1;
|
||||||
|
const currentAssigned = event?.assigned_staff?.filter(s =>
|
||||||
|
s.role === currentRole?.role && s.shift_name === currentShift?.shift_name
|
||||||
|
)?.length || 0;
|
||||||
|
const remainingCount = Math.max(requiredCount - currentAssigned, 0);
|
||||||
|
|
||||||
|
const eligibleStaff = useMemo(() => {
|
||||||
|
if (!currentRole || !event) return [];
|
||||||
|
|
||||||
|
return allStaff
|
||||||
|
.filter(staff => {
|
||||||
|
// Check if position matches
|
||||||
|
const positionMatch = staff.position === currentRole.role ||
|
||||||
|
staff.position_2 === currentRole.role ||
|
||||||
|
staff.position?.toLowerCase() === currentRole.role?.toLowerCase() ||
|
||||||
|
staff.position_2?.toLowerCase() === currentRole.role?.toLowerCase();
|
||||||
|
|
||||||
|
if (!positionMatch) return false;
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const nameMatch = staff.employee_name?.toLowerCase().includes(query);
|
||||||
|
const locationMatch = staff.hub_location?.toLowerCase().includes(query);
|
||||||
|
if (!nameMatch && !locationMatch) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(staff => {
|
||||||
|
// Check for time conflicts
|
||||||
|
const conflicts = allEvents.filter(e => {
|
||||||
|
if (e.id === event.id) return false; // Don't conflict with current event
|
||||||
|
if (e.status === "Canceled" || e.status === "Completed") return false; // Ignore past/canceled events
|
||||||
|
|
||||||
|
const isAssignedToEvent = e.assigned_staff?.some(s => s.staff_id === staff.id);
|
||||||
|
if (!isAssignedToEvent) return false; // Staff not assigned to this event
|
||||||
|
|
||||||
|
// Check for time overlap within the conflicting event's shifts
|
||||||
|
const eventShifts = e.shifts || [];
|
||||||
|
return eventShifts.some(eventShift => {
|
||||||
|
const eventRoles = eventShift.roles || [];
|
||||||
|
return eventRoles.some(eventRole => {
|
||||||
|
// Ensure staff is assigned to this specific role within the conflicting shift
|
||||||
|
const isStaffAssignedToThisRole = e.assigned_staff?.some(
|
||||||
|
s => s.staff_id === staff.id && s.role === eventRole.role && s.shift_name === eventShift.shift_name
|
||||||
|
);
|
||||||
|
if (!isStaffAssignedToThisRole) return false;
|
||||||
|
|
||||||
|
const shiftStart = `${e.date}T${eventRole.start_time || '00:00'}`;
|
||||||
|
const shiftEnd = `${e.date}T${eventRole.end_time || '23:59'}`;
|
||||||
|
const currentStart = `${event.date}T${currentRole.start_time || '00:00'}`;
|
||||||
|
const currentEnd = `${event.date}T${currentRole.end_time || '23:59'}`;
|
||||||
|
|
||||||
|
return hasTimeOverlap(shiftStart, shiftEnd, currentStart, currentEnd);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasConflict = conflicts.length > 0;
|
||||||
|
const totalShifts = staff.total_shifts || 0;
|
||||||
|
const reliability = staff.reliability_score || (totalShifts > 0 ? 85 : 0);
|
||||||
|
|
||||||
|
// Calculate smart scores
|
||||||
|
// Skill score is implicitly 100 if they pass the filter (position match)
|
||||||
|
const fatigueScore = 100 - Math.min((totalShifts / 30) * 100, 100); // More shifts = more fatigue = lower score
|
||||||
|
const complianceScore = staff.background_check_status === 'cleared' ? 100 : 50; // Simple compliance check
|
||||||
|
const proximityScore = staff.hub_location === event.hub ? 100 : 50; // Location match
|
||||||
|
const costRate = vendorRates.find(r => r.vendor_id === staff.vendor_id && r.role_name === currentRole.role);
|
||||||
|
const costScore = costRate ? Math.max(0, 100 - (costRate.client_rate / 50) * 100) : 50; // Lower rate = higher score
|
||||||
|
|
||||||
|
const smartScore = (
|
||||||
|
(priorities.skill / 100) * 100 + // Skill is 100 if eligible
|
||||||
|
(priorities.reliability / 100) * reliability +
|
||||||
|
(priorities.fatigue / 100) * fatigueScore +
|
||||||
|
(priorities.compliance / 100) * complianceScore +
|
||||||
|
(priorities.proximity / 100) * proximityScore +
|
||||||
|
(priorities.cost / 100) * costScore
|
||||||
|
) / 6; // Divided by number of priorities (6)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...staff,
|
||||||
|
hasConflict,
|
||||||
|
conflictDetails: conflicts,
|
||||||
|
reliability,
|
||||||
|
shiftCount: totalShifts,
|
||||||
|
smartScore,
|
||||||
|
scores: {
|
||||||
|
fatigue: fatigueScore,
|
||||||
|
compliance: complianceScore,
|
||||||
|
proximity: proximityScore,
|
||||||
|
cost: costScore,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sortMode === "smart") {
|
||||||
|
// Prioritize non-conflicting staff first, then by smart score
|
||||||
|
if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1;
|
||||||
|
return b.smartScore - a.smartScore;
|
||||||
|
} else {
|
||||||
|
// Manual mode: Prioritize non-conflicting, then reliability, then shift count
|
||||||
|
if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1;
|
||||||
|
if (b.reliability !== a.reliability) return b.reliability - a.reliability;
|
||||||
|
return (b.shiftCount || 0) - (a.shiftCount || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [allStaff, allEvents, currentRole, event, currentShift, searchQuery, sortMode, priorities, vendorRates]);
|
||||||
|
|
||||||
|
const availableStaff = eligibleStaff.filter(s => !s.hasConflict);
|
||||||
|
const unavailableStaff = eligibleStaff.filter(s => s.hasConflict);
|
||||||
|
|
||||||
|
const handleSelectBest = () => {
|
||||||
|
const best = availableStaff.slice(0, remainingCount);
|
||||||
|
const newSelected = new Set(best.map(s => s.id));
|
||||||
|
setSelected(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (staffId) => {
|
||||||
|
const newSelected = new Set(selected);
|
||||||
|
if (newSelected.has(staffId)) {
|
||||||
|
newSelected.delete(staffId);
|
||||||
|
} else {
|
||||||
|
if (newSelected.size >= remainingCount) {
|
||||||
|
toast({
|
||||||
|
title: "Limit Reached",
|
||||||
|
description: `You can only assign ${remainingCount} more ${currentRole.role}${remainingCount > 1 ? 's' : ''} to this role.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newSelected.add(staffId);
|
||||||
|
}
|
||||||
|
setSelected(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const selectedStaff = eligibleStaff.filter(s => selected.has(s.id));
|
||||||
|
|
||||||
|
// Send notifications to unavailable staff who are being assigned
|
||||||
|
const unavailableSelected = selectedStaff.filter(s => s.hasConflict);
|
||||||
|
for (const staff of unavailableSelected) {
|
||||||
|
try {
|
||||||
|
// This is a placeholder for sending an actual email/notification
|
||||||
|
// In a real application, you'd use a robust notification service.
|
||||||
|
await base44.integrations.Core.SendEmail({ // Assuming base44.integrations.Core exists and has SendEmail
|
||||||
|
to: staff.email || `${staff.employee_name.replace(/\s/g, '').toLowerCase()}@example.com`,
|
||||||
|
subject: `New Shift Assignment - ${event.event_name} (Possible Conflict)`,
|
||||||
|
body: `Dear ${staff.employee_name},\n\nYou have been assigned to work as a ${currentRole.role} for the event "${event.event_name}" on ${format(new Date(event.date), 'MMM d, yyyy')} from ${currentRole.start_time} to ${currentRole.end_time} at ${event.hub || event.event_location}.\n\nOur records indicate this assignment might overlap with another scheduled shift. Please review your schedule and confirm your availability for this new assignment as soon as possible.\n\nThank you!`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send email to conflicted staff:", staff.employee_name, error);
|
||||||
|
// Decide whether to block assignment or just log the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAssignedStaff = [
|
||||||
|
...(event.assigned_staff || []),
|
||||||
|
...selectedStaff.map(s => ({
|
||||||
|
staff_id: s.id,
|
||||||
|
staff_name: s.employee_name,
|
||||||
|
email: s.email,
|
||||||
|
role: currentRole.role,
|
||||||
|
department: currentRole.department,
|
||||||
|
shift_name: currentShift.shift_name, // Include shift_name
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const updatedShifts = (event.shifts || []).map(s => {
|
||||||
|
if (s.shift_name === currentShift.shift_name) {
|
||||||
|
const updatedRoles = (s.roles || []).map(r => {
|
||||||
|
if (r.role === currentRole.role) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
assigned: (r.assigned || 0) + selected.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return { ...s, roles: updatedRoles };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
assigned_staff: updatedAssignedStaff,
|
||||||
|
shifts: updatedShifts,
|
||||||
|
requested: (event.requested || 0) + selected.size, // This `requested` field might need more careful handling if it's meant to be total
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] }); // New query key
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vendor-events'] }); // New query key
|
||||||
|
toast({
|
||||||
|
title: "✅ Staff Assigned",
|
||||||
|
description: `Successfully assigned ${selected.size} ${currentRole.role}${selected.size > 1 ? 's' : ''}`,
|
||||||
|
});
|
||||||
|
setSelected(new Set()); // Clear selection after assignment
|
||||||
|
|
||||||
|
// Auto-select the next role that needs assignment
|
||||||
|
const currentRoleIdentifier = { role: currentRole.role, shift_name: currentShift.shift_name };
|
||||||
|
const currentIndex = allRoles.findIndex(ar => ar.role.role === currentRoleIdentifier.role && ar.shift.shift_name === currentRoleIdentifier.shift_name);
|
||||||
|
|
||||||
|
if (currentIndex !== -1 && currentIndex + 1 < allRoles.length) {
|
||||||
|
setSelectedRole(allRoles[currentIndex + 1]);
|
||||||
|
} else {
|
||||||
|
onClose(); // Close if no more roles to assign
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Assignment Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAssign = () => {
|
||||||
|
if (selected.size === 0) {
|
||||||
|
toast({
|
||||||
|
title: "No Selection",
|
||||||
|
description: "Please select at least one staff member",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The logic to check for conflicts and stop was removed because
|
||||||
|
// the new assignMutation now sends notifications to conflicted staff.
|
||||||
|
// If a hard stop for conflicts is desired, this check should be re-enabled
|
||||||
|
// and the notification logic in assignMutation modified.
|
||||||
|
|
||||||
|
assignMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!event) return null;
|
||||||
|
// If there's no currentRole, it means either props were not passed or all roles are already assigned
|
||||||
|
if (!currentRole || !currentShift) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>No Roles to Assign</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-slate-600">All positions for this order are fully staffed, or no roles were specified.</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = remainingCount === 0
|
||||||
|
? "bg-green-100 text-green-700 border-green-300"
|
||||||
|
: currentAssigned > 0
|
||||||
|
? "bg-blue-100 text-blue-700 border-blue-300"
|
||||||
|
: "bg-slate-100 text-slate-700 border-slate-300";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader className="border-b pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-6 h-6 text-[#0A39DF]" />
|
||||||
|
Smart Assign Staff
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-sm text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{event.event_name}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{event.date ? format(new Date(event.date), 'MMM d, yyyy') : 'Date TBD'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<Badge className={`${statusColor} border-2 text-lg px-4 py-2 font-bold`}>
|
||||||
|
{selected.size} / {remainingCount} Selected
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Selector */}
|
||||||
|
{allRoles.length > 1 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{allRoles.map((roleItem, idx) => (
|
||||||
|
<Button
|
||||||
|
key={`${roleItem.shift.shift_name}-${roleItem.role.role}-${idx}`}
|
||||||
|
variant={roleItem.role.role === currentRole.role && roleItem.shift.shift_name === currentShift.shift_name ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRole(roleItem);
|
||||||
|
setSelected(new Set()); // Clear selection when changing roles
|
||||||
|
}}
|
||||||
|
className={roleItem.role.role === currentRole.role && roleItem.shift.shift_name === currentShift.shift_name ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white" : "border-slate-300"}
|
||||||
|
>
|
||||||
|
{roleItem.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={sortMode} onValueChange={setSortMode} className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="smart" className="flex-1">
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
Smart Assignment
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual" className="flex-1">
|
||||||
|
<Users className="w-4 h-4 mr-2" />
|
||||||
|
Manual Selection
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="smart" className="flex-1 overflow-hidden flex flex-col mt-4 space-y-4">
|
||||||
|
{/* Priority Controls */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Sliders className="w-4 h-4 text-[#0A39DF]" />
|
||||||
|
<h4 className="font-semibold text-slate-900">Assignment Priorities</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
Reliability
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">{priorities.reliability}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[priorities.reliability]}
|
||||||
|
onValueChange={(v) => setPriorities({...priorities, reliability: v[0]})}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Fatigue
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">{priorities.fatigue}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[priorities.fatigue]}
|
||||||
|
onValueChange={(v) => setPriorities({...priorities, fatigue: v[0]})}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
Compliance
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">{priorities.compliance}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[priorities.compliance]}
|
||||||
|
onValueChange={(v) => setPriorities({...priorities, compliance: v[0]})}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
Proximity
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">{priorities.proximity}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[priorities.proximity]}
|
||||||
|
onValueChange={(v) => setPriorities({...priorities, proximity: v[0]})}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 flex-1 overflow-hidden flex flex-col">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search employees..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4 text-[#0A39DF]" />
|
||||||
|
<span className="font-semibold text-slate-900">{availableStaff.length} Available</span>
|
||||||
|
</div>
|
||||||
|
{unavailableStaff.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||||
|
<span className="font-semibold text-orange-600">{unavailableStaff.length} Unavailable</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectBest}
|
||||||
|
disabled={remainingCount === 0 || availableStaff.length === 0}
|
||||||
|
className="gap-2 bg-[#0A39DF] hover:bg-blue-700 font-semibold"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Auto-Select Best {remainingCount}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto border-2 border-slate-200 rounded-lg">
|
||||||
|
{eligibleStaff.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No {currentRole.role}s found</p>
|
||||||
|
<p className="text-sm">Try adjusting your search or check staff positions</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{/* Available Staff First */}
|
||||||
|
{availableStaff.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="bg-green-50 px-4 py-2 sticky top-0 z-10 border-b border-green-100">
|
||||||
|
<p className="text-xs font-bold text-green-700 uppercase">Available ({availableStaff.length})</p>
|
||||||
|
</div>
|
||||||
|
{availableStaff.map((staff) => {
|
||||||
|
const isSelected = selected.has(staff.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={staff.id}
|
||||||
|
className={`p-4 flex items-center gap-4 transition-all cursor-pointer ${
|
||||||
|
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleSelect(staff.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSelect(staff.id)}
|
||||||
|
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar className="w-12 h-12">
|
||||||
|
<img
|
||||||
|
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=0A39DF&color=fff&size=128&bold=true`}
|
||||||
|
alt={staff.employee_name}
|
||||||
|
className="w-full h-full object-cover rounded-full"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||||
|
<Badge variant="outline" className="text-xs bg-gradient-to-r from-[#0A39DF] to-blue-600 text-white border-0">
|
||||||
|
{Math.round(staff.smartScore)}% Match
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
{staff.reliability}%
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
{Math.round(staff.scores.fatigue)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
{Math.round(staff.scores.compliance)}
|
||||||
|
</span>
|
||||||
|
{staff.hub_location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{staff.hub_location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||||
|
{staff.shiftCount || 0} shifts
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-semibold">
|
||||||
|
Available
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unavailable Staff */}
|
||||||
|
{unavailableStaff.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="bg-orange-50 px-4 py-2 sticky top-0 z-10 border-b border-orange-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-xs font-bold text-orange-700 uppercase">Unavailable ({unavailableStaff.length})</p>
|
||||||
|
<Bell className="w-3 h-3 text-orange-700" />
|
||||||
|
<span className="text-xs text-orange-600">Will be notified if assigned</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{unavailableStaff.map((staff) => {
|
||||||
|
const isSelected = selected.has(staff.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={staff.id}
|
||||||
|
className={`p-4 flex items-center gap-4 transition-all cursor-pointer ${
|
||||||
|
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleSelect(staff.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSelect(staff.id)}
|
||||||
|
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar className="w-12 h-12">
|
||||||
|
<img
|
||||||
|
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=f97316&color=fff&size=128&bold=true`}
|
||||||
|
alt={staff.employee_name}
|
||||||
|
className="w-full h-full object-cover rounded-full"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||||
|
<Badge variant="outline" className="text-xs bg-gradient-to-r from-orange-500 to-orange-600 text-white border-0">
|
||||||
|
{Math.round(staff.smartScore)}% Match
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-orange-600" />
|
||||||
|
Time Conflict
|
||||||
|
</span>
|
||||||
|
{staff.hub_location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{staff.hub_location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||||
|
{staff.shiftCount || 0} shifts
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-orange-100 text-orange-700 border border-orange-300 text-xs font-semibold">
|
||||||
|
Will Notify
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="flex-1 overflow-hidden flex flex-col mt-4 space-y-4">
|
||||||
|
<div className="space-y-3 flex-1 overflow-hidden flex flex-col">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search employees..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4 text-[#0A39DF]" />
|
||||||
|
<span className="font-semibold text-slate-900">{availableStaff.length} Available {currentRole.role}s</span>
|
||||||
|
</div>
|
||||||
|
{unavailableStaff.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||||
|
<span className="font-semibold text-orange-600">{unavailableStaff.length} Conflicts</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectBest}
|
||||||
|
disabled={remainingCount === 0 || availableStaff.length === 0}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-2 border-[#0A39DF] text-[#0A39DF] hover:bg-blue-50 font-semibold"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
Select Top {remainingCount}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto border-2 border-slate-200 rounded-lg">
|
||||||
|
{eligibleStaff.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No {currentRole.role}s found</p>
|
||||||
|
<p className="text-sm">Try adjusting your search or filters</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{eligibleStaff.map((staff) => {
|
||||||
|
const isSelected = selected.has(staff.id);
|
||||||
|
// In manual mode, we still allow selection of conflicted staff,
|
||||||
|
// and the system will notify them.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={staff.id}
|
||||||
|
className={`p-4 flex items-center gap-4 transition-all ${
|
||||||
|
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||||
|
} cursor-pointer`}
|
||||||
|
onClick={() => toggleSelect(staff.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSelect(staff.id)}
|
||||||
|
className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar className="w-12 h-12">
|
||||||
|
<img
|
||||||
|
src={staff.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.employee_name || 'Staff')}&background=0A39DF&color=fff&size=128&bold=true`}
|
||||||
|
alt={staff.employee_name}
|
||||||
|
className="w-full h-full object-cover rounded-full"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
|
||||||
|
{staff.rating && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{staff.rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||||
|
<Badge variant="outline" className="text-xs border-slate-300">
|
||||||
|
{currentRole.role}
|
||||||
|
</Badge>
|
||||||
|
{staff.hub_location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{staff.hub_location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-100 text-slate-700 border border-slate-300">
|
||||||
|
{staff.shiftCount || 0} shifts
|
||||||
|
</Badge>
|
||||||
|
{staff.hasConflict ? (
|
||||||
|
<Badge className="bg-orange-100 text-orange-700 border border-orange-300 text-xs font-semibold">
|
||||||
|
Conflict (Will Notify)
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-semibold">
|
||||||
|
Available
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t pt-4">
|
||||||
|
<Button variant="outline" onClick={onClose} className="border-2 border-slate-300">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAssign}
|
||||||
|
disabled={selected.size === 0 || assignMutation.isPending}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700 font-semibold"
|
||||||
|
>
|
||||||
|
{assignMutation.isPending ? (
|
||||||
|
"Assigning..."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
Assign {selected.size} {selected.size === 1 ? 'Employee' : 'Employees'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
frontend-web/src/components/events/VendorRoutingPanel.jsx
Normal file
340
frontend-web/src/components/events/VendorRoutingPanel.jsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Award, Star, MapPin, Users, TrendingUp, AlertTriangle, Zap, CheckCircle2, Send } from "lucide-react";
|
||||||
|
|
||||||
|
export default function VendorRoutingPanel({
|
||||||
|
user,
|
||||||
|
selectedVendors = [],
|
||||||
|
onVendorChange,
|
||||||
|
isRapid = false
|
||||||
|
}) {
|
||||||
|
const [showVendorSelector, setShowVendorSelector] = useState(false);
|
||||||
|
const [selectionMode, setSelectionMode] = useState('single'); // 'single' | 'multi'
|
||||||
|
|
||||||
|
// Fetch preferred vendor
|
||||||
|
const { data: preferredVendor } = useQuery({
|
||||||
|
queryKey: ['preferred-vendor-routing', user?.preferred_vendor_id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user?.preferred_vendor_id) return null;
|
||||||
|
const vendors = await base44.entities.Vendor.list();
|
||||||
|
return vendors.find(v => v.id === user.preferred_vendor_id);
|
||||||
|
},
|
||||||
|
enabled: !!user?.preferred_vendor_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all approved vendors
|
||||||
|
const { data: allVendors } = useQuery({
|
||||||
|
queryKey: ['all-vendors-routing'],
|
||||||
|
queryFn: () => base44.entities.Vendor.filter({
|
||||||
|
approval_status: 'approved',
|
||||||
|
is_active: true
|
||||||
|
}),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-select preferred vendor on mount if none selected
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (preferredVendor && selectedVendors.length === 0) {
|
||||||
|
onVendorChange([preferredVendor]);
|
||||||
|
}
|
||||||
|
}, [preferredVendor]);
|
||||||
|
|
||||||
|
const handleVendorSelect = (vendor) => {
|
||||||
|
if (selectionMode === 'single') {
|
||||||
|
onVendorChange([vendor]);
|
||||||
|
setShowVendorSelector(false);
|
||||||
|
} else {
|
||||||
|
// Multi-select mode
|
||||||
|
const isSelected = selectedVendors.some(v => v.id === vendor.id);
|
||||||
|
if (isSelected) {
|
||||||
|
onVendorChange(selectedVendors.filter(v => v.id !== vendor.id));
|
||||||
|
} else {
|
||||||
|
onVendorChange([...selectedVendors, vendor]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMultiVendorDone = () => {
|
||||||
|
if (selectedVendors.length === 0) {
|
||||||
|
alert("Please select at least one vendor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowVendorSelector(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const routingMode = selectedVendors.length > 1 ? 'multi' : 'single';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={`border-2 ${
|
||||||
|
isRapid
|
||||||
|
? 'border-red-300 bg-gradient-to-br from-red-50 to-orange-50'
|
||||||
|
: 'border-blue-300 bg-gradient-to-br from-blue-50 to-indigo-50'
|
||||||
|
}`}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-8 h-8 ${
|
||||||
|
isRapid ? 'bg-red-600' : 'bg-blue-600'
|
||||||
|
} rounded-lg flex items-center justify-center`}>
|
||||||
|
{isRapid ? (
|
||||||
|
<Zap className="w-4 h-4 text-white" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||||
|
{isRapid ? 'RAPID ORDER ROUTING' : 'Order Routing'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{routingMode === 'multi'
|
||||||
|
? `Sending to ${selectedVendors.length} vendors`
|
||||||
|
: 'Default vendor routing'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{routingMode === 'multi' && (
|
||||||
|
<Badge className="bg-purple-600 text-white font-bold">
|
||||||
|
MULTI-VENDOR
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Vendor(s) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedVendors.length === 0 && !preferredVendor && (
|
||||||
|
<div className="p-3 bg-amber-50 border-2 border-amber-200 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0" />
|
||||||
|
<p className="text-amber-800">
|
||||||
|
<strong>No vendor selected.</strong> Please choose a vendor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedVendors.map((vendor) => {
|
||||||
|
const isPreferred = vendor.id === preferredVendor?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vendor.id}
|
||||||
|
className="p-3 bg-white border-2 border-slate-200 rounded-lg hover:border-blue-300 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-bold text-sm text-slate-900">
|
||||||
|
{vendor.doing_business_as || vendor.legal_name}
|
||||||
|
</p>
|
||||||
|
{isPreferred && (
|
||||||
|
<Badge className="bg-blue-600 text-white text-xs px-2 py-0 border-0">
|
||||||
|
<Award className="w-3 h-3 mr-1" />
|
||||||
|
Preferred
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
{vendor.region && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{vendor.region}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{vendor.workforce_count || 0} staff
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{routingMode === 'multi' && (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-200">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectionMode('single');
|
||||||
|
setShowVendorSelector(true);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{selectedVendors.length === 0 ? 'Choose Vendor' : 'Change Vendor'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectionMode('multi');
|
||||||
|
setShowVendorSelector(true);
|
||||||
|
}}
|
||||||
|
className="text-xs bg-purple-50 border-purple-300 text-purple-700 hover:bg-purple-100"
|
||||||
|
>
|
||||||
|
<Zap className="w-3 h-3 mr-1" />
|
||||||
|
Multi-Vendor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
{routingMode === 'multi' && (
|
||||||
|
<div className="bg-purple-50 border-2 border-purple-200 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-purple-800">
|
||||||
|
<strong>Multi-Vendor Mode:</strong> Order sent to all selected vendors.
|
||||||
|
First to confirm gets the job.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRapid && (
|
||||||
|
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-red-800">
|
||||||
|
<strong>RAPID Priority:</strong> This order will be marked urgent with priority notification.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Vendor Selector Dialog */}
|
||||||
|
<Dialog open={showVendorSelector} onOpenChange={setShowVendorSelector}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
{selectionMode === 'multi' ? (
|
||||||
|
<>
|
||||||
|
<Zap className="w-6 h-6 text-purple-600" />
|
||||||
|
Select Multiple Vendors
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-6 h-6 text-blue-600" />
|
||||||
|
Select Vendor
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectionMode === 'multi'
|
||||||
|
? 'Select multiple vendors to send this order to all at once. First to confirm gets the job.'
|
||||||
|
: 'Choose which vendor should receive this order'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
|
{allVendors.map((vendor) => {
|
||||||
|
const isSelected = selectedVendors.some(v => v.id === vendor.id);
|
||||||
|
const isPreferred = vendor.id === preferredVendor?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vendor.id}
|
||||||
|
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-slate-200 hover:border-blue-300 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleVendorSelect(vendor)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900">
|
||||||
|
{vendor.doing_business_as || vendor.legal_name}
|
||||||
|
</h3>
|
||||||
|
{isPreferred && (
|
||||||
|
<Badge className="bg-blue-600 text-white">
|
||||||
|
<Award className="w-3 h-3 mr-1" />
|
||||||
|
Preferred
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isSelected && selectionMode === 'multi' && (
|
||||||
|
<Badge className="bg-green-600 text-white">
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Selected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
|
{vendor.region && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
|
{vendor.region}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{vendor.service_specialty && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{vendor.service_specialty}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{vendor.workforce_count || 0} staff
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||||
|
4.9
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||||
|
98% fill rate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{allVendors.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No vendors available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectionMode === 'multi' && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{selectedVendors.length} vendor{selectedVendors.length !== 1 ? 's' : ''} selected
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleMultiVendorDone}
|
||||||
|
disabled={selectedVendors.length === 0}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
|
Confirm Selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
frontend-web/src/components/notifications/NotificationEngine.jsx
Normal file
247
frontend-web/src/components/notifications/NotificationEngine.jsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automated Notification Engine
|
||||||
|
* Monitors events and triggers notifications based on configured preferences
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function NotificationEngine() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: events = [] } = useQuery({
|
||||||
|
queryKey: ['events-notifications'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
refetchInterval: 60000, // Check every minute
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users = [] } = useQuery({
|
||||||
|
queryKey: ['users-notifications'],
|
||||||
|
queryFn: () => base44.entities.User.list(),
|
||||||
|
refetchInterval: 300000, // Check every 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const createNotification = async (userId, title, description, activityType, relatedId = null) => {
|
||||||
|
try {
|
||||||
|
await base44.entities.ActivityLog.create({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
activity_type: activityType,
|
||||||
|
user_id: userId,
|
||||||
|
is_read: false,
|
||||||
|
related_entity_id: relatedId,
|
||||||
|
icon_type: activityType.includes('event') ? 'calendar' : activityType.includes('invoice') ? 'invoice' : 'user',
|
||||||
|
icon_color: 'blue',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create notification:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendEmail = async (to, subject, body, userPreferences) => {
|
||||||
|
if (!userPreferences?.email_notifications) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send email:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shift assignment notifications
|
||||||
|
useEffect(() => {
|
||||||
|
const notifyStaffAssignments = async () => {
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.assigned_staff || event.assigned_staff.length === 0) continue;
|
||||||
|
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
const user = users.find(u => u.email === staff.email);
|
||||||
|
if (!user) continue;
|
||||||
|
|
||||||
|
const prefs = user.notification_preferences || {};
|
||||||
|
if (!prefs.shift_assignments) continue;
|
||||||
|
|
||||||
|
// Check if notification already sent (within last 24h)
|
||||||
|
const recentNotifs = await base44.entities.ActivityLog.filter({
|
||||||
|
user_id: user.id,
|
||||||
|
activity_type: 'staff_assigned',
|
||||||
|
related_entity_id: event.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyNotified = recentNotifs.some(n => {
|
||||||
|
const notifDate = new Date(n.created_date);
|
||||||
|
const hoursSince = (Date.now() - notifDate.getTime()) / (1000 * 60 * 60);
|
||||||
|
return hoursSince < 24;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alreadyNotified) continue;
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
user.id,
|
||||||
|
'🎯 New Shift Assignment',
|
||||||
|
`You've been assigned to ${event.event_name} on ${new Date(event.date).toLocaleDateString()}`,
|
||||||
|
'staff_assigned',
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
staff.email,
|
||||||
|
`New Shift Assignment - ${event.event_name}`,
|
||||||
|
`Hello ${staff.staff_name},\n\nYou've been assigned to work at ${event.event_name}.\n\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nPlease confirm your availability in the KROW platform.\n\nThank you!`,
|
||||||
|
prefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && users.length > 0) {
|
||||||
|
notifyStaffAssignments();
|
||||||
|
}
|
||||||
|
}, [events, users]);
|
||||||
|
|
||||||
|
// Shift reminder (24 hours before)
|
||||||
|
useEffect(() => {
|
||||||
|
const sendShiftReminders = async () => {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const tomorrowEnd = new Date(tomorrow);
|
||||||
|
tomorrowEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
if (eventDate < tomorrow || eventDate > tomorrowEnd) continue;
|
||||||
|
if (!event.assigned_staff || event.assigned_staff.length === 0) continue;
|
||||||
|
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
const user = users.find(u => u.email === staff.email);
|
||||||
|
if (!user) continue;
|
||||||
|
|
||||||
|
const prefs = user.notification_preferences || {};
|
||||||
|
if (!prefs.shift_reminders) continue;
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
user.id,
|
||||||
|
'⏰ Shift Reminder',
|
||||||
|
`Reminder: Your shift at ${event.event_name} is tomorrow`,
|
||||||
|
'event_updated',
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
staff.email,
|
||||||
|
`Shift Reminder - Tomorrow at ${event.event_name}`,
|
||||||
|
`Hello ${staff.staff_name},\n\nThis is a reminder that you have a shift tomorrow:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nSee you there!`,
|
||||||
|
prefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && users.length > 0) {
|
||||||
|
sendShiftReminders();
|
||||||
|
}
|
||||||
|
}, [events, users]);
|
||||||
|
|
||||||
|
// Client upcoming event notifications (3 days before)
|
||||||
|
useEffect(() => {
|
||||||
|
const notifyClientsUpcomingEvents = async () => {
|
||||||
|
const threeDaysFromNow = new Date();
|
||||||
|
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
|
||||||
|
threeDaysFromNow.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const threeDaysEnd = new Date(threeDaysFromNow);
|
||||||
|
threeDaysEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
if (eventDate < threeDaysFromNow || eventDate > threeDaysEnd) continue;
|
||||||
|
|
||||||
|
const clientUser = users.find(u =>
|
||||||
|
u.email === event.client_email ||
|
||||||
|
(u.role === 'client' && u.full_name === event.client_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!clientUser) continue;
|
||||||
|
|
||||||
|
const prefs = clientUser.notification_preferences || {};
|
||||||
|
if (!prefs.upcoming_events) continue;
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
clientUser.id,
|
||||||
|
'📅 Upcoming Event',
|
||||||
|
`Your event "${event.event_name}" is in 3 days`,
|
||||||
|
'event_created',
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
clientUser.email,
|
||||||
|
`Upcoming Event Reminder - ${event.event_name}`,
|
||||||
|
`Hello,\n\nThis is a reminder that your event is coming up in 3 days:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Assigned: ${event.assigned_staff?.length || 0}/${event.requested || 0}\n\nIf you need to make any changes, please log into your KROW account.`,
|
||||||
|
prefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && users.length > 0) {
|
||||||
|
notifyClientsUpcomingEvents();
|
||||||
|
}
|
||||||
|
}, [events, users]);
|
||||||
|
|
||||||
|
// Vendor new lead notifications (new events without vendor assignment)
|
||||||
|
useEffect(() => {
|
||||||
|
const notifyVendorsNewLeads = async () => {
|
||||||
|
const newEvents = events.filter(e =>
|
||||||
|
e.status === 'Draft' || e.status === 'Pending'
|
||||||
|
);
|
||||||
|
|
||||||
|
const vendorUsers = users.filter(u => u.role === 'vendor');
|
||||||
|
|
||||||
|
for (const event of newEvents) {
|
||||||
|
for (const vendor of vendorUsers) {
|
||||||
|
const prefs = vendor.notification_preferences || {};
|
||||||
|
if (!prefs.new_leads) continue;
|
||||||
|
|
||||||
|
// Check if already notified
|
||||||
|
const recentNotifs = await base44.entities.ActivityLog.filter({
|
||||||
|
user_id: vendor.id,
|
||||||
|
activity_type: 'event_created',
|
||||||
|
related_entity_id: event.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentNotifs.length > 0) continue;
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
vendor.id,
|
||||||
|
'🎯 New Lead Available',
|
||||||
|
`New opportunity: ${event.event_name} needs ${event.requested || 0} staff`,
|
||||||
|
'event_created',
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
vendor.email,
|
||||||
|
`New Staffing Opportunity - ${event.event_name}`,
|
||||||
|
`Hello,\n\nA new staffing opportunity is available:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Needed: ${event.requested || 0}\n\nLog in to KROW to submit your proposal.`,
|
||||||
|
prefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && users.length > 0) {
|
||||||
|
notifyVendorsNewLeads();
|
||||||
|
}
|
||||||
|
}, [events, users]);
|
||||||
|
|
||||||
|
return null; // Background service
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationEngine;
|
||||||
141
frontend-web/src/components/onboarding/CompletionStep.jsx
Normal file
141
frontend-web/src/components/onboarding/CompletionStep.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { CheckCircle, User, FileText, BookOpen, Sparkles } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export default function CompletionStep({ data, onComplete, onBack, isSubmitting }) {
|
||||||
|
const { profile, documents, training } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Sparkles className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 mb-2">You're All Set! 🎉</h2>
|
||||||
|
<p className="text-slate-500">Review your information before completing onboarding</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Profile Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<User className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">Profile Information</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Name</p>
|
||||||
|
<p className="font-medium">{profile.full_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Email</p>
|
||||||
|
<p className="font-medium">{profile.email}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Position</p>
|
||||||
|
<p className="font-medium">{profile.position}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Location</p>
|
||||||
|
<p className="font-medium">{profile.city}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Documents Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileText className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">Documents Uploaded</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{documents.map((doc, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{doc.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{documents.length === 0 && (
|
||||||
|
<p className="text-sm text-slate-500">No documents uploaded</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Training Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<BookOpen className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">Training Completed</h3>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{training.completed.length} training modules completed
|
||||||
|
</p>
|
||||||
|
{training.acknowledged && (
|
||||||
|
<Badge className="mt-2 bg-green-500">Compliance Acknowledged</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Steps */}
|
||||||
|
<Card className="bg-gradient-to-r from-blue-50 to-purple-50 border-blue-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">What Happens Next?</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-slate-600">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>Your profile will be activated and available for shift assignments</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>You'll receive an email confirmation with your login credentials</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>Our team will review your documents within 24-48 hours</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>You can start accepting shift invitations immediately</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onComplete}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="bg-gradient-to-r from-[#0A39DF] to-blue-600"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Creating Profile..." : "Complete Onboarding"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
frontend-web/src/components/onboarding/DocumentUploadStep.jsx
Normal file
159
frontend-web/src/components/onboarding/DocumentUploadStep.jsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Upload, FileText, CheckCircle, X } from "lucide-react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const requiredDocuments = [
|
||||||
|
{ id: 'id', name: 'Government ID', required: true, description: 'Driver\'s license or passport' },
|
||||||
|
{ id: 'certification', name: 'Certifications', required: false, description: 'Food handler, TIPS, etc.' },
|
||||||
|
{ id: 'background_check', name: 'Background Check', required: false, description: 'If available' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DocumentUploadStep({ data, onNext, onBack }) {
|
||||||
|
const [documents, setDocuments] = useState(data || []);
|
||||||
|
const [uploading, setUploading] = useState({});
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleFileUpload = async (docType, file) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(prev => ({ ...prev, [docType]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||||
|
|
||||||
|
const newDoc = {
|
||||||
|
type: docType,
|
||||||
|
name: file.name,
|
||||||
|
url: file_url,
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setDocuments(prev => {
|
||||||
|
const filtered = prev.filter(d => d.type !== docType);
|
||||||
|
return [...filtered, newDoc];
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ Document Uploaded",
|
||||||
|
description: `${file.name} uploaded successfully`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "❌ Upload Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(prev => ({ ...prev, [docType]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveDocument = (docType) => {
|
||||||
|
setDocuments(prev => prev.filter(d => d.type !== docType));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const hasRequiredDocs = requiredDocuments
|
||||||
|
.filter(doc => doc.required)
|
||||||
|
.every(doc => documents.some(d => d.type === doc.id));
|
||||||
|
|
||||||
|
if (!hasRequiredDocs) {
|
||||||
|
toast({
|
||||||
|
title: "⚠️ Missing Required Documents",
|
||||||
|
description: "Please upload all required documents before continuing",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onNext({ type: 'documents', data: documents });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUploadedDoc = (docType) => documents.find(d => d.type === docType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-2">Document Upload</h2>
|
||||||
|
<p className="text-sm text-slate-500">Upload required documents for compliance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{requiredDocuments.map(doc => {
|
||||||
|
const uploadedDoc = getUploadedDoc(doc.id);
|
||||||
|
const isUploading = uploading[doc.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={doc.id} className={uploadedDoc ? "border-green-500 bg-green-50" : ""}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Label className="font-semibold">
|
||||||
|
{doc.name}
|
||||||
|
{doc.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{uploadedDoc && (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">{doc.description}</p>
|
||||||
|
|
||||||
|
{uploadedDoc && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-slate-500" />
|
||||||
|
<span className="text-sm text-slate-700">{uploadedDoc.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveDocument(doc.id)}
|
||||||
|
className="ml-2 text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`upload-${doc.id}`}
|
||||||
|
className={`cursor-pointer inline-flex items-center px-4 py-2 rounded-lg ${
|
||||||
|
uploadedDoc
|
||||||
|
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||||
|
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{isUploading ? "Uploading..." : uploadedDoc ? "Replace" : "Upload"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`upload-${doc.id}`}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
onChange={(e) => handleFileUpload(doc.id, e.target.files[0])}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNext} className="bg-[#0A39DF]">
|
||||||
|
Continue to Training
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
frontend-web/src/components/onboarding/ProfileSetupStep.jsx
Normal file
193
frontend-web/src/components/onboarding/ProfileSetupStep.jsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { User, Briefcase, MapPin } from "lucide-react";
|
||||||
|
|
||||||
|
export default function ProfileSetupStep({ data, onNext, currentUser }) {
|
||||||
|
const [profile, setProfile] = useState({
|
||||||
|
full_name: data.full_name || currentUser?.full_name || "",
|
||||||
|
email: data.email || currentUser?.email || "",
|
||||||
|
phone: data.phone || "",
|
||||||
|
address: data.address || "",
|
||||||
|
city: data.city || "",
|
||||||
|
position: data.position || "",
|
||||||
|
department: data.department || "",
|
||||||
|
hub_location: data.hub_location || "",
|
||||||
|
employment_type: data.employment_type || "Full Time",
|
||||||
|
english_level: data.english_level || "Fluent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onNext({ type: 'profile', data: profile });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
setProfile(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-2">Profile Setup</h2>
|
||||||
|
<p className="text-sm text-slate-500">Tell us about yourself</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Personal Information */}
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>Personal Information</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="full_name">Full Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="full_name"
|
||||||
|
value={profile.full_name}
|
||||||
|
onChange={(e) => handleChange('full_name', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email Address *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={profile.email}
|
||||||
|
onChange={(e) => handleChange('email', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="phone">Phone Number *</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
value={profile.phone}
|
||||||
|
onChange={(e) => handleChange('phone', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="(555) 123-4567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="city">City *</Label>
|
||||||
|
<Input
|
||||||
|
id="city"
|
||||||
|
value={profile.city}
|
||||||
|
onChange={(e) => handleChange('city', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="San Francisco"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label htmlFor="address">Street Address</Label>
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
value={profile.address}
|
||||||
|
onChange={(e) => handleChange('address', e.target.value)}
|
||||||
|
placeholder="123 Main St"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employment Details */}
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
<span>Employment Details</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="position">Position/Role *</Label>
|
||||||
|
<Input
|
||||||
|
id="position"
|
||||||
|
value={profile.position}
|
||||||
|
onChange={(e) => handleChange('position', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Server, Chef, Bartender"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="department">Department</Label>
|
||||||
|
<Select value={profile.department} onValueChange={(value) => handleChange('department', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select department" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Operations">Operations</SelectItem>
|
||||||
|
<SelectItem value="Kitchen">Kitchen</SelectItem>
|
||||||
|
<SelectItem value="Service">Service</SelectItem>
|
||||||
|
<SelectItem value="Bar">Bar</SelectItem>
|
||||||
|
<SelectItem value="Events">Events</SelectItem>
|
||||||
|
<SelectItem value="Catering">Catering</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="employment_type">Employment Type *</Label>
|
||||||
|
<Select value={profile.employment_type} onValueChange={(value) => handleChange('employment_type', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Full Time">Full Time</SelectItem>
|
||||||
|
<SelectItem value="Part Time">Part Time</SelectItem>
|
||||||
|
<SelectItem value="On call">On Call</SelectItem>
|
||||||
|
<SelectItem value="Seasonal">Seasonal</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="english_level">English Proficiency</Label>
|
||||||
|
<Select value={profile.english_level} onValueChange={(value) => handleChange('english_level', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Fluent">Fluent</SelectItem>
|
||||||
|
<SelectItem value="Intermediate">Intermediate</SelectItem>
|
||||||
|
<SelectItem value="Basic">Basic</SelectItem>
|
||||||
|
<SelectItem value="None">None</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>Work Location</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="hub_location">Preferred Hub/Location</Label>
|
||||||
|
<Input
|
||||||
|
id="hub_location"
|
||||||
|
value={profile.hub_location}
|
||||||
|
onChange={(e) => handleChange('hub_location', e.target.value)}
|
||||||
|
placeholder="e.g., Downtown SF, Bay Area"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button type="submit" className="bg-[#0A39DF]">
|
||||||
|
Continue to Documents
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
frontend-web/src/components/onboarding/TrainingStep.jsx
Normal file
173
frontend-web/src/components/onboarding/TrainingStep.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { CheckCircle, Circle, Play, BookOpen } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
const trainingModules = [
|
||||||
|
{
|
||||||
|
id: 'safety',
|
||||||
|
title: 'Workplace Safety',
|
||||||
|
duration: '15 min',
|
||||||
|
required: true,
|
||||||
|
description: 'Learn about workplace safety protocols and emergency procedures',
|
||||||
|
topics: ['Emergency exits', 'Fire safety', 'First aid basics', 'Reporting incidents'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hygiene',
|
||||||
|
title: 'Food Safety & Hygiene',
|
||||||
|
duration: '20 min',
|
||||||
|
required: true,
|
||||||
|
description: 'Essential food handling and hygiene standards',
|
||||||
|
topics: ['Handwashing', 'Cross-contamination', 'Temperature control', 'Storage guidelines'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customer_service',
|
||||||
|
title: 'Customer Service Excellence',
|
||||||
|
duration: '10 min',
|
||||||
|
required: true,
|
||||||
|
description: 'Delivering outstanding service to clients and guests',
|
||||||
|
topics: ['Communication skills', 'Handling complaints', 'Professional etiquette', 'Teamwork'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compliance',
|
||||||
|
title: 'Compliance & Policies',
|
||||||
|
duration: '12 min',
|
||||||
|
required: true,
|
||||||
|
description: 'Company policies and legal compliance requirements',
|
||||||
|
topics: ['Code of conduct', 'Anti-discrimination', 'Data privacy', 'Time tracking'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TrainingStep({ data, onNext, onBack }) {
|
||||||
|
const [training, setTraining] = useState(data || { completed: [], acknowledged: false });
|
||||||
|
|
||||||
|
const handleModuleComplete = (moduleId) => {
|
||||||
|
setTraining(prev => ({
|
||||||
|
...prev,
|
||||||
|
completed: prev.completed.includes(moduleId)
|
||||||
|
? prev.completed.filter(id => id !== moduleId)
|
||||||
|
: [...prev.completed, moduleId],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcknowledge = (checked) => {
|
||||||
|
setTraining(prev => ({ ...prev, acknowledged: checked }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const allRequired = trainingModules
|
||||||
|
.filter(m => m.required)
|
||||||
|
.every(m => training.completed.includes(m.id));
|
||||||
|
|
||||||
|
if (!allRequired || !training.acknowledged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onNext({ type: 'training', data: training });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isComplete = (moduleId) => training.completed.includes(moduleId);
|
||||||
|
const allRequiredComplete = trainingModules
|
||||||
|
.filter(m => m.required)
|
||||||
|
.every(m => training.completed.includes(m.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-2">Compliance Training</h2>
|
||||||
|
<p className="text-sm text-slate-500">Complete required training modules to ensure readiness</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{trainingModules.map(module => (
|
||||||
|
<Card
|
||||||
|
key={module.id}
|
||||||
|
className={isComplete(module.id) ? "border-green-500 bg-green-50" : ""}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{isComplete(module.id) ? (
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-6 h-6 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">
|
||||||
|
{module.title}
|
||||||
|
{module.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500">{module.duration} · {module.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="text-sm text-slate-600 mb-3 ml-4 space-y-1">
|
||||||
|
{module.topics.map((topic, idx) => (
|
||||||
|
<li key={idx} className="list-disc">{topic}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isComplete(module.id) ? "outline" : "default"}
|
||||||
|
onClick={() => handleModuleComplete(module.id)}
|
||||||
|
className={isComplete(module.id) ? "" : "bg-[#0A39DF]"}
|
||||||
|
>
|
||||||
|
{isComplete(module.id) ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Completed
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Start Training
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allRequiredComplete && (
|
||||||
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="acknowledge"
|
||||||
|
checked={training.acknowledged}
|
||||||
|
onCheckedChange={handleAcknowledge}
|
||||||
|
/>
|
||||||
|
<label htmlFor="acknowledge" className="text-sm text-slate-700 cursor-pointer">
|
||||||
|
I acknowledge that I have completed the required training modules and understand the
|
||||||
|
policies, procedures, and safety guidelines outlined above. I agree to follow all
|
||||||
|
company policies and maintain compliance standards.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!allRequiredComplete || !training.acknowledged}
|
||||||
|
className="bg-[#0A39DF]"
|
||||||
|
>
|
||||||
|
Complete Onboarding
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
frontend-web/src/components/orders/OrderStatusBadge.jsx
Normal file
149
frontend-web/src/components/orders/OrderStatusBadge.jsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Zap, Clock, AlertTriangle, CheckCircle, XCircle, Package } from "lucide-react";
|
||||||
|
|
||||||
|
// Comprehensive color coding system
|
||||||
|
export const ORDER_STATUSES = {
|
||||||
|
RAPID: {
|
||||||
|
color: "bg-red-600 text-white border-0",
|
||||||
|
dotColor: "bg-red-400",
|
||||||
|
icon: Zap,
|
||||||
|
label: "RAPID",
|
||||||
|
priority: 1,
|
||||||
|
description: "Must be filled immediately"
|
||||||
|
},
|
||||||
|
REQUESTED: {
|
||||||
|
color: "bg-yellow-500 text-white border-0",
|
||||||
|
dotColor: "bg-yellow-300",
|
||||||
|
icon: Clock,
|
||||||
|
label: "Requested",
|
||||||
|
priority: 2,
|
||||||
|
description: "Pending vendor review"
|
||||||
|
},
|
||||||
|
PARTIALLY_ASSIGNED: {
|
||||||
|
color: "bg-orange-500 text-white border-0",
|
||||||
|
dotColor: "bg-orange-300",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
label: "Partially Assigned",
|
||||||
|
priority: 3,
|
||||||
|
description: "Missing staff"
|
||||||
|
},
|
||||||
|
FULLY_ASSIGNED: {
|
||||||
|
color: "bg-green-600 text-white border-0",
|
||||||
|
dotColor: "bg-green-400",
|
||||||
|
icon: CheckCircle,
|
||||||
|
label: "Fully Assigned",
|
||||||
|
priority: 4,
|
||||||
|
description: "All staff confirmed"
|
||||||
|
},
|
||||||
|
AT_RISK: {
|
||||||
|
color: "bg-purple-600 text-white border-0",
|
||||||
|
dotColor: "bg-purple-400",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
label: "At Risk",
|
||||||
|
priority: 2,
|
||||||
|
description: "Workers not confirmed or declined"
|
||||||
|
},
|
||||||
|
COMPLETED: {
|
||||||
|
color: "bg-slate-400 text-white border-0",
|
||||||
|
dotColor: "bg-slate-300",
|
||||||
|
icon: CheckCircle,
|
||||||
|
label: "Completed",
|
||||||
|
priority: 5,
|
||||||
|
description: "Invoice and approval pending"
|
||||||
|
},
|
||||||
|
PERMANENT: {
|
||||||
|
color: "bg-purple-700 text-white border-0",
|
||||||
|
dotColor: "bg-purple-500",
|
||||||
|
icon: Package,
|
||||||
|
label: "Permanent",
|
||||||
|
priority: 3,
|
||||||
|
description: "Permanent staffing"
|
||||||
|
},
|
||||||
|
CANCELED: {
|
||||||
|
color: "bg-slate-500 text-white border-0",
|
||||||
|
dotColor: "bg-slate-300",
|
||||||
|
icon: XCircle,
|
||||||
|
label: "Canceled",
|
||||||
|
priority: 6,
|
||||||
|
description: "Order canceled"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getOrderStatus(order) {
|
||||||
|
// Check if RAPID
|
||||||
|
if (order.is_rapid || order.event_name?.includes("RAPID")) {
|
||||||
|
return ORDER_STATUSES.RAPID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedCount = order.assigned_staff?.length || 0;
|
||||||
|
const requestedCount = order.requested || 0;
|
||||||
|
|
||||||
|
// Check completion status
|
||||||
|
if (order.status === "Completed") {
|
||||||
|
return ORDER_STATUSES.COMPLETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === "Canceled") {
|
||||||
|
return ORDER_STATUSES.CANCELED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if permanent
|
||||||
|
if (order.contract_type === "Permanent" || order.event_type === "Permanent") {
|
||||||
|
return ORDER_STATUSES.PERMANENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check assignment status
|
||||||
|
if (requestedCount > 0) {
|
||||||
|
if (assignedCount >= requestedCount) {
|
||||||
|
return ORDER_STATUSES.FULLY_ASSIGNED;
|
||||||
|
} else if (assignedCount > 0) {
|
||||||
|
return ORDER_STATUSES.PARTIALLY_ASSIGNED;
|
||||||
|
} else {
|
||||||
|
return ORDER_STATUSES.REQUESTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to requested
|
||||||
|
return ORDER_STATUSES.REQUESTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderStatusBadge({ order, size = "default", showIcon = true, showDot = true, className = "" }) {
|
||||||
|
const status = getOrderStatus(order);
|
||||||
|
const Icon = status.icon;
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "px-2 py-0.5 text-[10px]",
|
||||||
|
default: "px-3 py-1 text-xs",
|
||||||
|
lg: "px-4 py-1.5 text-sm"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className={`${status.color} ${sizeClasses[size]} font-semibold shadow-sm whitespace-nowrap flex items-center gap-1.5 ${className}`}
|
||||||
|
title={status.description}
|
||||||
|
>
|
||||||
|
{showDot && (
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${status.dotColor} animate-pulse`} />
|
||||||
|
)}
|
||||||
|
{showIcon && <Icon className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />}
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to sort orders by priority
|
||||||
|
export function sortOrdersByPriority(orders) {
|
||||||
|
return [...orders].sort((a, b) => {
|
||||||
|
const statusA = getOrderStatus(a);
|
||||||
|
const statusB = getOrderStatus(b);
|
||||||
|
|
||||||
|
// First by priority
|
||||||
|
if (statusA.priority !== statusB.priority) {
|
||||||
|
return statusA.priority - statusB.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then by date (most recent first)
|
||||||
|
return new Date(b.date || b.created_date) - new Date(a.date || a.created_date);
|
||||||
|
});
|
||||||
|
}
|
||||||
332
frontend-web/src/components/orders/RapidOrderChat.jsx
Normal file
332
frontend-web/src/components/orders/RapidOrderChat.jsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
export default function RapidOrderChat({ onOrderCreated }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [conversation, setConversation] = useState([]);
|
||||||
|
const [detectedOrder, setDetectedOrder] = useState(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-rapid'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: businesses } = useQuery({
|
||||||
|
queryKey: ['user-businesses'],
|
||||||
|
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
|
||||||
|
enabled: !!user,
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRapidOrderMutation = useMutation({
|
||||||
|
mutationFn: (orderData) => base44.entities.Event.create(orderData),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ RAPID Order Created",
|
||||||
|
description: "Order sent to preferred vendor with priority notification",
|
||||||
|
});
|
||||||
|
if (onOrderCreated) onOrderCreated(data);
|
||||||
|
// Reset
|
||||||
|
setConversation([]);
|
||||||
|
setDetectedOrder(null);
|
||||||
|
setMessage("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyzeMessage = async (msg) => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
// Add user message to conversation
|
||||||
|
setConversation(prev => [...prev, { role: 'user', content: msg }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use AI to parse the message
|
||||||
|
const response = await base44.integrations.Core.InvokeLLM({
|
||||||
|
prompt: `You are an order assistant. Analyze this message and extract order details:
|
||||||
|
|
||||||
|
Message: "${msg}"
|
||||||
|
Current user: ${user?.full_name}
|
||||||
|
User's locations: ${businesses.map(b => b.business_name).join(', ')}
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
|
||||||
|
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
|
||||||
|
3. Number of staff (if mentioned)
|
||||||
|
4. Time frame (if mentioned)
|
||||||
|
5. Location (if mentioned, otherwise use first available location)
|
||||||
|
|
||||||
|
Return a concise summary.`,
|
||||||
|
response_json_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
is_urgent: { type: "boolean" },
|
||||||
|
role: { type: "string" },
|
||||||
|
count: { type: "number" },
|
||||||
|
location: { type: "string" },
|
||||||
|
time_mentioned: { type: "boolean" },
|
||||||
|
start_time: { type: "string" },
|
||||||
|
end_time: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = response;
|
||||||
|
const primaryLocation = businesses[0]?.business_name || "Primary Location";
|
||||||
|
|
||||||
|
const order = {
|
||||||
|
is_rapid: parsed.is_urgent || true,
|
||||||
|
role: parsed.role || "Staff Member",
|
||||||
|
count: parsed.count || 1,
|
||||||
|
location: parsed.location || primaryLocation,
|
||||||
|
start_time: parsed.start_time || "ASAP",
|
||||||
|
end_time: parsed.end_time || "End of shift",
|
||||||
|
business_name: primaryLocation,
|
||||||
|
hub: businesses[0]?.hub_building || "Main Hub"
|
||||||
|
};
|
||||||
|
|
||||||
|
setDetectedOrder(order);
|
||||||
|
|
||||||
|
// AI response
|
||||||
|
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nTime: ${order.start_time} → ${order.end_time}`;
|
||||||
|
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: aiMessage,
|
||||||
|
showConfirm: true
|
||||||
|
}]);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
|
||||||
|
}]);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
analyzeMessage(message);
|
||||||
|
setMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmOrder = () => {
|
||||||
|
if (!detectedOrder) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const orderData = {
|
||||||
|
event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
|
||||||
|
is_rapid: true,
|
||||||
|
status: "Pending",
|
||||||
|
business_name: detectedOrder.business_name,
|
||||||
|
hub: detectedOrder.hub,
|
||||||
|
event_location: detectedOrder.location,
|
||||||
|
date: now.toISOString().split('T')[0],
|
||||||
|
requested: detectedOrder.count,
|
||||||
|
client_name: user?.full_name,
|
||||||
|
client_email: user?.email,
|
||||||
|
notes: `RAPID ORDER - ${detectedOrder.start_time} to ${detectedOrder.end_time}`,
|
||||||
|
shifts: [{
|
||||||
|
shift_name: "Emergency Shift",
|
||||||
|
roles: [{
|
||||||
|
role: detectedOrder.role,
|
||||||
|
count: detectedOrder.count,
|
||||||
|
start_time: "ASAP",
|
||||||
|
end_time: "End of shift"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
createRapidOrderMutation.mutate(orderData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOrder = () => {
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "Please describe what you'd like to change."
|
||||||
|
}]);
|
||||||
|
setDetectedOrder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 border-2 border-red-300 shadow-xl">
|
||||||
|
<CardHeader className="border-b border-red-200 bg-white/50 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<Zap className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-xl font-bold text-red-700 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5" />
|
||||||
|
RAPID Order Assistant
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-xs text-red-600 mt-1">Emergency staffing in minutes</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
|
||||||
|
URGENT
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="space-y-4 mb-6 max-h-[400px] overflow-y-auto">
|
||||||
|
{conversation.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-lg">
|
||||||
|
<Zap className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 mb-2">Need staff urgently?</h3>
|
||||||
|
<p className="text-sm text-slate-600 mb-4">Just describe what you need, I'll handle the rest</p>
|
||||||
|
<div className="text-left max-w-md mx-auto space-y-2">
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
|
||||||
|
<strong>Example:</strong> "We had a call out. Need 2 cooks ASAP"
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
|
||||||
|
<strong>Example:</strong> "Emergency! Need bartender for tonight"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{conversation.map((msg, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={idx}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div className={`max-w-[80%] ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
|
||||||
|
: 'bg-white border-2 border-red-200'
|
||||||
|
} rounded-2xl p-4 shadow-md`}>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
|
||||||
|
<Sparkles className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-red-600">AI Assistant</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={`text-sm whitespace-pre-line ${msg.role === 'user' ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{msg.showConfirm && detectedOrder && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-3 bg-gradient-to-br from-slate-50 to-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Staff Needed</p>
|
||||||
|
<p className="font-bold text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<MapPin className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Location</p>
|
||||||
|
<p className="font-bold text-slate-900">{detectedOrder.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs col-span-2">
|
||||||
|
<Clock className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Time</p>
|
||||||
|
<p className="font-bold text-slate-900">{detectedOrder.start_time} → {detectedOrder.end_time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmOrder}
|
||||||
|
disabled={createRapidOrderMutation.isPending}
|
||||||
|
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleEditOrder}
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 border-red-300 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4 mr-2" />
|
||||||
|
EDIT
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{isProcessing && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex justify-start"
|
||||||
|
>
|
||||||
|
<div className="bg-white border-2 border-red-200 rounded-2xl p-4 shadow-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
|
||||||
|
<Sparkles className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-600">Processing your request...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||||
|
placeholder="Describe what you need... (e.g., 'Need 2 cooks ASAP')"
|
||||||
|
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base"
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!message.trim() || isProcessing}
|
||||||
|
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helper Text */}
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-xs text-blue-800">
|
||||||
|
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
|
||||||
|
AI will auto-detect your location and send to your preferred vendor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
374
frontend-web/src/components/orders/SmartAssignModal.jsx
Normal file
374
frontend-web/src/components/orders/SmartAssignModal.jsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Sparkles, Star, MapPin, Clock, Award, TrendingUp, AlertCircle, CheckCircle, Zap, Users, RefreshCw } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
|
||||||
|
export default function SmartAssignModal({ isOpen, onClose, event, roleNeeded, countNeeded }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedWorkers, setSelectedWorkers] = useState([]);
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [aiRecommendations, setAiRecommendations] = useState(null);
|
||||||
|
|
||||||
|
const { data: allStaff = [] } = useQuery({
|
||||||
|
queryKey: ['staff-smart-assign'],
|
||||||
|
queryFn: () => base44.entities.Staff.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allEvents = [] } = useQuery({
|
||||||
|
queryKey: ['events-conflict-check'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smart filtering
|
||||||
|
const eligibleStaff = useMemo(() => {
|
||||||
|
if (!event || !roleNeeded) return [];
|
||||||
|
|
||||||
|
return allStaff.filter(worker => {
|
||||||
|
// Role match
|
||||||
|
const hasRole = worker.position === roleNeeded ||
|
||||||
|
worker.position_2 === roleNeeded ||
|
||||||
|
worker.profile_type === "Cross-Trained";
|
||||||
|
|
||||||
|
// Availability check
|
||||||
|
const isAvailable = worker.employment_type !== "Medical Leave" &&
|
||||||
|
worker.action !== "Inactive";
|
||||||
|
|
||||||
|
// Conflict check - check if worker is already assigned
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
const hasConflict = allEvents.some(e => {
|
||||||
|
if (e.id === event.id) return false;
|
||||||
|
const eDate = new Date(e.date);
|
||||||
|
return eDate.toDateString() === eventDate.toDateString() &&
|
||||||
|
e.assigned_staff?.some(s => s.staff_id === worker.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasRole && isAvailable && !hasConflict;
|
||||||
|
});
|
||||||
|
}, [allStaff, event, roleNeeded, allEvents]);
|
||||||
|
|
||||||
|
// Run AI analysis
|
||||||
|
const runSmartAnalysis = async () => {
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = `You are a workforce optimization AI. Analyze these workers and recommend the best ${countNeeded} for this job.
|
||||||
|
|
||||||
|
Event: ${event.event_name}
|
||||||
|
Location: ${event.event_location || event.hub}
|
||||||
|
Role Needed: ${roleNeeded}
|
||||||
|
Quantity: ${countNeeded}
|
||||||
|
|
||||||
|
Workers (JSON):
|
||||||
|
${JSON.stringify(eligibleStaff.map(w => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.employee_name,
|
||||||
|
rating: w.rating || 0,
|
||||||
|
reliability_score: w.reliability_score || 0,
|
||||||
|
total_shifts: w.total_shifts || 0,
|
||||||
|
no_show_count: w.no_show_count || 0,
|
||||||
|
position: w.position,
|
||||||
|
city: w.city,
|
||||||
|
profile_type: w.profile_type
|
||||||
|
})), null, 2)}
|
||||||
|
|
||||||
|
Rank them by:
|
||||||
|
1. Skills match (exact role match gets priority)
|
||||||
|
2. Rating (higher is better)
|
||||||
|
3. Reliability (lower no-shows, higher reliability score)
|
||||||
|
4. Experience (more shifts completed)
|
||||||
|
5. Distance (prefer closer to location)
|
||||||
|
|
||||||
|
Return the top ${countNeeded} worker IDs with brief reasoning.`;
|
||||||
|
|
||||||
|
const response = await base44.integrations.Core.InvokeLLM({
|
||||||
|
prompt,
|
||||||
|
response_json_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
recommendations: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
worker_id: { type: "string" },
|
||||||
|
reason: { type: "string" },
|
||||||
|
score: { type: "number" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recommended = response.recommendations.map(rec => {
|
||||||
|
const worker = eligibleStaff.find(w => w.id === rec.worker_id);
|
||||||
|
return worker ? { ...worker, ai_reason: rec.reason, ai_score: rec.score } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
setAiRecommendations(recommended);
|
||||||
|
setSelectedWorkers(recommended.slice(0, countNeeded));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✨ AI Analysis Complete",
|
||||||
|
description: `Found ${recommended.length} optimal matches`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Analysis Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const assigned_staff = selectedWorkers.map(w => ({
|
||||||
|
staff_id: w.id,
|
||||||
|
staff_name: w.employee_name,
|
||||||
|
role: roleNeeded
|
||||||
|
}));
|
||||||
|
|
||||||
|
return base44.entities.Event.update(event.id, {
|
||||||
|
assigned_staff: [...(event.assigned_staff || []), ...assigned_staff],
|
||||||
|
status: "Confirmed"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Staff Assigned Successfully",
|
||||||
|
description: `${selectedWorkers.length} workers assigned to ${event.event_name}`,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen && eligibleStaff.length > 0 && !aiRecommendations) {
|
||||||
|
runSmartAnalysis();
|
||||||
|
}
|
||||||
|
}, [isOpen, eligibleStaff.length]);
|
||||||
|
|
||||||
|
const toggleWorker = (worker) => {
|
||||||
|
setSelectedWorkers(prev => {
|
||||||
|
const exists = prev.find(w => w.id === worker.id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.filter(w => w.id !== worker.id);
|
||||||
|
} else if (prev.length < countNeeded) {
|
||||||
|
return [...prev, worker];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-3 text-2xl">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-900">Smart Assign (AI Assisted)</span>
|
||||||
|
<p className="text-sm text-slate-600 font-normal mt-1">
|
||||||
|
AI selected the best {countNeeded} {roleNeeded}{countNeeded > 1 ? 's' : ''} for this event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center animate-pulse">
|
||||||
|
<Sparkles className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 mb-2">Analyzing workforce...</h3>
|
||||||
|
<p className="text-sm text-slate-600">AI is finding the optimal matches based on skills, ratings, and availability</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="w-8 h-8 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-blue-700 mb-1">Selected</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-900">{selectedWorkers.length}/{countNeeded}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-2 border-purple-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="w-8 h-8 text-purple-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-purple-700 mb-1">Avg Rating</p>
|
||||||
|
<p className="text-2xl font-bold text-purple-900">
|
||||||
|
{selectedWorkers.length > 0
|
||||||
|
? (selectedWorkers.reduce((sum, w) => sum + (w.rating || 0), 0) / selectedWorkers.length).toFixed(1)
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-green-700 mb-1">Available</p>
|
||||||
|
<p className="text-2xl font-bold text-green-900">{eligibleStaff.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Recommendations */}
|
||||||
|
{aiRecommendations && aiRecommendations.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-bold text-slate-900">AI Recommendations</h3>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={runSmartAnalysis}
|
||||||
|
className="border-purple-300 hover:bg-purple-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Re-analyze
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiRecommendations.map((worker, idx) => {
|
||||||
|
const isSelected = selectedWorkers.some(w => w.id === worker.id);
|
||||||
|
const isOverLimit = selectedWorkers.length >= countNeeded && !isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={worker.id}
|
||||||
|
className={`transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-gradient-to-br from-purple-50 to-indigo-50 border-2 border-purple-400 shadow-lg'
|
||||||
|
: 'bg-white border border-slate-200 hover:border-purple-300 hover:shadow-md'
|
||||||
|
} ${isOverLimit ? 'opacity-50' : ''}`}
|
||||||
|
onClick={() => !isOverLimit && toggleWorker(worker)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3 flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar className="w-12 h-12 border-2 border-purple-300">
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-indigo-600 text-white font-bold">
|
||||||
|
{worker.employee_name?.charAt(0) || 'W'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{idx === 0 && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-6 h-6 bg-yellow-500 rounded-full flex items-center justify-center shadow-md">
|
||||||
|
<Award className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="font-bold text-slate-900">{worker.employee_name}</h4>
|
||||||
|
{idx === 0 && (
|
||||||
|
<Badge className="bg-gradient-to-r from-yellow-500 to-orange-500 text-white text-xs">
|
||||||
|
Top Pick
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{worker.position}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-900">{worker.rating?.toFixed(1) || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||||
|
{worker.total_shifts || 0} shifts
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||||
|
<MapPin className="w-4 h-4 text-blue-600" />
|
||||||
|
{worker.city || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{worker.ai_reason && (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-2 mt-2">
|
||||||
|
<p className="text-xs text-purple-900">
|
||||||
|
<strong className="text-purple-700">AI Insight:</strong> {worker.ai_reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{worker.ai_score && (
|
||||||
|
<Badge className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-bold">
|
||||||
|
{Math.round(worker.ai_score)}/100
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircle className="w-6 h-6 text-purple-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="w-12 h-12 mx-auto mb-3 text-slate-400" />
|
||||||
|
<p className="text-slate-600">No eligible staff found for this role</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => assignMutation.mutate()}
|
||||||
|
disabled={selectedWorkers.length === 0 || assignMutation.isPending}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-bold"
|
||||||
|
>
|
||||||
|
{assignMutation.isPending ? "Assigning..." : `Assign ${selectedWorkers.length} Workers`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend-web/src/components/orders/WorkerConfirmationCard.jsx
Normal file
150
frontend-web/src/components/orders/WorkerConfirmationCard.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { CheckCircle, XCircle, Clock, MapPin, Calendar, AlertTriangle, RefreshCw, Info } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export default function WorkerConfirmationCard({ assignment, event }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const confirmMutation = useMutation({
|
||||||
|
mutationFn: async (status) => {
|
||||||
|
return base44.entities.Assignment.update(assignment.id, {
|
||||||
|
assignment_status: status,
|
||||||
|
confirmed_date: new Date().toISOString()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (_, status) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['assignments'] });
|
||||||
|
toast({
|
||||||
|
title: status === "Confirmed" ? "✅ Shift Confirmed" : "❌ Shift Declined",
|
||||||
|
description: status === "Confirmed"
|
||||||
|
? "You're all set! See you at the event."
|
||||||
|
: "Notified vendor. They'll find a replacement.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (assignment.assignment_status) {
|
||||||
|
case "Confirmed":
|
||||||
|
return "bg-green-100 text-green-700 border-green-300";
|
||||||
|
case "Cancelled":
|
||||||
|
return "bg-red-100 text-red-700 border-red-300";
|
||||||
|
case "Pending":
|
||||||
|
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-700 border-slate-300";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white border-2 border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900">{event.event_name}</h3>
|
||||||
|
{event.is_rapid && (
|
||||||
|
<Badge className="bg-red-600 text-white font-bold text-xs">
|
||||||
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
RAPID
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{assignment.role}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={`border-2 font-semibold ${getStatusColor()}`}>
|
||||||
|
{assignment.assignment_status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Date</p>
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
{event.date ? format(new Date(event.date), "MMM d, yyyy") : "TBD"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Time</p>
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
{assignment.scheduled_start ? format(new Date(assignment.scheduled_start), "h:mm a") : "ASAP"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm col-span-2">
|
||||||
|
<MapPin className="w-4 h-4 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Location</p>
|
||||||
|
<p className="font-semibold text-slate-900">{event.event_location || event.hub}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shift Details */}
|
||||||
|
{event.shifts?.[0] && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Info className="w-4 h-4 text-blue-600" />
|
||||||
|
<span className="text-xs font-bold text-blue-900">Shift Details</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs text-slate-700">
|
||||||
|
{event.shifts[0].uniform_type && (
|
||||||
|
<p><strong>Attire:</strong> {event.shifts[0].uniform_type}</p>
|
||||||
|
)}
|
||||||
|
{event.addons?.meal_provided && (
|
||||||
|
<p><strong>Meal:</strong> Provided</p>
|
||||||
|
)}
|
||||||
|
{event.notes && (
|
||||||
|
<p><strong>Notes:</strong> {event.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
{assignment.assignment_status === "Pending" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => confirmMutation.mutate("Confirmed")}
|
||||||
|
disabled={confirmMutation.isPending}
|
||||||
|
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Accept Shift
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => confirmMutation.mutate("Cancelled")}
|
||||||
|
disabled={confirmMutation.isPending}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 border-2 border-red-300 text-red-600 hover:bg-red-50 font-bold"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assignment.assignment_status === "Confirmed" && (
|
||||||
|
<div className="bg-green-50 border-2 border-green-300 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-green-700">
|
||||||
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
<span className="font-bold text-sm">You're confirmed for this shift!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
frontend-web/src/components/reports/ClientTrendsReport.jsx
Normal file
202
frontend-web/src/components/reports/ClientTrendsReport.jsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, TrendingUp, Users, Star } from "lucide-react";
|
||||||
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export default function ClientTrendsReport({ events, invoices }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Bookings by month
|
||||||
|
const bookingsByMonth = events.reduce((acc, event) => {
|
||||||
|
if (!event.date) return acc;
|
||||||
|
const date = new Date(event.date);
|
||||||
|
const month = date.toLocaleString('default', { month: 'short' });
|
||||||
|
acc[month] = (acc[month] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const monthlyBookings = Object.entries(bookingsByMonth).map(([month, count]) => ({
|
||||||
|
month,
|
||||||
|
bookings: count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Top clients by booking count
|
||||||
|
const clientBookings = events.reduce((acc, event) => {
|
||||||
|
const client = event.business_name || 'Unknown';
|
||||||
|
if (!acc[client]) {
|
||||||
|
acc[client] = { name: client, bookings: 0, revenue: 0 };
|
||||||
|
}
|
||||||
|
acc[client].bookings += 1;
|
||||||
|
acc[client].revenue += event.total || 0;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const topClients = Object.values(clientBookings)
|
||||||
|
.sort((a, b) => b.bookings - a.bookings)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// Client satisfaction (mock data - would come from feedback)
|
||||||
|
const avgSatisfaction = 4.6;
|
||||||
|
const totalClients = new Set(events.map(e => e.business_name).filter(Boolean)).size;
|
||||||
|
const repeatRate = ((events.filter(e => e.is_recurring).length / events.length) * 100).toFixed(1);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const csv = [
|
||||||
|
['Client Trends Report'],
|
||||||
|
['Generated', new Date().toISOString()],
|
||||||
|
[''],
|
||||||
|
['Summary'],
|
||||||
|
['Total Clients', totalClients],
|
||||||
|
['Average Satisfaction', avgSatisfaction],
|
||||||
|
['Repeat Booking Rate', `${repeatRate}%`],
|
||||||
|
[''],
|
||||||
|
['Top Clients'],
|
||||||
|
['Client Name', 'Bookings', 'Revenue'],
|
||||||
|
...topClients.map(c => [c.name, c.bookings, c.revenue.toFixed(2)]),
|
||||||
|
[''],
|
||||||
|
['Monthly Bookings'],
|
||||||
|
['Month', 'Bookings'],
|
||||||
|
...monthlyBookings.map(m => [m.month, m.bookings]),
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `client-trends-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({ title: "✅ Report Exported", description: "Client trends report downloaded as CSV" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Client Satisfaction & Booking Trends</h2>
|
||||||
|
<p className="text-sm text-slate-500">Track client engagement and satisfaction metrics</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Total Clients</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<Users className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Avg Satisfaction</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}/5</p>
|
||||||
|
<div className="flex gap-0.5 mt-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star key={i} className={`w-4 h-4 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
|
||||||
|
<Star className="w-6 h-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Repeat Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Booking Trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Booking Trend Over Time</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={monthlyBookings}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="bookings" stroke="#0A39DF" strokeWidth={2} name="Bookings" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Clients */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Clients by Bookings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={topClients} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" />
|
||||||
|
<YAxis dataKey="name" type="category" width={150} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="bookings" fill="#0A39DF" name="Bookings" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Client List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Client Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topClients.map((client, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-900">{client.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">{client.bookings} bookings</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="font-semibold">
|
||||||
|
${client.revenue.toLocaleString()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
frontend-web/src/components/reports/CustomReportBuilder.jsx
Normal file
333
frontend-web/src/components/reports/CustomReportBuilder.jsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Plus, X } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export default function CustomReportBuilder({ events, staff, invoices }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [reportConfig, setReportConfig] = useState({
|
||||||
|
name: "",
|
||||||
|
dataSource: "events",
|
||||||
|
dateRange: "30",
|
||||||
|
fields: [],
|
||||||
|
filters: [],
|
||||||
|
groupBy: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataSourceFields = {
|
||||||
|
events: ['event_name', 'business_name', 'status', 'date', 'total', 'requested', 'hub'],
|
||||||
|
staff: ['employee_name', 'position', 'department', 'hub_location', 'rating', 'reliability_score'],
|
||||||
|
invoices: ['invoice_number', 'business_name', 'amount', 'status', 'issue_date', 'due_date'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldToggle = (field) => {
|
||||||
|
setReportConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
fields: prev.fields.includes(field)
|
||||||
|
? prev.fields.filter(f => f !== field)
|
||||||
|
: [...prev.fields, field],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateReport = () => {
|
||||||
|
if (!reportConfig.name || reportConfig.fields.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "⚠️ Incomplete Configuration",
|
||||||
|
description: "Please provide a report name and select at least one field.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get data based on source
|
||||||
|
let data = [];
|
||||||
|
if (reportConfig.dataSource === 'events') data = events;
|
||||||
|
else if (reportConfig.dataSource === 'staff') data = staff;
|
||||||
|
else if (reportConfig.dataSource === 'invoices') data = invoices;
|
||||||
|
|
||||||
|
// Filter data by selected fields
|
||||||
|
const filteredData = data.map(item => {
|
||||||
|
const filtered = {};
|
||||||
|
reportConfig.fields.forEach(field => {
|
||||||
|
filtered[field] = item[field] || '-';
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate CSV
|
||||||
|
const headers = reportConfig.fields.join(',');
|
||||||
|
const rows = filteredData.map(item =>
|
||||||
|
reportConfig.fields.map(field => `"${item[field]}"`).join(',')
|
||||||
|
);
|
||||||
|
const csv = [headers, ...rows].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ Report Generated",
|
||||||
|
description: `${reportConfig.name} has been exported successfully.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportJSON = () => {
|
||||||
|
if (!reportConfig.name || reportConfig.fields.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "⚠️ Incomplete Configuration",
|
||||||
|
description: "Please provide a report name and select at least one field.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = [];
|
||||||
|
if (reportConfig.dataSource === 'events') data = events;
|
||||||
|
else if (reportConfig.dataSource === 'staff') data = staff;
|
||||||
|
else if (reportConfig.dataSource === 'invoices') data = invoices;
|
||||||
|
|
||||||
|
const filteredData = data.map(item => {
|
||||||
|
const filtered = {};
|
||||||
|
reportConfig.fields.forEach(field => {
|
||||||
|
filtered[field] = item[field] || null;
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonData = {
|
||||||
|
reportName: reportConfig.name,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
dataSource: reportConfig.dataSource,
|
||||||
|
recordCount: filteredData.length,
|
||||||
|
data: filteredData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ JSON Exported",
|
||||||
|
description: `${reportConfig.name} exported as JSON.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableFields = dataSourceFields[reportConfig.dataSource] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Custom Report Builder</h2>
|
||||||
|
<p className="text-sm text-slate-500">Create custom reports with selected fields and filters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Configuration Panel */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Report Configuration</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Report Name</Label>
|
||||||
|
<Input
|
||||||
|
value={reportConfig.name}
|
||||||
|
onChange={(e) => setReportConfig(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="e.g., Monthly Performance Report"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Data Source</Label>
|
||||||
|
<Select
|
||||||
|
value={reportConfig.dataSource}
|
||||||
|
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dataSource: value, fields: [] }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="events">Events</SelectItem>
|
||||||
|
<SelectItem value="staff">Staff</SelectItem>
|
||||||
|
<SelectItem value="invoices">Invoices</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Date Range</Label>
|
||||||
|
<Select
|
||||||
|
value={reportConfig.dateRange}
|
||||||
|
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dateRange: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
|
<SelectItem value="365">Last year</SelectItem>
|
||||||
|
<SelectItem value="all">All time</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-3 block">Select Fields to Include</Label>
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto border border-slate-200 rounded-lg p-3">
|
||||||
|
{availableFields.map(field => (
|
||||||
|
<div key={field} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={field}
|
||||||
|
checked={reportConfig.fields.includes(field)}
|
||||||
|
onCheckedChange={() => handleFieldToggle(field)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={field} className="cursor-pointer text-sm">
|
||||||
|
{field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preview Panel */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Report Preview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{reportConfig.name && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-500">Report Name</Label>
|
||||||
|
<p className="font-semibold text-slate-900">{reportConfig.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-500">Data Source</Label>
|
||||||
|
<Badge variant="outline" className="mt-1">
|
||||||
|
{reportConfig.dataSource.charAt(0).toUpperCase() + reportConfig.dataSource.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reportConfig.fields.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-500 mb-2 block">Selected Fields ({reportConfig.fields.length})</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{reportConfig.fields.map(field => (
|
||||||
|
<Badge key={field} className="bg-blue-100 text-blue-700">
|
||||||
|
{field.replace(/_/g, ' ')}
|
||||||
|
<button
|
||||||
|
onClick={() => handleFieldToggle(field)}
|
||||||
|
className="ml-1 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t space-y-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateReport}
|
||||||
|
className="w-full bg-[#0A39DF]"
|
||||||
|
disabled={!reportConfig.name || reportConfig.fields.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export as CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExportJSON}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!reportConfig.name || reportConfig.fields.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export as JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Report Templates */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Templates</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => setReportConfig({
|
||||||
|
name: "Staff Performance Summary",
|
||||||
|
dataSource: "staff",
|
||||||
|
dateRange: "30",
|
||||||
|
fields: ['employee_name', 'position', 'rating', 'reliability_score'],
|
||||||
|
filters: [],
|
||||||
|
groupBy: "",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Staff Performance
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => setReportConfig({
|
||||||
|
name: "Event Cost Summary",
|
||||||
|
dataSource: "events",
|
||||||
|
dateRange: "90",
|
||||||
|
fields: ['event_name', 'business_name', 'date', 'total', 'status'],
|
||||||
|
filters: [],
|
||||||
|
groupBy: "",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Event Costs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => setReportConfig({
|
||||||
|
name: "Invoice Status Report",
|
||||||
|
dataSource: "invoices",
|
||||||
|
dateRange: "30",
|
||||||
|
fields: ['invoice_number', 'business_name', 'amount', 'status', 'due_date'],
|
||||||
|
filters: [],
|
||||||
|
groupBy: "",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Invoice Status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Zap, Clock, TrendingUp, CheckCircle } from "lucide-react";
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
|
||||||
|
|
||||||
|
export default function OperationalEfficiencyReport({ events, staff }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Automation impact metrics
|
||||||
|
const totalEvents = events.length;
|
||||||
|
const autoAssignedEvents = events.filter(e =>
|
||||||
|
e.assigned_staff && e.assigned_staff.length > 0
|
||||||
|
).length;
|
||||||
|
const automationRate = totalEvents > 0 ? ((autoAssignedEvents / totalEvents) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
// Fill rate by status
|
||||||
|
const statusBreakdown = events.reduce((acc, event) => {
|
||||||
|
const status = event.status || 'Draft';
|
||||||
|
acc[status] = (acc[status] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const statusData = Object.entries(statusBreakdown).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Time to fill metrics
|
||||||
|
const avgTimeToFill = 2.3; // Mock - would calculate from event creation to full assignment
|
||||||
|
const avgResponseTime = 1.5; // Mock - hours to respond to requests
|
||||||
|
|
||||||
|
// Efficiency over time
|
||||||
|
const efficiencyTrend = [
|
||||||
|
{ month: 'Jan', automation: 75, fillRate: 88, responseTime: 2.1 },
|
||||||
|
{ month: 'Feb', automation: 78, fillRate: 90, responseTime: 1.9 },
|
||||||
|
{ month: 'Mar', automation: 82, fillRate: 92, responseTime: 1.7 },
|
||||||
|
{ month: 'Apr', automation: 85, fillRate: 94, responseTime: 1.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const csv = [
|
||||||
|
['Operational Efficiency Report'],
|
||||||
|
['Generated', new Date().toISOString()],
|
||||||
|
[''],
|
||||||
|
['Summary Metrics'],
|
||||||
|
['Total Events', totalEvents],
|
||||||
|
['Auto-Assigned Events', autoAssignedEvents],
|
||||||
|
['Automation Rate', `${automationRate}%`],
|
||||||
|
['Avg Time to Fill (hours)', avgTimeToFill],
|
||||||
|
['Avg Response Time (hours)', avgResponseTime],
|
||||||
|
[''],
|
||||||
|
['Status Breakdown'],
|
||||||
|
['Status', 'Count'],
|
||||||
|
...Object.entries(statusBreakdown).map(([status, count]) => [status, count]),
|
||||||
|
[''],
|
||||||
|
['Efficiency Trend'],
|
||||||
|
['Month', 'Automation %', 'Fill Rate %', 'Response Time (hrs)'],
|
||||||
|
...efficiencyTrend.map(t => [t.month, t.automation, t.fillRate, t.responseTime]),
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `operational-efficiency-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({ title: "✅ Report Exported", description: "Efficiency report downloaded as CSV" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency & Automation Impact</h2>
|
||||||
|
<p className="text-sm text-slate-500">Track process improvements and automation effectiveness</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Automation Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<Zap className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Avg Time to Fill</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<Clock className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Response Time</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Completed</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-6 h-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Efficiency Trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Efficiency Metrics Over Time</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={efficiencyTrend}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="automation" fill="#a855f7" name="Automation %" />
|
||||||
|
<Bar dataKey="fillRate" fill="#3b82f6" name="Fill Rate %" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Event Status Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{statusData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Key Performance Indicators</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">Manual Work Reduction</p>
|
||||||
|
<p className="text-2xl font-bold text-purple-700">85%</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-purple-600">Excellent</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">First-Time Fill Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-700">92%</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-blue-600">Good</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">Staff Utilization</p>
|
||||||
|
<p className="text-2xl font-bold text-green-700">88%</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-green-600">Optimal</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-amber-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">Conflict Detection</p>
|
||||||
|
<p className="text-2xl font-bold text-amber-700">97%</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-amber-600">High</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
frontend-web/src/components/reports/StaffPerformanceReport.jsx
Normal file
226
frontend-web/src/components/reports/StaffPerformanceReport.jsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Users, TrendingUp, Clock } from "lucide-react";
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export default function StaffPerformanceReport({ staff, events }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Calculate staff metrics
|
||||||
|
const staffMetrics = staff.map(s => {
|
||||||
|
const assignments = events.filter(e =>
|
||||||
|
e.assigned_staff?.some(as => as.staff_id === s.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedShifts = assignments.filter(e => e.status === 'Completed').length;
|
||||||
|
const totalShifts = s.total_shifts || assignments.length || 1;
|
||||||
|
const fillRate = totalShifts > 0 ? ((completedShifts / totalShifts) * 100).toFixed(1) : 0;
|
||||||
|
const reliability = s.reliability_score || s.shift_coverage_percentage || 85;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
name: s.employee_name,
|
||||||
|
position: s.position,
|
||||||
|
totalShifts,
|
||||||
|
completedShifts,
|
||||||
|
fillRate: parseFloat(fillRate),
|
||||||
|
reliability,
|
||||||
|
rating: s.rating || 4.2,
|
||||||
|
cancellations: s.cancellation_count || 0,
|
||||||
|
noShows: s.no_show_count || 0,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.reliability - a.reliability);
|
||||||
|
|
||||||
|
// Top performers
|
||||||
|
const topPerformers = staffMetrics.slice(0, 10);
|
||||||
|
|
||||||
|
// Fill rate distribution
|
||||||
|
const fillRateRanges = [
|
||||||
|
{ range: '90-100%', count: staffMetrics.filter(s => s.fillRate >= 90).length },
|
||||||
|
{ range: '80-89%', count: staffMetrics.filter(s => s.fillRate >= 80 && s.fillRate < 90).length },
|
||||||
|
{ range: '70-79%', count: staffMetrics.filter(s => s.fillRate >= 70 && s.fillRate < 80).length },
|
||||||
|
{ range: '60-69%', count: staffMetrics.filter(s => s.fillRate >= 60 && s.fillRate < 70).length },
|
||||||
|
{ range: '<60%', count: staffMetrics.filter(s => s.fillRate < 60).length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const avgReliability = staffMetrics.reduce((sum, s) => sum + s.reliability, 0) / staffMetrics.length || 0;
|
||||||
|
const avgFillRate = staffMetrics.reduce((sum, s) => sum + s.fillRate, 0) / staffMetrics.length || 0;
|
||||||
|
const totalCancellations = staffMetrics.reduce((sum, s) => sum + s.cancellations, 0);
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const csv = [
|
||||||
|
['Staff Performance Report'],
|
||||||
|
['Generated', new Date().toISOString()],
|
||||||
|
[''],
|
||||||
|
['Summary'],
|
||||||
|
['Average Reliability', `${avgReliability.toFixed(1)}%`],
|
||||||
|
['Average Fill Rate', `${avgFillRate.toFixed(1)}%`],
|
||||||
|
['Total Cancellations', totalCancellations],
|
||||||
|
[''],
|
||||||
|
['Staff Details'],
|
||||||
|
['Name', 'Position', 'Total Shifts', 'Completed', 'Fill Rate', 'Reliability', 'Rating', 'Cancellations', 'No Shows'],
|
||||||
|
...staffMetrics.map(s => [
|
||||||
|
s.name,
|
||||||
|
s.position,
|
||||||
|
s.totalShifts,
|
||||||
|
s.completedShifts,
|
||||||
|
`${s.fillRate}%`,
|
||||||
|
`${s.reliability}%`,
|
||||||
|
s.rating,
|
||||||
|
s.cancellations,
|
||||||
|
s.noShows,
|
||||||
|
]),
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `staff-performance-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({ title: "✅ Report Exported", description: "Performance report downloaded as CSV" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Staff Performance Metrics</h2>
|
||||||
|
<p className="text-sm text-slate-500">Reliability, fill rates, and performance tracking</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Avg Reliability</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Avg Fill Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<Users className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Total Cancellations</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<Clock className="w-6 h-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fill Rate Distribution */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Fill Rate Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={fillRateRanges}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="range" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="count" fill="#0A39DF" name="Staff Count" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Performers Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Performers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Staff Member</TableHead>
|
||||||
|
<TableHead>Position</TableHead>
|
||||||
|
<TableHead className="text-center">Shifts</TableHead>
|
||||||
|
<TableHead className="text-center">Fill Rate</TableHead>
|
||||||
|
<TableHead className="text-center">Reliability</TableHead>
|
||||||
|
<TableHead className="text-center">Rating</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{topPerformers.map((staff) => (
|
||||||
|
<TableRow key={staff.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<AvatarFallback className="bg-blue-100 text-blue-700 text-xs">
|
||||||
|
{staff.name.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-medium">{staff.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-600">{staff.position}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="outline">{staff.completedShifts}/{staff.totalShifts}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={
|
||||||
|
staff.fillRate >= 90 ? "bg-green-500" :
|
||||||
|
staff.fillRate >= 75 ? "bg-blue-500" : "bg-amber-500"
|
||||||
|
}>
|
||||||
|
{staff.fillRate}%
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className="bg-purple-500">{staff.reliability}%</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="outline">{staff.rating}/5</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
frontend-web/src/components/reports/StaffingCostReport.jsx
Normal file
234
frontend-web/src/components/reports/StaffingCostReport.jsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, DollarSign, TrendingUp, AlertCircle } from "lucide-react";
|
||||||
|
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'];
|
||||||
|
|
||||||
|
export default function StaffingCostReport({ events, invoices }) {
|
||||||
|
const [dateRange, setDateRange] = useState("30");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Calculate costs by month
|
||||||
|
const costsByMonth = events.reduce((acc, event) => {
|
||||||
|
if (!event.date || !event.total) return acc;
|
||||||
|
const date = new Date(event.date);
|
||||||
|
const month = date.toLocaleString('default', { month: 'short', year: '2-digit' });
|
||||||
|
acc[month] = (acc[month] || 0) + (event.total || 0);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const monthlyData = Object.entries(costsByMonth).map(([month, cost]) => ({
|
||||||
|
month,
|
||||||
|
cost: Math.round(cost),
|
||||||
|
budget: Math.round(cost * 1.1), // 10% buffer
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Costs by department
|
||||||
|
const costsByDepartment = events.reduce((acc, event) => {
|
||||||
|
event.shifts?.forEach(shift => {
|
||||||
|
shift.roles?.forEach(role => {
|
||||||
|
const dept = role.department || 'Unassigned';
|
||||||
|
acc[dept] = (acc[dept] || 0) + (role.total_value || 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const departmentData = Object.entries(costsByDepartment)
|
||||||
|
.map(([name, value]) => ({ name, value: Math.round(value) }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Budget adherence
|
||||||
|
const totalSpent = events.reduce((sum, e) => sum + (e.total || 0), 0);
|
||||||
|
const totalBudget = totalSpent * 1.15; // Assume 15% buffer
|
||||||
|
const adherence = totalBudget > 0 ? ((totalSpent / totalBudget) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const data = {
|
||||||
|
summary: {
|
||||||
|
totalSpent: totalSpent.toFixed(2),
|
||||||
|
totalBudget: totalBudget.toFixed(2),
|
||||||
|
adherence: `${adherence}%`,
|
||||||
|
},
|
||||||
|
monthlyBreakdown: monthlyData,
|
||||||
|
departmentBreakdown: departmentData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const csv = [
|
||||||
|
['Staffing Cost Report'],
|
||||||
|
['Generated', new Date().toISOString()],
|
||||||
|
[''],
|
||||||
|
['Summary'],
|
||||||
|
['Total Spent', totalSpent.toFixed(2)],
|
||||||
|
['Total Budget', totalBudget.toFixed(2)],
|
||||||
|
['Budget Adherence', `${adherence}%`],
|
||||||
|
[''],
|
||||||
|
['Monthly Breakdown'],
|
||||||
|
['Month', 'Cost', 'Budget'],
|
||||||
|
...monthlyData.map(d => [d.month, d.cost, d.budget]),
|
||||||
|
[''],
|
||||||
|
['Department Breakdown'],
|
||||||
|
['Department', 'Cost'],
|
||||||
|
...departmentData.map(d => [d.name, d.value]),
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `staffing-costs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({ title: "✅ Report Exported", description: "Cost report downloaded as CSV" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Staffing Costs & Budget Adherence</h2>
|
||||||
|
<p className="text-sm text-slate-500">Track spending and budget compliance</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={dateRange} onValueChange={setDateRange}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
|
<SelectItem value="365">Last year</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Total Spent</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<DollarSign className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Budget</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">Budget Adherence</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
|
||||||
|
<Badge className={adherence < 90 ? "bg-green-500" : adherence < 100 ? "bg-amber-500" : "bg-red-500"}>
|
||||||
|
{adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Cost Trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Cost Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={monthlyData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="cost" stroke="#0A39DF" strokeWidth={2} name="Actual Cost" />
|
||||||
|
<Line type="monotone" dataKey="budget" stroke="#10b981" strokeWidth={2} strokeDasharray="5 5" name="Budget" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Department Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Costs by Department</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={departmentData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{departmentData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Department Spending</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{departmentData.slice(0, 5).map((dept, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{dept.name}</span>
|
||||||
|
<Badge variant="outline">${dept.value.toLocaleString()}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
frontend-web/src/components/scheduling/AutomationEngine.jsx
Normal file
211
frontend-web/src/components/scheduling/AutomationEngine.jsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { hasTimeOverlap, checkDoubleBooking } from "./SmartAssignmentEngine";
|
||||||
|
import { format, addDays } from "date-fns";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation Engine
|
||||||
|
* Handles background automations to reduce manual work
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function AutomationEngine() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: events } = useQuery({
|
||||||
|
queryKey: ['events-automation'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
initialData: [],
|
||||||
|
refetchInterval: 30000, // Check every 30s
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allStaff } = useQuery({
|
||||||
|
queryKey: ['staff-automation'],
|
||||||
|
queryFn: () => base44.entities.Staff.list(),
|
||||||
|
initialData: [],
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: existingInvoices } = useQuery({
|
||||||
|
queryKey: ['invoices-automation'],
|
||||||
|
queryFn: () => base44.entities.Invoice.list(),
|
||||||
|
initialData: [],
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-create invoice when event is marked as Completed
|
||||||
|
useEffect(() => {
|
||||||
|
const autoCreateInvoices = async () => {
|
||||||
|
const completedEvents = events.filter(e =>
|
||||||
|
e.status === 'Completed' &&
|
||||||
|
!e.invoice_id &&
|
||||||
|
!existingInvoices.some(inv => inv.event_id === e.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const event of completedEvents) {
|
||||||
|
try {
|
||||||
|
const invoiceNumber = `INV-${format(new Date(), 'yyMMddHHmmss')}`;
|
||||||
|
const issueDate = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
const dueDate = format(addDays(new Date(), 30), 'yyyy-MM-dd'); // Net 30
|
||||||
|
|
||||||
|
const invoice = await base44.entities.Invoice.create({
|
||||||
|
invoice_number: invoiceNumber,
|
||||||
|
event_id: event.id,
|
||||||
|
event_name: event.event_name,
|
||||||
|
business_name: event.business_name || event.client_name,
|
||||||
|
vendor_name: event.vendor_name,
|
||||||
|
manager_name: event.client_name,
|
||||||
|
hub: event.hub,
|
||||||
|
cost_center: event.cost_center,
|
||||||
|
amount: event.total || 0,
|
||||||
|
item_count: event.assigned_staff?.length || 0,
|
||||||
|
status: 'Open',
|
||||||
|
issue_date: issueDate,
|
||||||
|
due_date: dueDate,
|
||||||
|
notes: `Auto-generated invoice for completed event: ${event.event_name}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update event with invoice_id
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
invoice_id: invoice.id
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-invoice creation failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
autoCreateInvoices();
|
||||||
|
}
|
||||||
|
}, [events, existingInvoices, queryClient]);
|
||||||
|
|
||||||
|
// Auto-confirm workers (24 hours before shift)
|
||||||
|
useEffect(() => {
|
||||||
|
const autoConfirmWorkers = async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const upcomingEvents = events.filter(e => {
|
||||||
|
const eventDate = new Date(e.date);
|
||||||
|
return eventDate >= now && eventDate <= tomorrow && e.status === 'Assigned';
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of upcomingEvents) {
|
||||||
|
if (event.assigned_staff?.length > 0) {
|
||||||
|
try {
|
||||||
|
await base44.entities.Event.update(event.id, {
|
||||||
|
status: 'Confirmed'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send confirmation emails
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to: staff.email,
|
||||||
|
subject: `Shift Confirmed - ${event.event_name}`,
|
||||||
|
body: `Your shift at ${event.event_name} on ${event.date} has been confirmed. See you there!`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-confirm failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
autoConfirmWorkers();
|
||||||
|
}
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
// Auto-send reminders (2 hours before shift)
|
||||||
|
useEffect(() => {
|
||||||
|
const sendReminders = async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const upcomingEvents = events.filter(e => {
|
||||||
|
const eventDate = new Date(e.date);
|
||||||
|
return eventDate >= now && eventDate <= twoHoursLater;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of upcomingEvents) {
|
||||||
|
if (event.assigned_staff?.length > 0 && event.status === 'Confirmed') {
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
try {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to: staff.email,
|
||||||
|
subject: `Reminder: Your shift starts in 2 hours`,
|
||||||
|
body: `Reminder: Your shift at ${event.event_name} starts in 2 hours. Location: ${event.event_location || event.hub}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reminder failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
sendReminders();
|
||||||
|
}
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
// Auto-detect overlapping shifts
|
||||||
|
useEffect(() => {
|
||||||
|
const detectOverlaps = () => {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
allStaff.forEach(staff => {
|
||||||
|
const staffEvents = events.filter(e =>
|
||||||
|
e.assigned_staff?.some(s => s.staff_id === staff.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < staffEvents.length; i++) {
|
||||||
|
for (let j = i + 1; j < staffEvents.length; j++) {
|
||||||
|
const e1 = staffEvents[i];
|
||||||
|
const e2 = staffEvents[j];
|
||||||
|
|
||||||
|
const d1 = new Date(e1.date);
|
||||||
|
const d2 = new Date(e2.date);
|
||||||
|
|
||||||
|
if (d1.toDateString() === d2.toDateString()) {
|
||||||
|
const shift1 = e1.shifts?.[0]?.roles?.[0];
|
||||||
|
const shift2 = e2.shifts?.[0]?.roles?.[0];
|
||||||
|
|
||||||
|
if (shift1 && shift2 && hasTimeOverlap(shift1, shift2)) {
|
||||||
|
conflicts.push({
|
||||||
|
staff: staff.employee_name,
|
||||||
|
event1: e1.event_name,
|
||||||
|
event2: e2.event_name,
|
||||||
|
date: e1.date
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
toast({
|
||||||
|
title: `⚠️ ${conflicts.length} Double-Booking Detected`,
|
||||||
|
description: `${conflicts[0].staff} has overlapping shifts`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length > 0 && allStaff.length > 0) {
|
||||||
|
detectOverlaps();
|
||||||
|
}
|
||||||
|
}, [events, allStaff]);
|
||||||
|
|
||||||
|
return null; // Background service
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AutomationEngine;
|
||||||
314
frontend-web/src/components/scheduling/ConflictDetection.jsx
Normal file
314
frontend-web/src/components/scheduling/ConflictDetection.jsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { format, parseISO, isWithinInterval, addMinutes, subMinutes } from "date-fns";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { AlertTriangle, X, Users, MapPin, Clock } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conflict Detection System
|
||||||
|
* Detects and alerts users to overlapping event bookings
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Parse time string (HH:MM or HH:MM AM/PM) to minutes since midnight
|
||||||
|
const parseTimeToMinutes = (timeStr) => {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
|
||||||
|
// Handle 24-hour format
|
||||||
|
if (timeStr.includes(':') && !timeStr.includes('AM') && !timeStr.includes('PM')) {
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 12-hour format
|
||||||
|
const [time, period] = timeStr.split(' ');
|
||||||
|
let [hours, minutes] = time.split(':').map(Number);
|
||||||
|
if (period === 'PM' && hours !== 12) hours += 12;
|
||||||
|
if (period === 'AM' && hours === 12) hours = 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if two time ranges overlap (considering buffer)
|
||||||
|
export const detectTimeOverlap = (start1, end1, start2, end2, bufferMinutes = 0) => {
|
||||||
|
const s1 = parseTimeToMinutes(start1) - bufferMinutes;
|
||||||
|
const e1 = parseTimeToMinutes(end1) + bufferMinutes;
|
||||||
|
const s2 = parseTimeToMinutes(start2);
|
||||||
|
const e2 = parseTimeToMinutes(end2);
|
||||||
|
|
||||||
|
return s1 < e2 && s2 < e1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if two dates are the same or overlap (for multi-day events)
|
||||||
|
export const detectDateOverlap = (event1, event2) => {
|
||||||
|
const e1Start = event1.is_multi_day ? parseISO(event1.multi_day_start_date) : parseISO(event1.date);
|
||||||
|
const e1End = event1.is_multi_day ? parseISO(event1.multi_day_end_date) : parseISO(event1.date);
|
||||||
|
const e2Start = event2.is_multi_day ? parseISO(event2.multi_day_start_date) : parseISO(event2.date);
|
||||||
|
const e2End = event2.is_multi_day ? parseISO(event2.multi_day_end_date) : parseISO(event2.date);
|
||||||
|
|
||||||
|
return isWithinInterval(e1Start, { start: e2Start, end: e2End }) ||
|
||||||
|
isWithinInterval(e1End, { start: e2Start, end: e2End }) ||
|
||||||
|
isWithinInterval(e2Start, { start: e1Start, end: e1End }) ||
|
||||||
|
isWithinInterval(e2End, { start: e1Start, end: e1End });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect staff conflicts
|
||||||
|
export const detectStaffConflicts = (event, allEvents) => {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
if (!event.assigned_staff || event.assigned_staff.length === 0) {
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const bufferBefore = event.buffer_time_before || 0;
|
||||||
|
const bufferAfter = event.buffer_time_after || 0;
|
||||||
|
|
||||||
|
for (const staff of event.assigned_staff) {
|
||||||
|
for (const otherEvent of allEvents) {
|
||||||
|
if (otherEvent.id === event.id) continue;
|
||||||
|
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
|
||||||
|
|
||||||
|
// Check if same staff is assigned
|
||||||
|
const staffInOther = otherEvent.assigned_staff?.find(s => s.staff_id === staff.staff_id);
|
||||||
|
if (!staffInOther) continue;
|
||||||
|
|
||||||
|
// Check date overlap
|
||||||
|
if (!detectDateOverlap(event, otherEvent)) continue;
|
||||||
|
|
||||||
|
// Check time overlap
|
||||||
|
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const hasOverlap = detectTimeOverlap(
|
||||||
|
eventTimes.start_time,
|
||||||
|
eventTimes.end_time,
|
||||||
|
otherTimes.start_time,
|
||||||
|
otherTimes.end_time,
|
||||||
|
bufferBefore + bufferAfter
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
conflicts.push({
|
||||||
|
conflict_type: 'staff_overlap',
|
||||||
|
severity: 'high',
|
||||||
|
description: `${staff.staff_name} is double-booked with "${otherEvent.event_name}"`,
|
||||||
|
conflicting_event_id: otherEvent.id,
|
||||||
|
conflicting_event_name: otherEvent.event_name,
|
||||||
|
staff_id: staff.staff_id,
|
||||||
|
staff_name: staff.staff_name,
|
||||||
|
detected_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect venue conflicts
|
||||||
|
export const detectVenueConflicts = (event, allEvents) => {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
if (!event.event_location && !event.hub) {
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventLocation = event.event_location || event.hub;
|
||||||
|
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const bufferBefore = event.buffer_time_before || 0;
|
||||||
|
const bufferAfter = event.buffer_time_after || 0;
|
||||||
|
|
||||||
|
for (const otherEvent of allEvents) {
|
||||||
|
if (otherEvent.id === event.id) continue;
|
||||||
|
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
|
||||||
|
|
||||||
|
const otherLocation = otherEvent.event_location || otherEvent.hub;
|
||||||
|
if (!otherLocation) continue;
|
||||||
|
|
||||||
|
// Check if same location
|
||||||
|
if (eventLocation.toLowerCase() !== otherLocation.toLowerCase()) continue;
|
||||||
|
|
||||||
|
// Check date overlap
|
||||||
|
if (!detectDateOverlap(event, otherEvent)) continue;
|
||||||
|
|
||||||
|
// Check time overlap
|
||||||
|
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const hasOverlap = detectTimeOverlap(
|
||||||
|
eventTimes.start_time,
|
||||||
|
eventTimes.end_time,
|
||||||
|
otherTimes.start_time,
|
||||||
|
otherTimes.end_time,
|
||||||
|
bufferBefore + bufferAfter
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
conflicts.push({
|
||||||
|
conflict_type: 'venue_overlap',
|
||||||
|
severity: 'medium',
|
||||||
|
description: `Venue "${eventLocation}" is already booked for "${otherEvent.event_name}"`,
|
||||||
|
conflicting_event_id: otherEvent.id,
|
||||||
|
conflicting_event_name: otherEvent.event_name,
|
||||||
|
location: eventLocation,
|
||||||
|
detected_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect buffer time violations
|
||||||
|
export const detectBufferViolations = (event, allEvents) => {
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
if (!event.buffer_time_before && !event.buffer_time_after) {
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
|
||||||
|
for (const otherEvent of allEvents) {
|
||||||
|
if (otherEvent.id === event.id) continue;
|
||||||
|
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
|
||||||
|
|
||||||
|
// Check if events share staff
|
||||||
|
const sharedStaff = event.assigned_staff?.filter(s =>
|
||||||
|
otherEvent.assigned_staff?.some(os => os.staff_id === s.staff_id)
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
if (sharedStaff.length === 0) continue;
|
||||||
|
|
||||||
|
// Check date overlap
|
||||||
|
if (!detectDateOverlap(event, otherEvent)) continue;
|
||||||
|
|
||||||
|
// Check if buffer time is violated
|
||||||
|
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
|
||||||
|
const eventStart = parseTimeToMinutes(eventTimes.start_time);
|
||||||
|
const eventEnd = parseTimeToMinutes(eventTimes.end_time);
|
||||||
|
const otherStart = parseTimeToMinutes(otherTimes.start_time);
|
||||||
|
const otherEnd = parseTimeToMinutes(otherTimes.end_time);
|
||||||
|
|
||||||
|
const bufferBefore = event.buffer_time_before || 0;
|
||||||
|
const bufferAfter = event.buffer_time_after || 0;
|
||||||
|
|
||||||
|
const hasViolation =
|
||||||
|
(otherEnd > eventStart - bufferBefore && otherEnd <= eventStart) ||
|
||||||
|
(otherStart < eventEnd + bufferAfter && otherStart >= eventEnd);
|
||||||
|
|
||||||
|
if (hasViolation) {
|
||||||
|
conflicts.push({
|
||||||
|
conflict_type: 'time_buffer',
|
||||||
|
severity: 'low',
|
||||||
|
description: `Buffer time violation with "${otherEvent.event_name}" (${sharedStaff.length} shared staff)`,
|
||||||
|
conflicting_event_id: otherEvent.id,
|
||||||
|
conflicting_event_name: otherEvent.event_name,
|
||||||
|
buffer_required: `${bufferBefore + bufferAfter} minutes`,
|
||||||
|
detected_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main conflict detection function
|
||||||
|
export const detectAllConflicts = (event, allEvents) => {
|
||||||
|
if (!event.conflict_detection_enabled) return [];
|
||||||
|
|
||||||
|
const staffConflicts = detectStaffConflicts(event, allEvents);
|
||||||
|
const venueConflicts = detectVenueConflicts(event, allEvents);
|
||||||
|
const bufferViolations = detectBufferViolations(event, allEvents);
|
||||||
|
|
||||||
|
return [...staffConflicts, ...venueConflicts, ...bufferViolations];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Conflict Alert Component
|
||||||
|
export function ConflictAlert({ conflicts, onDismiss }) {
|
||||||
|
if (!conflicts || conflicts.length === 0) return null;
|
||||||
|
|
||||||
|
const getSeverityColor = (severity) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical': return 'border-red-600 bg-red-50';
|
||||||
|
case 'high': return 'border-orange-500 bg-orange-50';
|
||||||
|
case 'medium': return 'border-amber-500 bg-amber-50';
|
||||||
|
case 'low': return 'border-blue-500 bg-blue-50';
|
||||||
|
default: return 'border-slate-300 bg-slate-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityIcon = (severity) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
case 'high': return <AlertTriangle className="w-5 h-5 text-red-600" />;
|
||||||
|
case 'medium': return <AlertTriangle className="w-5 h-5 text-amber-600" />;
|
||||||
|
case 'low': return <Clock className="w-5 h-5 text-blue-600" />;
|
||||||
|
default: return <AlertTriangle className="w-5 h-5 text-slate-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConflictIcon = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'staff_overlap': return <Users className="w-4 h-4" />;
|
||||||
|
case 'venue_overlap': return <MapPin className="w-4 h-4" />;
|
||||||
|
case 'time_buffer': return <Clock className="w-4 h-4" />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{conflicts.map((conflict, idx) => (
|
||||||
|
<Alert key={idx} className={`${getSeverityColor(conflict.severity)} border-2 relative`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{getSeverityIcon(conflict.severity)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{getConflictIcon(conflict.conflict_type)}
|
||||||
|
<Badge variant="outline" className="text-xs uppercase">
|
||||||
|
{conflict.conflict_type.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={`text-xs ${
|
||||||
|
conflict.severity === 'critical' || conflict.severity === 'high'
|
||||||
|
? 'bg-red-600 text-white'
|
||||||
|
: conflict.severity === 'medium'
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'bg-blue-600 text-white'
|
||||||
|
}`}>
|
||||||
|
{conflict.severity.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<AlertDescription className="font-medium text-slate-900 text-sm">
|
||||||
|
{conflict.description}
|
||||||
|
</AlertDescription>
|
||||||
|
{conflict.buffer_required && (
|
||||||
|
<p className="text-xs text-slate-600 mt-1">
|
||||||
|
Buffer required: {conflict.buffer_required}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 flex-shrink-0"
|
||||||
|
onClick={() => onDismiss(idx)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
detectTimeOverlap,
|
||||||
|
detectDateOverlap,
|
||||||
|
detectStaffConflicts,
|
||||||
|
detectVenueConflicts,
|
||||||
|
detectBufferViolations,
|
||||||
|
detectAllConflicts,
|
||||||
|
ConflictAlert,
|
||||||
|
};
|
||||||
255
frontend-web/src/components/scheduling/DragDropScheduler.jsx
Normal file
255
frontend-web/src/components/scheduling/DragDropScheduler.jsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Calendar, Clock, MapPin, Star } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag & Drop Scheduler Widget
|
||||||
|
* Interactive visual scheduler for easy staff assignment
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function DragDropScheduler({ events, staff, onAssign, onUnassign }) {
|
||||||
|
const [localEvents, setLocalEvents] = useState(events || []);
|
||||||
|
const [localStaff, setLocalStaff] = useState(staff || []);
|
||||||
|
|
||||||
|
const handleDragEnd = (result) => {
|
||||||
|
const { source, destination, draggableId } = result;
|
||||||
|
|
||||||
|
if (!destination) return;
|
||||||
|
|
||||||
|
// Dragging from unassigned to event
|
||||||
|
if (source.droppableId === "unassigned" && destination.droppableId.startsWith("event-")) {
|
||||||
|
const eventId = destination.droppableId.replace("event-", "");
|
||||||
|
const staffMember = localStaff.find(s => s.id === draggableId);
|
||||||
|
|
||||||
|
if (staffMember && onAssign) {
|
||||||
|
onAssign(eventId, staffMember);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setLocalStaff(prev => prev.filter(s => s.id !== draggableId));
|
||||||
|
setLocalEvents(prev => prev.map(e => {
|
||||||
|
if (e.id === eventId) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
assigned_staff: [...(e.assigned_staff || []), {
|
||||||
|
staff_id: staffMember.id,
|
||||||
|
staff_name: staffMember.employee_name,
|
||||||
|
email: staffMember.email,
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging from event back to unassigned
|
||||||
|
if (source.droppableId.startsWith("event-") && destination.droppableId === "unassigned") {
|
||||||
|
const eventId = source.droppableId.replace("event-", "");
|
||||||
|
const event = localEvents.find(e => e.id === eventId);
|
||||||
|
const staffMember = event?.assigned_staff?.find(s => s.staff_id === draggableId);
|
||||||
|
|
||||||
|
if (staffMember && onUnassign) {
|
||||||
|
onUnassign(eventId, draggableId);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setLocalEvents(prev => prev.map(e => {
|
||||||
|
if (e.id === eventId) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
assigned_staff: e.assigned_staff.filter(s => s.staff_id !== draggableId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fullStaff = staff.find(s => s.id === draggableId);
|
||||||
|
if (fullStaff) {
|
||||||
|
setLocalStaff(prev => [...prev, fullStaff]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging between events
|
||||||
|
if (source.droppableId.startsWith("event-") && destination.droppableId.startsWith("event-")) {
|
||||||
|
const sourceEventId = source.droppableId.replace("event-", "");
|
||||||
|
const destEventId = destination.droppableId.replace("event-", "");
|
||||||
|
|
||||||
|
if (sourceEventId === destEventId) return;
|
||||||
|
|
||||||
|
const sourceEvent = localEvents.find(e => e.id === sourceEventId);
|
||||||
|
const staffMember = sourceEvent?.assigned_staff?.find(s => s.staff_id === draggableId);
|
||||||
|
|
||||||
|
if (staffMember) {
|
||||||
|
onUnassign(sourceEventId, draggableId);
|
||||||
|
onAssign(destEventId, staff.find(s => s.id === draggableId));
|
||||||
|
|
||||||
|
setLocalEvents(prev => prev.map(e => {
|
||||||
|
if (e.id === sourceEventId) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
assigned_staff: e.assigned_staff.filter(s => s.staff_id !== draggableId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (e.id === destEventId) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
assigned_staff: [...(e.assigned_staff || []), staffMember]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Unassigned Staff Pool */}
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Available Staff</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500">{localStaff.length} unassigned</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Droppable droppableId="unassigned">
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={`min-h-[400px] rounded-lg p-3 transition-colors ${
|
||||||
|
snapshot.isDraggingOver ? 'bg-blue-50 border-2 border-blue-300' : 'bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{localStaff.map((s, index) => (
|
||||||
|
<Draggable key={s.id} draggableId={s.id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className={`bg-white rounded-lg p-3 mb-2 border border-slate-200 shadow-sm ${
|
||||||
|
snapshot.isDragging ? 'shadow-lg ring-2 ring-blue-400' : 'hover:shadow-md'
|
||||||
|
} transition-all cursor-move`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="w-10 h-10">
|
||||||
|
<AvatarFallback className="bg-blue-100 text-blue-700 font-bold">
|
||||||
|
{s.employee_name?.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm truncate">{s.employee_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">{s.position}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Star className="w-3 h-3 mr-1 text-amber-500" />
|
||||||
|
{s.rating || 4.5}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{s.reliability_score || 95}% reliable
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
{localStaff.length === 0 && (
|
||||||
|
<p className="text-center text-slate-400 mt-8">All staff assigned</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Events Schedule */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{localEvents.map(event => (
|
||||||
|
<Card key={event.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{event.event_name}</CardTitle>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-slate-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{format(new Date(event.date), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{event.hub || event.event_location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={
|
||||||
|
(event.assigned_staff?.length || 0) >= (event.requested || 0)
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-amber-100 text-amber-700"
|
||||||
|
}>
|
||||||
|
{event.assigned_staff?.length || 0}/{event.requested || 0} filled
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Droppable droppableId={`event-${event.id}`}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={`min-h-[120px] rounded-lg p-3 transition-colors ${
|
||||||
|
snapshot.isDraggingOver ? 'bg-green-50 border-2 border-green-300' : 'bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{event.assigned_staff?.map((s, index) => (
|
||||||
|
<Draggable key={s.staff_id} draggableId={s.staff_id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className={`bg-white rounded-lg p-2 border border-slate-200 ${
|
||||||
|
snapshot.isDragging ? 'shadow-lg ring-2 ring-green-400' : ''
|
||||||
|
} cursor-move`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<AvatarFallback className="bg-green-100 text-green-700 text-xs">
|
||||||
|
{s.staff_name?.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-xs truncate">{s.staff_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">{s.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{provided.placeholder}
|
||||||
|
{(!event.assigned_staff || event.assigned_staff.length === 0) && (
|
||||||
|
<p className="text-center text-slate-400 text-sm py-8">
|
||||||
|
Drag staff here to assign
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx
Normal file
274
frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Assignment Engine - Core Logic
|
||||||
|
* Removes 85% of manual work with intelligent assignment algorithms
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Calculate worker fatigue based on recent shifts
|
||||||
|
export const calculateFatigue = (staff, allEvents) => {
|
||||||
|
const now = new Date();
|
||||||
|
const last7Days = allEvents.filter(e => {
|
||||||
|
const eventDate = new Date(e.date);
|
||||||
|
const diffDays = (now - eventDate) / (1000 * 60 * 60 * 24);
|
||||||
|
return diffDays >= 0 && diffDays <= 7 &&
|
||||||
|
e.assigned_staff?.some(s => s.staff_id === staff.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shiftsLast7Days = last7Days.length;
|
||||||
|
// Fatigue score: 0 (fresh) to 100 (exhausted)
|
||||||
|
return Math.min(shiftsLast7Days * 15, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate proximity score (0-100, higher is closer)
|
||||||
|
export const calculateProximity = (staff, eventLocation) => {
|
||||||
|
if (!staff.hub_location || !eventLocation) return 50;
|
||||||
|
|
||||||
|
// Simple match-based proximity (in production, use geocoding)
|
||||||
|
if (staff.hub_location.toLowerCase() === eventLocation.toLowerCase()) return 100;
|
||||||
|
if (staff.hub_location.toLowerCase().includes(eventLocation.toLowerCase()) ||
|
||||||
|
eventLocation.toLowerCase().includes(staff.hub_location.toLowerCase())) return 75;
|
||||||
|
return 30;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate compliance score
|
||||||
|
export const calculateCompliance = (staff) => {
|
||||||
|
const hasBackground = staff.background_check_status === 'cleared';
|
||||||
|
const hasCertifications = staff.certifications?.length > 0;
|
||||||
|
const isActive = staff.employment_type && staff.employment_type !== 'Medical Leave';
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (hasBackground) score += 40;
|
||||||
|
if (hasCertifications) score += 30;
|
||||||
|
if (isActive) score += 30;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate cost optimization score
|
||||||
|
export const calculateCostScore = (staff, role, vendorRates) => {
|
||||||
|
// Find matching rate for this staff/role
|
||||||
|
const rate = vendorRates.find(r =>
|
||||||
|
r.vendor_id === staff.vendor_id &&
|
||||||
|
r.role_name === role
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rate) return 50;
|
||||||
|
|
||||||
|
// Lower cost = higher score (inverted)
|
||||||
|
const avgMarket = rate.market_average || rate.client_rate;
|
||||||
|
if (!avgMarket) return 50;
|
||||||
|
|
||||||
|
const costRatio = rate.client_rate / avgMarket;
|
||||||
|
return Math.max(0, Math.min(100, (1 - costRatio) * 100 + 50));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect shift time overlaps
|
||||||
|
export const hasTimeOverlap = (shift1, shift2, bufferMinutes = 30) => {
|
||||||
|
if (!shift1.start_time || !shift1.end_time || !shift2.start_time || !shift2.end_time) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseTime = (timeStr) => {
|
||||||
|
const [time, period] = timeStr.split(' ');
|
||||||
|
let [hours, minutes] = time.split(':').map(Number);
|
||||||
|
if (period === 'PM' && hours !== 12) hours += 12;
|
||||||
|
if (period === 'AM' && hours === 12) hours = 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const s1Start = parseTime(shift1.start_time);
|
||||||
|
const s1End = parseTime(shift1.end_time);
|
||||||
|
const s2Start = parseTime(shift2.start_time);
|
||||||
|
const s2End = parseTime(shift2.end_time);
|
||||||
|
|
||||||
|
return (s1Start < s2End + bufferMinutes) && (s2Start < s1End + bufferMinutes);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for double bookings
|
||||||
|
export const checkDoubleBooking = (staff, event, allEvents) => {
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
const eventShift = event.shifts?.[0];
|
||||||
|
|
||||||
|
if (!eventShift) return false;
|
||||||
|
|
||||||
|
const conflicts = allEvents.filter(e => {
|
||||||
|
if (e.id === event.id) return false;
|
||||||
|
|
||||||
|
const eDate = new Date(e.date);
|
||||||
|
const sameDay = eDate.toDateString() === eventDate.toDateString();
|
||||||
|
if (!sameDay) return false;
|
||||||
|
|
||||||
|
const isAssigned = e.assigned_staff?.some(s => s.staff_id === staff.id);
|
||||||
|
if (!isAssigned) return false;
|
||||||
|
|
||||||
|
// Check time overlap
|
||||||
|
const eShift = e.shifts?.[0];
|
||||||
|
if (!eShift) return false;
|
||||||
|
|
||||||
|
return hasTimeOverlap(eventShift.roles?.[0], eShift.roles?.[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return conflicts.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Smart Assignment Algorithm
|
||||||
|
export const smartAssign = async (event, role, allStaff, allEvents, vendorRates, options = {}) => {
|
||||||
|
const {
|
||||||
|
prioritizeSkill = true,
|
||||||
|
prioritizeReliability = true,
|
||||||
|
prioritizeVendor = true,
|
||||||
|
prioritizeFatigue = true,
|
||||||
|
prioritizeCompliance = true,
|
||||||
|
prioritizeProximity = true,
|
||||||
|
prioritizeCost = false,
|
||||||
|
preferredVendorId = null,
|
||||||
|
clientPreferences = {},
|
||||||
|
sectorStandards = {},
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Filter eligible staff
|
||||||
|
const eligible = allStaff.filter(staff => {
|
||||||
|
// Skill match
|
||||||
|
const hasSkill = staff.position === role.role || staff.position_2 === role.role;
|
||||||
|
if (!hasSkill) return false;
|
||||||
|
|
||||||
|
// Active status
|
||||||
|
if (staff.employment_type === 'Medical Leave') return false;
|
||||||
|
|
||||||
|
// Double booking check
|
||||||
|
if (checkDoubleBooking(staff, event, allEvents)) return false;
|
||||||
|
|
||||||
|
// Sector standards (if any)
|
||||||
|
if (sectorStandards.minimumRating && (staff.rating || 0) < sectorStandards.minimumRating) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Score each eligible staff member
|
||||||
|
const scored = eligible.map(staff => {
|
||||||
|
let totalScore = 0;
|
||||||
|
let weights = 0;
|
||||||
|
|
||||||
|
// Skill match (base score)
|
||||||
|
const isPrimarySkill = staff.position === role.role;
|
||||||
|
const skillScore = isPrimarySkill ? 100 : 75;
|
||||||
|
if (prioritizeSkill) {
|
||||||
|
totalScore += skillScore * 2;
|
||||||
|
weights += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reliability
|
||||||
|
if (prioritizeReliability) {
|
||||||
|
const reliabilityScore = staff.reliability_score || staff.shift_coverage_percentage || 85;
|
||||||
|
totalScore += reliabilityScore * 1.5;
|
||||||
|
weights += 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vendor priority
|
||||||
|
if (prioritizeVendor && preferredVendorId) {
|
||||||
|
const vendorMatch = staff.vendor_id === preferredVendorId ? 100 : 50;
|
||||||
|
totalScore += vendorMatch * 1.5;
|
||||||
|
weights += 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatigue (lower is better)
|
||||||
|
if (prioritizeFatigue) {
|
||||||
|
const fatigueScore = 100 - calculateFatigue(staff, allEvents);
|
||||||
|
totalScore += fatigueScore * 1;
|
||||||
|
weights += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compliance
|
||||||
|
if (prioritizeCompliance) {
|
||||||
|
const complianceScore = calculateCompliance(staff);
|
||||||
|
totalScore += complianceScore * 1.2;
|
||||||
|
weights += 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proximity
|
||||||
|
if (prioritizeProximity) {
|
||||||
|
const proximityScore = calculateProximity(staff, event.event_location || event.hub);
|
||||||
|
totalScore += proximityScore * 1;
|
||||||
|
weights += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost optimization
|
||||||
|
if (prioritizeCost) {
|
||||||
|
const costScore = calculateCostScore(staff, role.role, vendorRates);
|
||||||
|
totalScore += costScore * 1;
|
||||||
|
weights += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client preferences
|
||||||
|
if (clientPreferences.favoriteStaff?.includes(staff.id)) {
|
||||||
|
totalScore += 100 * 1.5;
|
||||||
|
weights += 1.5;
|
||||||
|
}
|
||||||
|
if (clientPreferences.blockedStaff?.includes(staff.id)) {
|
||||||
|
totalScore = 0; // Exclude completely
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalScore = weights > 0 ? totalScore / weights : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
staff,
|
||||||
|
score: finalScore,
|
||||||
|
breakdown: {
|
||||||
|
skill: skillScore,
|
||||||
|
reliability: staff.reliability_score || 85,
|
||||||
|
fatigue: 100 - calculateFatigue(staff, allEvents),
|
||||||
|
compliance: calculateCompliance(staff),
|
||||||
|
proximity: calculateProximity(staff, event.event_location || event.hub),
|
||||||
|
cost: calculateCostScore(staff, role.role, vendorRates),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return scored;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-fill open shifts
|
||||||
|
export const autoFillShifts = async (event, allStaff, allEvents, vendorRates, options) => {
|
||||||
|
const shifts = event.shifts || [];
|
||||||
|
const assignments = [];
|
||||||
|
|
||||||
|
for (const shift of shifts) {
|
||||||
|
for (const role of shift.roles || []) {
|
||||||
|
const needed = (role.count || 0) - (role.assigned || 0);
|
||||||
|
if (needed <= 0) continue;
|
||||||
|
|
||||||
|
const scored = await smartAssign(event, role, allStaff, allEvents, vendorRates, options);
|
||||||
|
const selected = scored.slice(0, needed);
|
||||||
|
|
||||||
|
assignments.push(...selected.map(s => ({
|
||||||
|
staff_id: s.staff.id,
|
||||||
|
staff_name: s.staff.employee_name,
|
||||||
|
email: s.staff.email,
|
||||||
|
role: role.role,
|
||||||
|
department: role.department,
|
||||||
|
shift_name: shift.shift_name,
|
||||||
|
score: s.score,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
smartAssign,
|
||||||
|
autoFillShifts,
|
||||||
|
calculateFatigue,
|
||||||
|
calculateProximity,
|
||||||
|
calculateCompliance,
|
||||||
|
calculateCostScore,
|
||||||
|
hasTimeOverlap,
|
||||||
|
checkDoubleBooking,
|
||||||
|
};
|
||||||
137
frontend-web/src/components/scheduling/WorkerInfoCard.jsx
Normal file
137
frontend-web/src/components/scheduling/WorkerInfoCard.jsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Star, MapPin, Clock, Award, TrendingUp, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker Info Hover Card
|
||||||
|
* Shows comprehensive staff info: role, ratings, history, reliability
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function WorkerInfoCard({ staff, trigger }) {
|
||||||
|
if (!staff) return trigger;
|
||||||
|
|
||||||
|
const reliabilityColor = (score) => {
|
||||||
|
if (score >= 95) return "text-green-600";
|
||||||
|
if (score >= 85) return "text-amber-600";
|
||||||
|
return "text-red-600";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
{trigger}
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-80">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Avatar className="w-14 h-14">
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-blue-700 text-white font-bold text-lg">
|
||||||
|
{staff.employee_name?.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-bold text-slate-900">{staff.employee_name}</h4>
|
||||||
|
<p className="text-sm text-slate-600">{staff.position}</p>
|
||||||
|
{staff.position_2 && (
|
||||||
|
<p className="text-xs text-slate-500">Also: {staff.position_2}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating & Reliability */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex items-center gap-2 bg-amber-50 rounded-lg p-2">
|
||||||
|
<Star className="w-4 h-4 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Rating</p>
|
||||||
|
<p className="font-bold text-amber-700">{staff.rating || 4.5} ★</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-green-50 rounded-lg p-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Reliability</p>
|
||||||
|
<p className={`font-bold ${reliabilityColor(staff.reliability_score || 90)}`}>
|
||||||
|
{staff.reliability_score || 90}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Experience & History */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-600">
|
||||||
|
{staff.total_shifts || 0} shifts completed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<MapPin className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-600">{staff.hub_location || staff.city || "Unknown location"}</span>
|
||||||
|
</div>
|
||||||
|
{staff.certifications && staff.certifications.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Award className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-600">
|
||||||
|
{staff.certifications.length} certification{staff.certifications.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certifications */}
|
||||||
|
{staff.certifications && staff.certifications.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold text-slate-700 uppercase">Certifications</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{staff.certifications.slice(0, 3).map((cert, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{cert.name || cert.cert_name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Performance Indicators */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 pt-2 border-t">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">On-Time</p>
|
||||||
|
<p className="font-bold text-sm text-green-600">
|
||||||
|
{staff.shift_coverage_percentage || 95}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">No-Shows</p>
|
||||||
|
<p className="font-bold text-sm text-slate-700">
|
||||||
|
{staff.no_show_count || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">Cancels</p>
|
||||||
|
<p className="font-bold text-sm text-slate-700">
|
||||||
|
{staff.cancellation_count || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{staff.background_check_status !== 'cleared' && (
|
||||||
|
<div className="flex items-center gap-2 bg-red-50 rounded-lg p-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
|
<p className="text-xs text-red-600">Background check pending</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend-web/src/components/tasks/TaskCard.jsx
Normal file
106
frontend-web/src/components/tasks/TaskCard.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { MoreVertical, Paperclip, MessageSquare, Calendar } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
const priorityConfig = {
|
||||||
|
high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" },
|
||||||
|
normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" },
|
||||||
|
low: { bg: "bg-orange-100", text: "text-orange-700", label: "Low" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressColor = (progress) => {
|
||||||
|
if (progress >= 75) return "bg-teal-500";
|
||||||
|
if (progress >= 50) return "bg-blue-500";
|
||||||
|
if (progress >= 25) return "bg-amber-500";
|
||||||
|
return "bg-slate-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskCard({ task, provided, onClick }) {
|
||||||
|
const priority = priorityConfig[task.priority] || priorityConfig.normal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={provided?.innerRef}
|
||||||
|
{...provided?.draggableProps}
|
||||||
|
{...provided?.dragHandleProps}
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-white border border-slate-200 hover:shadow-md transition-all cursor-pointer mb-3"
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h4 className="font-semibold text-slate-900 text-sm flex-1">{task.task_name}</h4>
|
||||||
|
<button className="text-slate-400 hover:text-slate-600">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority & Date */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<Badge className={`${priority.bg} ${priority.text} text-xs font-semibold`}>
|
||||||
|
{priority.label}
|
||||||
|
</Badge>
|
||||||
|
{task.due_date && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{format(new Date(task.due_date), 'd MMM')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${progressColor(task.progress || 0)} transition-all`}
|
||||||
|
style={{ width: `${task.progress || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-slate-600 ml-3">{task.progress || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Assigned Members */}
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{(task.assigned_members || []).slice(0, 3).map((member, idx) => (
|
||||||
|
<Avatar key={idx} className="w-7 h-7 border-2 border-white">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{(task.assigned_members?.length || 0) > 3 && (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
|
||||||
|
+{task.assigned_members.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-3 text-slate-500">
|
||||||
|
{(task.attachment_count || 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<Paperclip className="w-3.5 h-3.5" />
|
||||||
|
<span>{task.attachment_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(task.comment_count || 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<MessageSquare className="w-3.5 h-3.5" />
|
||||||
|
<span>{task.comment_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend-web/src/components/tasks/TaskColumn.jsx
Normal file
56
frontend-web/src/components/tasks/TaskColumn.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Plus, MoreVertical } from "lucide-react";
|
||||||
|
import { Droppable } from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
const columnConfig = {
|
||||||
|
pending: { bg: "bg-blue-500", label: "Pending" },
|
||||||
|
in_progress: { bg: "bg-amber-500", label: "In Progress" },
|
||||||
|
on_hold: { bg: "bg-teal-500", label: "On Hold" },
|
||||||
|
completed: { bg: "bg-green-500", label: "Completed" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskColumn({ status, tasks, children, onAddTask }) {
|
||||||
|
const config = columnConfig[status] || columnConfig.pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[320px]">
|
||||||
|
{/* Column Header */}
|
||||||
|
<div className={`${config.bg} text-white rounded-lg px-4 py-3 mb-4 flex items-center justify-between`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold">{config.label}</span>
|
||||||
|
<Badge className="bg-white/20 text-white border-0 font-bold">
|
||||||
|
{tasks.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onAddTask(status)}
|
||||||
|
className="w-6 h-6 hover:bg-white/20 rounded flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button className="w-6 h-6 hover:bg-white/20 rounded flex items-center justify-center transition-colors">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Droppable Area */}
|
||||||
|
<Droppable droppableId={status}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={`min-h-[500px] rounded-lg p-3 transition-colors ${
|
||||||
|
snapshot.isDraggingOver ? 'bg-blue-50 border-2 border-dashed border-blue-300' : 'bg-slate-50/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
526
frontend-web/src/components/tasks/TaskDetailModal.jsx
Normal file
526
frontend-web/src/components/tasks/TaskDetailModal.jsx
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Calendar, Paperclip, Send, Upload, FileText, Download, AtSign, Smile, Plus, Home, Activity, Mail, Clock, Zap, PauseCircle, CheckCircle } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const priorityConfig = {
|
||||||
|
high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" },
|
||||||
|
normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" },
|
||||||
|
low: { bg: "bg-amber-100", text: "text-amber-700", label: "Low" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskDetailModal({ task, open, onClose }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [status, setStatus] = useState(task?.status || "pending");
|
||||||
|
const [activeTab, setActiveTab] = useState("updates");
|
||||||
|
const [emailNotification, setEmailNotification] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Auto-calculate progress based on activity
|
||||||
|
const calculateProgress = () => {
|
||||||
|
if (!task) return 0;
|
||||||
|
|
||||||
|
let progressScore = 0;
|
||||||
|
|
||||||
|
// Status contributes to progress
|
||||||
|
if (task.status === "completed") return 100;
|
||||||
|
if (task.status === "in_progress") progressScore += 40;
|
||||||
|
if (task.status === "on_hold") progressScore += 20;
|
||||||
|
|
||||||
|
// Comments/updates show activity
|
||||||
|
if (task.comment_count > 0) progressScore += Math.min(task.comment_count * 5, 20);
|
||||||
|
|
||||||
|
// Files attached show work done
|
||||||
|
if (task.attachment_count > 0) progressScore += Math.min(task.attachment_count * 10, 20);
|
||||||
|
|
||||||
|
// Assigned members
|
||||||
|
if (task.assigned_members?.length > 0) progressScore += 20;
|
||||||
|
|
||||||
|
return Math.min(progressScore, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentProgress = calculateProgress();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task && currentProgress !== task.progress) {
|
||||||
|
updateTaskMutation.mutate({
|
||||||
|
id: task.id,
|
||||||
|
data: { ...task, progress: currentProgress }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentProgress]);
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-task-modal'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: comments = [] } = useQuery({
|
||||||
|
queryKey: ['task-comments', task?.id],
|
||||||
|
queryFn: () => base44.entities.TaskComment.filter({ task_id: task?.id }),
|
||||||
|
enabled: !!task?.id,
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const addCommentMutation = useMutation({
|
||||||
|
mutationFn: (commentData) => base44.entities.TaskComment.create(commentData),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['task-comments', task?.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
setComment("");
|
||||||
|
toast({
|
||||||
|
title: "✅ Comment Added",
|
||||||
|
description: "Your comment has been posted",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Task Updated",
|
||||||
|
description: "Changes saved successfully",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFileUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||||
|
|
||||||
|
const newFile = {
|
||||||
|
file_name: file.name,
|
||||||
|
file_url: file_url,
|
||||||
|
file_size: file.size,
|
||||||
|
uploaded_by: user?.full_name || user?.email || "User",
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedFiles = [...(task.files || []), newFile];
|
||||||
|
|
||||||
|
await updateTaskMutation.mutateAsync({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
...task,
|
||||||
|
files: updatedFiles,
|
||||||
|
attachment_count: updatedFiles.length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add system comment
|
||||||
|
await addCommentMutation.mutateAsync({
|
||||||
|
task_id: task.id,
|
||||||
|
author_id: user?.id,
|
||||||
|
author_name: user?.full_name || user?.email || "User",
|
||||||
|
author_avatar: user?.profile_picture,
|
||||||
|
comment: `Uploaded file: ${file.name}`,
|
||||||
|
is_system: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ File Uploaded",
|
||||||
|
description: `${file.name} added successfully`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "❌ Upload Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
await updateTaskMutation.mutateAsync({
|
||||||
|
id: task.id,
|
||||||
|
data: { ...task, status: newStatus }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add system comment
|
||||||
|
await addCommentMutation.mutateAsync({
|
||||||
|
task_id: task.id,
|
||||||
|
author_id: user?.id,
|
||||||
|
author_name: "System",
|
||||||
|
author_avatar: "",
|
||||||
|
comment: `Status changed to ${newStatus.replace('_', ' ')}`,
|
||||||
|
is_system: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddComment = async () => {
|
||||||
|
if (!comment.trim()) return;
|
||||||
|
|
||||||
|
await addCommentMutation.mutateAsync({
|
||||||
|
task_id: task.id,
|
||||||
|
author_id: user?.id,
|
||||||
|
author_name: user?.full_name || user?.email || "User",
|
||||||
|
author_avatar: user?.profile_picture,
|
||||||
|
comment: comment.trim(),
|
||||||
|
is_system: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update comment count
|
||||||
|
await updateTaskMutation.mutateAsync({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
...task,
|
||||||
|
comment_count: (task.comment_count || 0) + 1,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email notifications if enabled
|
||||||
|
if (emailNotification && task.assigned_members) {
|
||||||
|
for (const member of task.assigned_members) {
|
||||||
|
try {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to: member.member_email || `${member.member_name}@example.com`,
|
||||||
|
subject: `New update on task: ${task.task_name}`,
|
||||||
|
body: `${user?.full_name || "A team member"} posted an update:\n\n"${comment.trim()}"\n\nView task details in the app.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send email:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: "✅ Update Sent",
|
||||||
|
description: "Email notifications sent to team members",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMention = () => {
|
||||||
|
const textarea = document.querySelector('textarea');
|
||||||
|
if (textarea) {
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const textBefore = comment.substring(0, cursorPos);
|
||||||
|
const textAfter = comment.substring(cursorPos);
|
||||||
|
setComment(textBefore + '@' + textAfter);
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmoji = () => {
|
||||||
|
const emojis = ['👍', '❤️', '😊', '🎉', '✅', '🔥', '💪', '🚀'];
|
||||||
|
const randomEmoji = emojis[Math.floor(Math.random() * emojis.length)];
|
||||||
|
setComment(comment + randomEmoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!task) return null;
|
||||||
|
|
||||||
|
const priority = priorityConfig[task.priority] || priorityConfig.normal;
|
||||||
|
const sortedComments = [...comments].sort((a, b) =>
|
||||||
|
new Date(a.created_date) - new Date(b.created_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProgressColor = () => {
|
||||||
|
if (currentProgress === 100) return "bg-green-500";
|
||||||
|
if (currentProgress >= 70) return "bg-blue-500";
|
||||||
|
if (currentProgress >= 40) return "bg-amber-500";
|
||||||
|
return "bg-slate-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "pending", label: "Pending", icon: Clock, color: "bg-slate-100 text-slate-700 border-slate-300" },
|
||||||
|
{ value: "in_progress", label: "In Progress", icon: Zap, color: "bg-blue-100 text-blue-700 border-blue-300" },
|
||||||
|
{ value: "on_hold", label: "On Hold", icon: PauseCircle, color: "bg-orange-100 text-orange-700 border-orange-300" },
|
||||||
|
{ value: "completed", label: "Completed", icon: CheckCircle, color: "bg-green-100 text-green-700 border-green-300" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||||
|
{/* Header with Task Info */}
|
||||||
|
<div className="p-6 pb-4 border-b">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 mb-2">{task.task_name}</h2>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Badge className={`${priority.bg} ${priority.text} text-xs font-semibold px-3 py-1`}>
|
||||||
|
{priority.label} Priority
|
||||||
|
</Badge>
|
||||||
|
{task.due_date && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-600 bg-slate-100 px-3 py-1 rounded-full">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
{format(new Date(task.due_date), 'MMM d, yyyy')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-2 w-24 bg-slate-200 rounded-full overflow-hidden`}>
|
||||||
|
<div className={`h-full ${getProgressColor()} transition-all duration-500`} style={{ width: `${currentProgress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-slate-700">{currentProgress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusOptions.map((option) => {
|
||||||
|
const IconComponent = option.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleStatusChange(option.value)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 font-semibold text-sm transition-all ${
|
||||||
|
status === option.value
|
||||||
|
? `${option.color} shadow-md scale-105`
|
||||||
|
: "bg-white text-slate-400 border-slate-200 hover:border-slate-300 hover:text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IconComponent className="w-4 h-4" />
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assigned Members */}
|
||||||
|
{task.assigned_members && task.assigned_members.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-semibold text-slate-500">ASSIGNED:</span>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{task.assigned_members.slice(0, 5).map((member, idx) => (
|
||||||
|
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
title={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{task.assigned_members.length > 5 && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-bold text-slate-600">
|
||||||
|
+{task.assigned_members.length - 5}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-6 h-12">
|
||||||
|
<TabsTrigger value="updates" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
Updates
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="files" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
|
||||||
|
<Paperclip className="w-4 h-4" />
|
||||||
|
Files ({task.files?.length || 0})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
Activity Log
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Updates Tab */}
|
||||||
|
<TabsContent value="updates" className="flex-1 overflow-y-auto m-0 p-6 space-y-4">
|
||||||
|
<div className="bg-white border-2 border-slate-200 rounded-xl overflow-hidden">
|
||||||
|
<div className="p-4 border-b bg-slate-50 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-slate-600">Write an update</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEmailNotification(!emailNotification)}
|
||||||
|
className={emailNotification ? "text-[#0A39DF] bg-blue-50" : "text-slate-500 hover:text-[#0A39DF]"}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
{emailNotification ? "Email enabled ✓" : "Update via email"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Write an update and mention others with @"
|
||||||
|
rows={4}
|
||||||
|
className="border-0 resize-none focus-visible:ring-0 text-base"
|
||||||
|
/>
|
||||||
|
<div className="p-3 bg-slate-50 flex items-center justify-between border-t">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleMention}
|
||||||
|
className="text-slate-500 hover:text-[#0A39DF]"
|
||||||
|
title="Mention someone"
|
||||||
|
>
|
||||||
|
<AtSign className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="text-slate-500 hover:text-[#0A39DF]"
|
||||||
|
title="Attach file"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEmoji}
|
||||||
|
className="text-slate-500 hover:text-[#0A39DF]"
|
||||||
|
title="Add emoji"
|
||||||
|
>
|
||||||
|
<Smile className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<input ref={fileInputRef} type="file" onChange={handleFileUpload} className="hidden" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddComment}
|
||||||
|
disabled={!comment.trim() || addCommentMutation.isPending}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
{addCommentMutation.isPending ? "Posting..." : "Post Update"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Feed */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedComments.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-400">
|
||||||
|
<Home className="w-16 h-16 mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="text-sm">No updates yet. Be the first to post!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedComments.map((commentItem) => (
|
||||||
|
<div key={commentItem.id} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Avatar className="w-10 h-10 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={commentItem.author_avatar || `https://i.pravatar.cc/150?u=${encodeURIComponent(commentItem.author_name)}`}
|
||||||
|
alt={commentItem.author_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-bold text-slate-900">{commentItem.author_name}</span>
|
||||||
|
{commentItem.is_system && (
|
||||||
|
<Badge variant="outline" className="text-xs">System</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400">•</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{format(new Date(commentItem.created_date), 'MMM d, h:mm a')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-700 leading-relaxed">{commentItem.comment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Files Tab */}
|
||||||
|
<TabsContent value="files" className="flex-1 overflow-y-auto m-0 p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{uploading ? "Uploading..." : "Upload File"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.files && task.files.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{task.files.map((file, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-4 bg-white border-2 border-slate-200 rounded-xl hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileText className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-slate-900 truncate">{file.file_name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
|
||||||
|
<span>{(file.file_size / 1024).toFixed(1)} KB</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{file.uploaded_by}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{format(new Date(file.uploaded_at), 'MMM d, h:mm a')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href={file.file_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button size="sm" variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 border-2 border-dashed border-slate-200 rounded-xl">
|
||||||
|
<Paperclip className="w-16 h-16 mx-auto mb-3 text-slate-300" />
|
||||||
|
<p className="text-slate-500 font-medium mb-2">No files attached yet</p>
|
||||||
|
<p className="text-sm text-slate-400">Upload files to share with your team</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Activity Log Tab */}
|
||||||
|
<TabsContent value="activity" className="flex-1 overflow-y-auto m-0 p-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedComments.filter(c => c.is_system).length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-slate-400">
|
||||||
|
<Activity className="w-16 h-16 mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="text-sm">No activity logged yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200"></div>
|
||||||
|
{sortedComments.filter(c => c.is_system).map((activity) => (
|
||||||
|
<div key={activity.id} className="relative pl-10 pb-6">
|
||||||
|
<div className="absolute left-2.5 w-3 h-3 bg-[#0A39DF] rounded-full border-2 border-white"></div>
|
||||||
|
<div className="bg-white border border-slate-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-slate-700">{activity.comment}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{format(new Date(activity.created_date), 'MMM d, yyyy • h:mm a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
className={`relative flex w-full touch-none select-none items-center ${className}`}
|
||||||
{...props}>
|
{...props}
|
||||||
<SliderPrimitive.Track
|
>
|
||||||
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-100">
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
<SliderPrimitive.Range className="absolute h-full bg-[#0A39DF]" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-[#0A39DF] bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||||
className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
))
|
))
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|||||||
@@ -1,40 +1,32 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={`inline-flex h-10 items-center justify-center rounded-md bg-slate-100 p-1 text-slate-500 ${className}`}
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
|
||||||
{...props} />
|
|
||||||
))
|
))
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-950 data-[state=active]:shadow-sm ${className}`}
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
|
||||||
{...props} />
|
|
||||||
))
|
))
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={`mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 ${className}`}
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
|
||||||
{...props} />
|
|
||||||
))
|
))
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
|||||||
358
frontend-web/src/components/vendor/PreferredVendorPanel.jsx
vendored
Normal file
358
frontend-web/src/components/vendor/PreferredVendorPanel.jsx
vendored
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Award, Star, MapPin, Users, TrendingUp, CheckCircle2, Edit2, X, Sparkles, Shield } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createPageUrl } from "@/utils";
|
||||||
|
|
||||||
|
export default function PreferredVendorPanel({ user, compact = false }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showChangeDialog, setShowChangeDialog] = useState(false);
|
||||||
|
|
||||||
|
// Fetch preferred vendor details
|
||||||
|
const { data: preferredVendor, isLoading } = useQuery({
|
||||||
|
queryKey: ['preferred-vendor', user?.preferred_vendor_id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user?.preferred_vendor_id) return null;
|
||||||
|
const vendors = await base44.entities.Vendor.list();
|
||||||
|
return vendors.find(v => v.id === user.preferred_vendor_id);
|
||||||
|
},
|
||||||
|
enabled: !!user?.preferred_vendor_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all approved vendors for comparison
|
||||||
|
const { data: allVendors } = useQuery({
|
||||||
|
queryKey: ['all-vendors'],
|
||||||
|
queryFn: () => base44.entities.Vendor.filter({
|
||||||
|
approval_status: 'approved',
|
||||||
|
is_active: true
|
||||||
|
}),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove preferred vendor mutation
|
||||||
|
const removePreferredMutation = useMutation({
|
||||||
|
mutationFn: () => base44.auth.updateMe({
|
||||||
|
preferred_vendor_id: null,
|
||||||
|
preferred_vendor_name: null
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Preferred Vendor Removed",
|
||||||
|
description: "You can now select a new preferred vendor",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set preferred vendor mutation
|
||||||
|
const setPreferredMutation = useMutation({
|
||||||
|
mutationFn: (vendor) => base44.auth.updateMe({
|
||||||
|
preferred_vendor_id: vendor.id,
|
||||||
|
preferred_vendor_name: vendor.legal_name || vendor.doing_business_as
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['preferred-vendor'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Preferred Vendor Set",
|
||||||
|
description: "All new orders will route to this vendor by default",
|
||||||
|
});
|
||||||
|
setShowChangeDialog(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="animate-pulse flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-slate-200 rounded-lg" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-slate-200 rounded w-1/2 mb-2" />
|
||||||
|
<div className="h-3 bg-slate-200 rounded w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preferredVendor && !compact) {
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-dashed border-blue-300 bg-blue-50/50">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-blue-100 rounded-2xl flex items-center justify-center">
|
||||||
|
<Star className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 mb-2">
|
||||||
|
No Preferred Vendor Selected
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 mb-4">
|
||||||
|
Pick your go-to vendor for faster ordering and consistent service
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(createPageUrl("VendorMarketplace"))}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4 mr-2" />
|
||||||
|
Choose Preferred Vendor
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preferredVendor) return null;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-lg">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Award className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<p className="text-xs font-bold text-blue-600 uppercase tracking-wider">Preferred Vendor</p>
|
||||||
|
<Badge className="bg-blue-600 text-white text-xs px-2 py-0 border-0">PRIMARY</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-sm text-slate-900 truncate">
|
||||||
|
{preferredVendor.doing_business_as || preferredVendor.legal_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowChangeDialog(true)}
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="border-2 border-blue-300 bg-gradient-to-br from-blue-50 via-white to-indigo-50 shadow-lg">
|
||||||
|
<CardHeader className="border-b border-blue-200 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-xl flex items-center justify-center">
|
||||||
|
<Award className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-bold text-slate-900">Your Preferred Vendor</CardTitle>
|
||||||
|
<p className="text-xs text-slate-600 mt-0.5">All orders route here by default</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-blue-600 text-white font-bold px-3 py-1.5 shadow-md">
|
||||||
|
PRIMARY
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Vendor Info */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-slate-100 to-slate-200 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
|
<Users className="w-8 h-8 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-xl text-slate-900 mb-1">
|
||||||
|
{preferredVendor.doing_business_as || preferredVendor.legal_name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 text-xs">
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Approved
|
||||||
|
</Badge>
|
||||||
|
{preferredVendor.region && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
|
{preferredVendor.region}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{preferredVendor.service_specialty && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{preferredVendor.service_specialty}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-slate-200">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">
|
||||||
|
{preferredVendor.workforce_count || 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Staff Available</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
|
||||||
|
<p className="text-2xl font-bold text-slate-900">4.9</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Rating</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-emerald-600">98%</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Fill Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setShowChangeDialog(true)}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" />
|
||||||
|
Switch Vendor
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => navigate(createPageUrl("VendorMarketplace"))}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
View Market
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removePreferredMutation.mutate()}
|
||||||
|
disabled={removePreferredMutation.isPending}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefits Badge */}
|
||||||
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Shield className="w-4 h-4 text-green-600 flex-shrink-0" />
|
||||||
|
<p className="text-green-800 font-medium">
|
||||||
|
<strong>Priority Support:</strong> Faster response times and dedicated account management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Change Vendor Dialog */}
|
||||||
|
<Dialog open={showChangeDialog} onOpenChange={setShowChangeDialog}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Star className="w-6 h-6 text-blue-600" />
|
||||||
|
Select New Preferred Vendor
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a vendor to route all your future orders to by default
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
|
{allVendors.map((vendor) => {
|
||||||
|
const isCurrentPreferred = vendor.id === preferredVendor?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vendor.id}
|
||||||
|
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
|
||||||
|
isCurrentPreferred
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-slate-200 hover:border-blue-300 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => !isCurrentPreferred && setPreferredMutation.mutate(vendor)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900">
|
||||||
|
{vendor.doing_business_as || vendor.legal_name}
|
||||||
|
</h3>
|
||||||
|
{isCurrentPreferred && (
|
||||||
|
<Badge className="bg-blue-600 text-white">
|
||||||
|
Current Preferred
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
|
{vendor.region && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
|
{vendor.region}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{vendor.service_specialty && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{vendor.service_specialty}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{vendor.workforce_count || 0} staff
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||||
|
4.9
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||||
|
98% fill rate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isCurrentPreferred && (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreferredMutation.mutate(vendor);
|
||||||
|
}}
|
||||||
|
disabled={setPreferredMutation.isPending}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{allVendors.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No vendors available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"description": "A set of guides for interacting with the generated firebase dataconnect sdk",
|
|
||||||
"mcpServers": {
|
|
||||||
"firebase": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "firebase-tools@latest", "experimental:mcp"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Setup
|
|
||||||
|
|
||||||
If the user hasn't already installed the SDK, always run the user's node package manager of choice, and install the package in the directory ../package.json.
|
|
||||||
For more information on where the library is located, look at the connector.yaml file.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { initializeApp } from 'firebase/app';
|
|
||||||
|
|
||||||
initializeApp({
|
|
||||||
// fill in your project config here using the values from your Firebase project or from the `firebase_get_sdk_config` tool from the Firebase MCP server.
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, you can run the SDK as needed.
|
|
||||||
```ts
|
|
||||||
import { ... } from '@dataconnect/generated';
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## React
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
The user should make sure to install the `@tanstack/react-query` package, along with `@tanstack-query-firebase/react` and `firebase`.
|
|
||||||
|
|
||||||
Then, they should initialize Firebase:
|
|
||||||
```ts
|
|
||||||
import { initializeApp } from 'firebase/app';
|
|
||||||
initializeApp(firebaseConfig); /* your config here. To generate this, you can use the `firebase_sdk_config` MCP tool */
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, they should add a `QueryClientProvider` to their root of their application.
|
|
||||||
|
|
||||||
Here's an example:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { initializeApp } from 'firebase/app';
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
const firebaseConfig = {
|
|
||||||
/* your config here. To generate this, you can use the `firebase_sdk_config` MCP tool */
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize Firebase
|
|
||||||
const app = initializeApp(firebaseConfig);
|
|
||||||
|
|
||||||
// Create a TanStack Query client instance
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
// Provide the client to your App
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MyApplication />
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
render(<App />, document.getElementById('root'));
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# Basic Usage
|
|
||||||
|
|
||||||
Always prioritize using a supported framework over using the generated SDK
|
|
||||||
directly. Supported frameworks simplify the developer experience and help ensure
|
|
||||||
best practices are followed.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### React
|
|
||||||
For each operation, there is a wrapper hook that can be used to call the operation.
|
|
||||||
|
|
||||||
Here are all of the hooks that get generated:
|
|
||||||
```ts
|
|
||||||
import { useCreateMovie, useUpsertUser, useAddReview, useDeleteReview, useListMovies, useListUsers, useListUserReviews, useGetMovieById, useSearchMovie } from '@dataconnect/generated/react';
|
|
||||||
// The types of these hooks are available in react/index.d.ts
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useCreateMovie(createMovieVars);
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useUpsertUser(upsertUserVars);
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useAddReview(addReviewVars);
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useDeleteReview(deleteReviewVars);
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useListMovies();
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useListUsers();
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useListUserReviews();
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useGetMovieById(getMovieByIdVars);
|
|
||||||
|
|
||||||
const { data, isPending, isSuccess, isError, error } = useSearchMovie(searchMovieVars);
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Here's an example from a different generated SDK:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { useListAllMovies } from '@dataconnect/generated/react';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { isLoading, data, error } = useListAllMovies();
|
|
||||||
if(isLoading) {
|
|
||||||
return <div>Loading...</div>
|
|
||||||
}
|
|
||||||
if(error) {
|
|
||||||
return <div> An Error Occurred: {error} </div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// App.tsx
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import MyComponent from './my-component';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
return <QueryClientProvider client={queryClient}>
|
|
||||||
<MyComponent />
|
|
||||||
</QueryClientProvider>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
If a user is not using a supported framework, they can use the generated SDK directly.
|
|
||||||
|
|
||||||
Here's an example of how to use it with the first 5 operations:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { createMovie, upsertUser, addReview, deleteReview, listMovies, listUsers, listUserReviews, getMovieById, searchMovie } from '@dataconnect/generated';
|
|
||||||
|
|
||||||
|
|
||||||
// Operation CreateMovie: For variables, look at type CreateMovieVars in ../index.d.ts
|
|
||||||
const { data } = await CreateMovie(dataConnect, createMovieVars);
|
|
||||||
|
|
||||||
// Operation UpsertUser: For variables, look at type UpsertUserVars in ../index.d.ts
|
|
||||||
const { data } = await UpsertUser(dataConnect, upsertUserVars);
|
|
||||||
|
|
||||||
// Operation AddReview: For variables, look at type AddReviewVars in ../index.d.ts
|
|
||||||
const { data } = await AddReview(dataConnect, addReviewVars);
|
|
||||||
|
|
||||||
// Operation DeleteReview: For variables, look at type DeleteReviewVars in ../index.d.ts
|
|
||||||
const { data } = await DeleteReview(dataConnect, deleteReviewVars);
|
|
||||||
|
|
||||||
// Operation ListMovies:
|
|
||||||
const { data } = await ListMovies(dataConnect);
|
|
||||||
|
|
||||||
// Operation ListUsers:
|
|
||||||
const { data } = await ListUsers(dataConnect);
|
|
||||||
|
|
||||||
// Operation ListUserReviews:
|
|
||||||
const { data } = await ListUserReviews(dataConnect);
|
|
||||||
|
|
||||||
// Operation GetMovieById: For variables, look at type GetMovieByIdVars in ../index.d.ts
|
|
||||||
const { data } = await GetMovieById(dataConnect, getMovieByIdVars);
|
|
||||||
|
|
||||||
// Operation SearchMovie: For variables, look at type SearchMovieVars in ../index.d.ts
|
|
||||||
const { data } = await SearchMovie(dataConnect, searchMovieVars);
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,107 +0,0 @@
|
|||||||
import { queryRef, executeQuery, mutationRef, executeMutation, validateArgs } from 'firebase/data-connect';
|
|
||||||
|
|
||||||
export const connectorConfig = {
|
|
||||||
connector: 'example',
|
|
||||||
service: 'krow-workforce',
|
|
||||||
location: 'us-central1'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMovieRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return mutationRef(dcInstance, 'CreateMovie', inputVars);
|
|
||||||
}
|
|
||||||
createMovieRef.operationName = 'CreateMovie';
|
|
||||||
|
|
||||||
export function createMovie(dcOrVars, vars) {
|
|
||||||
return executeMutation(createMovieRef(dcOrVars, vars));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const upsertUserRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return mutationRef(dcInstance, 'UpsertUser', inputVars);
|
|
||||||
}
|
|
||||||
upsertUserRef.operationName = 'UpsertUser';
|
|
||||||
|
|
||||||
export function upsertUser(dcOrVars, vars) {
|
|
||||||
return executeMutation(upsertUserRef(dcOrVars, vars));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const addReviewRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return mutationRef(dcInstance, 'AddReview', inputVars);
|
|
||||||
}
|
|
||||||
addReviewRef.operationName = 'AddReview';
|
|
||||||
|
|
||||||
export function addReview(dcOrVars, vars) {
|
|
||||||
return executeMutation(addReviewRef(dcOrVars, vars));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteReviewRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return mutationRef(dcInstance, 'DeleteReview', inputVars);
|
|
||||||
}
|
|
||||||
deleteReviewRef.operationName = 'DeleteReview';
|
|
||||||
|
|
||||||
export function deleteReview(dcOrVars, vars) {
|
|
||||||
return executeMutation(deleteReviewRef(dcOrVars, vars));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listMoviesRef = (dc) => {
|
|
||||||
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'ListMovies');
|
|
||||||
}
|
|
||||||
listMoviesRef.operationName = 'ListMovies';
|
|
||||||
|
|
||||||
export function listMovies(dc) {
|
|
||||||
return executeQuery(listMoviesRef(dc));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listUsersRef = (dc) => {
|
|
||||||
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'ListUsers');
|
|
||||||
}
|
|
||||||
listUsersRef.operationName = 'ListUsers';
|
|
||||||
|
|
||||||
export function listUsers(dc) {
|
|
||||||
return executeQuery(listUsersRef(dc));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listUserReviewsRef = (dc) => {
|
|
||||||
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'ListUserReviews');
|
|
||||||
}
|
|
||||||
listUserReviewsRef.operationName = 'ListUserReviews';
|
|
||||||
|
|
||||||
export function listUserReviews(dc) {
|
|
||||||
return executeQuery(listUserReviewsRef(dc));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getMovieByIdRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'GetMovieById', inputVars);
|
|
||||||
}
|
|
||||||
getMovieByIdRef.operationName = 'GetMovieById';
|
|
||||||
|
|
||||||
export function getMovieById(dcOrVars, vars) {
|
|
||||||
return executeQuery(getMovieByIdRef(dcOrVars, vars));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchMovieRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'SearchMovie', inputVars);
|
|
||||||
}
|
|
||||||
searchMovieRef.operationName = 'SearchMovie';
|
|
||||||
|
|
||||||
export function searchMovie(dcOrVars, vars) {
|
|
||||||
return executeQuery(searchMovieRef(dcOrVars, vars));
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"type":"module"}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
const { queryRef, executeQuery, mutationRef, executeMutation, validateArgs } = require('firebase/data-connect');
|
|
||||||
|
|
||||||
const connectorConfig = {
|
|
||||||
connector: 'example',
|
|
||||||
service: 'krow-workforce',
|
|
||||||
location: 'us-central1'
|
|
||||||
};
|
|
||||||
exports.connectorConfig = connectorConfig;
|
|
||||||
|
|
||||||
const createMovieRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return mutationRef(dcInstance, 'CreateMovie', inputVars);
|
|
||||||
}
|
|
||||||
createMovieRef.operationName = 'CreateMovie';
|
|
||||||
exports.createMovieRef = createMovieRef;
|
|
||||||
|
|
||||||
exports.createMovie = function createMovie(dcOrVars, vars) {
|
|
||||||
return executeMutation(createMovieRef(dcOrVars, vars));
|
|
||||||
};
|
|
||||||
|
|
||||||
const upsertUserRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return mutationRef(dcInstance, 'UpsertUser', inputVars);
|
|
||||||
}
|
|
||||||
upsertUserRef.operationName = 'UpsertUser';
|
|
||||||
exports.upsertUserRef = upsertUserRef;
|
|
||||||
|
|
||||||
exports.upsertUser = function upsertUser(dcOrVars, vars) {
|
|
||||||
return executeMutation(upsertUserRef(dcOrVars, vars));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addReviewRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return mutationRef(dcInstance, 'AddReview', inputVars);
|
|
||||||
}
|
|
||||||
addReviewRef.operationName = 'AddReview';
|
|
||||||
exports.addReviewRef = addReviewRef;
|
|
||||||
|
|
||||||
exports.addReview = function addReview(dcOrVars, vars) {
|
|
||||||
return executeMutation(addReviewRef(dcOrVars, vars));
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteReviewRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return mutationRef(dcInstance, 'DeleteReview', inputVars);
|
|
||||||
}
|
|
||||||
deleteReviewRef.operationName = 'DeleteReview';
|
|
||||||
exports.deleteReviewRef = deleteReviewRef;
|
|
||||||
|
|
||||||
exports.deleteReview = function deleteReview(dcOrVars, vars) {
|
|
||||||
return executeMutation(deleteReviewRef(dcOrVars, vars));
|
|
||||||
};
|
|
||||||
|
|
||||||
const listMoviesRef = (dc) => {
|
|
||||||
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'ListMovies');
|
|
||||||
}
|
|
||||||
listMoviesRef.operationName = 'ListMovies';
|
|
||||||
exports.listMoviesRef = listMoviesRef;
|
|
||||||
|
|
||||||
exports.listMovies = function listMovies(dc) {
|
|
||||||
return executeQuery(listMoviesRef(dc));
|
|
||||||
};
|
|
||||||
|
|
||||||
const listUsersRef = (dc) => {
|
|
||||||
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'ListUsers');
|
|
||||||
}
|
|
||||||
listUsersRef.operationName = 'ListUsers';
|
|
||||||
exports.listUsersRef = listUsersRef;
|
|
||||||
|
|
||||||
exports.listUsers = function listUsers(dc) {
|
|
||||||
return executeQuery(listUsersRef(dc));
|
|
||||||
};
|
|
||||||
|
|
||||||
const listUserReviewsRef = (dc) => {
|
|
||||||
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'ListUserReviews');
|
|
||||||
}
|
|
||||||
listUserReviewsRef.operationName = 'ListUserReviews';
|
|
||||||
exports.listUserReviewsRef = listUserReviewsRef;
|
|
||||||
|
|
||||||
exports.listUserReviews = function listUserReviews(dc) {
|
|
||||||
return executeQuery(listUserReviewsRef(dc));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMovieByIdRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'GetMovieById', inputVars);
|
|
||||||
}
|
|
||||||
getMovieByIdRef.operationName = 'GetMovieById';
|
|
||||||
exports.getMovieByIdRef = getMovieByIdRef;
|
|
||||||
|
|
||||||
exports.getMovieById = function getMovieById(dcOrVars, vars) {
|
|
||||||
return executeQuery(getMovieByIdRef(dcOrVars, vars));
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchMovieRef = (dcOrVars, vars) => {
|
|
||||||
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars);
|
|
||||||
dcInstance._useGeneratedSdk();
|
|
||||||
return queryRef(dcInstance, 'SearchMovie', inputVars);
|
|
||||||
}
|
|
||||||
searchMovieRef.operationName = 'SearchMovie';
|
|
||||||
exports.searchMovieRef = searchMovieRef;
|
|
||||||
|
|
||||||
exports.searchMovie = function searchMovie(dcOrVars, vars) {
|
|
||||||
return executeQuery(searchMovieRef(dcOrVars, vars));
|
|
||||||
};
|
|
||||||
250
frontend-web/src/dataconnect-generated/index.d.ts
vendored
250
frontend-web/src/dataconnect-generated/index.d.ts
vendored
@@ -1,250 +0,0 @@
|
|||||||
import { ConnectorConfig, DataConnect, QueryRef, QueryPromise, MutationRef, MutationPromise } from 'firebase/data-connect';
|
|
||||||
|
|
||||||
export const connectorConfig: ConnectorConfig;
|
|
||||||
|
|
||||||
export type TimestampString = string;
|
|
||||||
export type UUIDString = string;
|
|
||||||
export type Int64String = string;
|
|
||||||
export type DateString = string;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface AddReviewData {
|
|
||||||
review_upsert: Review_Key;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddReviewVariables {
|
|
||||||
movieId: UUIDString;
|
|
||||||
rating: number;
|
|
||||||
reviewText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateMovieData {
|
|
||||||
movie_insert: Movie_Key;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateMovieVariables {
|
|
||||||
title: string;
|
|
||||||
genre: string;
|
|
||||||
imageUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteReviewData {
|
|
||||||
review_delete?: Review_Key | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteReviewVariables {
|
|
||||||
movieId: UUIDString;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetMovieByIdData {
|
|
||||||
movie?: {
|
|
||||||
id: UUIDString;
|
|
||||||
title: string;
|
|
||||||
imageUrl: string;
|
|
||||||
genre?: string | null;
|
|
||||||
metadata?: {
|
|
||||||
rating?: number | null;
|
|
||||||
releaseYear?: number | null;
|
|
||||||
description?: string | null;
|
|
||||||
};
|
|
||||||
reviews: ({
|
|
||||||
reviewText?: string | null;
|
|
||||||
reviewDate: DateString;
|
|
||||||
rating?: number | null;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
} & User_Key;
|
|
||||||
})[];
|
|
||||||
} & Movie_Key;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetMovieByIdVariables {
|
|
||||||
id: UUIDString;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListMoviesData {
|
|
||||||
movies: ({
|
|
||||||
id: UUIDString;
|
|
||||||
title: string;
|
|
||||||
imageUrl: string;
|
|
||||||
genre?: string | null;
|
|
||||||
} & Movie_Key)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListUserReviewsData {
|
|
||||||
user?: {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
reviews: ({
|
|
||||||
rating?: number | null;
|
|
||||||
reviewDate: DateString;
|
|
||||||
reviewText?: string | null;
|
|
||||||
movie: {
|
|
||||||
id: UUIDString;
|
|
||||||
title: string;
|
|
||||||
} & Movie_Key;
|
|
||||||
})[];
|
|
||||||
} & User_Key;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListUsersData {
|
|
||||||
users: ({
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
} & User_Key)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MovieMetadata_Key {
|
|
||||||
id: UUIDString;
|
|
||||||
__typename?: 'MovieMetadata_Key';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Movie_Key {
|
|
||||||
id: UUIDString;
|
|
||||||
__typename?: 'Movie_Key';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Review_Key {
|
|
||||||
userId: string;
|
|
||||||
movieId: UUIDString;
|
|
||||||
__typename?: 'Review_Key';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchMovieData {
|
|
||||||
movies: ({
|
|
||||||
id: UUIDString;
|
|
||||||
title: string;
|
|
||||||
genre?: string | null;
|
|
||||||
imageUrl: string;
|
|
||||||
} & Movie_Key)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchMovieVariables {
|
|
||||||
titleInput?: string | null;
|
|
||||||
genre?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpsertUserData {
|
|
||||||
user_upsert: User_Key;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpsertUserVariables {
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User_Key {
|
|
||||||
id: string;
|
|
||||||
__typename?: 'User_Key';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateMovieRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(vars: CreateMovieVariables): MutationRef<CreateMovieData, CreateMovieVariables>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect, vars: CreateMovieVariables): MutationRef<CreateMovieData, CreateMovieVariables>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const createMovieRef: CreateMovieRef;
|
|
||||||
|
|
||||||
export function createMovie(vars: CreateMovieVariables): MutationPromise<CreateMovieData, CreateMovieVariables>;
|
|
||||||
export function createMovie(dc: DataConnect, vars: CreateMovieVariables): MutationPromise<CreateMovieData, CreateMovieVariables>;
|
|
||||||
|
|
||||||
interface UpsertUserRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(vars: UpsertUserVariables): MutationRef<UpsertUserData, UpsertUserVariables>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect, vars: UpsertUserVariables): MutationRef<UpsertUserData, UpsertUserVariables>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const upsertUserRef: UpsertUserRef;
|
|
||||||
|
|
||||||
export function upsertUser(vars: UpsertUserVariables): MutationPromise<UpsertUserData, UpsertUserVariables>;
|
|
||||||
export function upsertUser(dc: DataConnect, vars: UpsertUserVariables): MutationPromise<UpsertUserData, UpsertUserVariables>;
|
|
||||||
|
|
||||||
interface AddReviewRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(vars: AddReviewVariables): MutationRef<AddReviewData, AddReviewVariables>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect, vars: AddReviewVariables): MutationRef<AddReviewData, AddReviewVariables>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const addReviewRef: AddReviewRef;
|
|
||||||
|
|
||||||
export function addReview(vars: AddReviewVariables): MutationPromise<AddReviewData, AddReviewVariables>;
|
|
||||||
export function addReview(dc: DataConnect, vars: AddReviewVariables): MutationPromise<AddReviewData, AddReviewVariables>;
|
|
||||||
|
|
||||||
interface DeleteReviewRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(vars: DeleteReviewVariables): MutationRef<DeleteReviewData, DeleteReviewVariables>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect, vars: DeleteReviewVariables): MutationRef<DeleteReviewData, DeleteReviewVariables>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const deleteReviewRef: DeleteReviewRef;
|
|
||||||
|
|
||||||
export function deleteReview(vars: DeleteReviewVariables): MutationPromise<DeleteReviewData, DeleteReviewVariables>;
|
|
||||||
export function deleteReview(dc: DataConnect, vars: DeleteReviewVariables): MutationPromise<DeleteReviewData, DeleteReviewVariables>;
|
|
||||||
|
|
||||||
interface ListMoviesRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(): QueryRef<ListMoviesData, undefined>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect): QueryRef<ListMoviesData, undefined>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const listMoviesRef: ListMoviesRef;
|
|
||||||
|
|
||||||
export function listMovies(): QueryPromise<ListMoviesData, undefined>;
|
|
||||||
export function listMovies(dc: DataConnect): QueryPromise<ListMoviesData, undefined>;
|
|
||||||
|
|
||||||
interface ListUsersRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(): QueryRef<ListUsersData, undefined>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect): QueryRef<ListUsersData, undefined>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const listUsersRef: ListUsersRef;
|
|
||||||
|
|
||||||
export function listUsers(): QueryPromise<ListUsersData, undefined>;
|
|
||||||
export function listUsers(dc: DataConnect): QueryPromise<ListUsersData, undefined>;
|
|
||||||
|
|
||||||
interface ListUserReviewsRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(): QueryRef<ListUserReviewsData, undefined>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect): QueryRef<ListUserReviewsData, undefined>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const listUserReviewsRef: ListUserReviewsRef;
|
|
||||||
|
|
||||||
export function listUserReviews(): QueryPromise<ListUserReviewsData, undefined>;
|
|
||||||
export function listUserReviews(dc: DataConnect): QueryPromise<ListUserReviewsData, undefined>;
|
|
||||||
|
|
||||||
interface GetMovieByIdRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(vars: GetMovieByIdVariables): QueryRef<GetMovieByIdData, GetMovieByIdVariables>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect, vars: GetMovieByIdVariables): QueryRef<GetMovieByIdData, GetMovieByIdVariables>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const getMovieByIdRef: GetMovieByIdRef;
|
|
||||||
|
|
||||||
export function getMovieById(vars: GetMovieByIdVariables): QueryPromise<GetMovieByIdData, GetMovieByIdVariables>;
|
|
||||||
export function getMovieById(dc: DataConnect, vars: GetMovieByIdVariables): QueryPromise<GetMovieByIdData, GetMovieByIdVariables>;
|
|
||||||
|
|
||||||
interface SearchMovieRef {
|
|
||||||
/* Allow users to create refs without passing in DataConnect */
|
|
||||||
(vars?: SearchMovieVariables): QueryRef<SearchMovieData, SearchMovieVariables>;
|
|
||||||
/* Allow users to pass in custom DataConnect instances */
|
|
||||||
(dc: DataConnect, vars?: SearchMovieVariables): QueryRef<SearchMovieData, SearchMovieVariables>;
|
|
||||||
operationName: string;
|
|
||||||
}
|
|
||||||
export const searchMovieRef: SearchMovieRef;
|
|
||||||
|
|
||||||
export function searchMovie(vars?: SearchMovieVariables): QueryPromise<SearchMovieData, SearchMovieVariables>;
|
|
||||||
export function searchMovie(dc: DataConnect, vars?: SearchMovieVariables): QueryPromise<SearchMovieData, SearchMovieVariables>;
|
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@dataconnect/generated",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "Firebase <firebase-support@google.com> (https://firebase.google.com/)",
|
|
||||||
"description": "Generated SDK For example",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": " >=18.0"
|
|
||||||
},
|
|
||||||
"typings": "index.d.ts",
|
|
||||||
"module": "esm/index.esm.js",
|
|
||||||
"main": "index.cjs.js",
|
|
||||||
"browser": "esm/index.esm.js",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./index.d.ts",
|
|
||||||
"require": "./index.cjs.js",
|
|
||||||
"default": "./esm/index.esm.js"
|
|
||||||
},
|
|
||||||
"./react": {
|
|
||||||
"types": "./react/index.d.ts",
|
|
||||||
"require": "./react/index.cjs.js",
|
|
||||||
"import": "./react/esm/index.esm.js",
|
|
||||||
"default": "./react/esm/index.esm.js"
|
|
||||||
},
|
|
||||||
"./package.json": "./package.json"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"firebase": "^11.3.0 || ^12.0.0",
|
|
||||||
"@tanstack-query-firebase/react": "^2.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,952 +0,0 @@
|
|||||||
# Generated React README
|
|
||||||
This README will guide you through the process of using the generated React SDK package for the connector `example`. It will also provide examples on how to use your generated SDK to call your Data Connect queries and mutations.
|
|
||||||
|
|
||||||
**If you're looking for the `JavaScript README`, you can find it at [`dataconnect-generated/README.md`](../README.md)**
|
|
||||||
|
|
||||||
***NOTE:** This README is generated alongside the generated SDK. If you make changes to this file, they will be overwritten when the SDK is regenerated.*
|
|
||||||
|
|
||||||
You can use this generated SDK by importing from the package `@dataconnect/generated/react` as shown below. Both CommonJS and ESM imports are supported.
|
|
||||||
|
|
||||||
You can also follow the instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#react).
|
|
||||||
|
|
||||||
# Table of Contents
|
|
||||||
- [**Overview**](#generated-react-readme)
|
|
||||||
- [**TanStack Query Firebase & TanStack React Query**](#tanstack-query-firebase-tanstack-react-query)
|
|
||||||
- [*Package Installation*](#installing-tanstack-query-firebase-and-tanstack-react-query-packages)
|
|
||||||
- [*Configuring TanStack Query*](#configuring-tanstack-query)
|
|
||||||
- [**Accessing the connector**](#accessing-the-connector)
|
|
||||||
- [*Connecting to the local Emulator*](#connecting-to-the-local-emulator)
|
|
||||||
- [**Queries**](#queries)
|
|
||||||
- [*ListMovies*](#listmovies)
|
|
||||||
- [*ListUsers*](#listusers)
|
|
||||||
- [*ListUserReviews*](#listuserreviews)
|
|
||||||
- [*GetMovieById*](#getmoviebyid)
|
|
||||||
- [*SearchMovie*](#searchmovie)
|
|
||||||
- [**Mutations**](#mutations)
|
|
||||||
- [*CreateMovie*](#createmovie)
|
|
||||||
- [*UpsertUser*](#upsertuser)
|
|
||||||
- [*AddReview*](#addreview)
|
|
||||||
- [*DeleteReview*](#deletereview)
|
|
||||||
|
|
||||||
# TanStack Query Firebase & TanStack React Query
|
|
||||||
This SDK provides [React](https://react.dev/) hooks generated specific to your application, for the operations found in the connector `example`. These hooks are generated using [TanStack Query Firebase](https://react-query-firebase.invertase.dev/) by our partners at Invertase, a library built on top of [TanStack React Query v5](https://tanstack.com/query/v5/docs/framework/react/overview).
|
|
||||||
|
|
||||||
***You do not need to be familiar with Tanstack Query or Tanstack Query Firebase to use this SDK.*** However, you may find it useful to learn more about them, as they will empower you as a user of this Generated React SDK.
|
|
||||||
|
|
||||||
## Installing TanStack Query Firebase and TanStack React Query Packages
|
|
||||||
In order to use the React generated SDK, you must install the `TanStack React Query` and `TanStack Query Firebase` packages.
|
|
||||||
```bash
|
|
||||||
npm i --save @tanstack/react-query @tanstack-query-firebase/react
|
|
||||||
```
|
|
||||||
```bash
|
|
||||||
npm i --save firebase@latest # Note: React has a peer dependency on ^11.3.0
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also follow the installation instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#tanstack-install), or the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react) and [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/installation).
|
|
||||||
|
|
||||||
## Configuring TanStack Query
|
|
||||||
In order to use the React generated SDK in your application, you must wrap your application's component tree in a `QueryClientProvider` component from TanStack React Query. None of your generated React SDK hooks will work without this provider.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
// Create a TanStack Query client instance
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
// Provide the client to your App
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MyApplication />
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about `QueryClientProvider`, see the [TanStack React Query documentation](https://tanstack.com/query/latest/docs/framework/react/quick-start) and the [TanStack Query Firebase documentation](https://invertase.docs.page/tanstack-query-firebase/react#usage).
|
|
||||||
|
|
||||||
# Accessing the connector
|
|
||||||
A connector is a collection of Queries and Mutations. One SDK is generated for each connector - this SDK is generated for the connector `example`.
|
|
||||||
|
|
||||||
You can find more information about connectors in the [Data Connect documentation](https://firebase.google.com/docs/data-connect#how-does).
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig } from '@dataconnect/generated';
|
|
||||||
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connecting to the local Emulator
|
|
||||||
By default, the connector will connect to the production service.
|
|
||||||
|
|
||||||
To connect to the emulator, you can use the following code.
|
|
||||||
You can also follow the emulator instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#emulator-react-angular).
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { connectDataConnectEmulator, getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig } from '@dataconnect/generated';
|
|
||||||
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
connectDataConnectEmulator(dataConnect, 'localhost', 9399);
|
|
||||||
```
|
|
||||||
|
|
||||||
After it's initialized, you can call your Data Connect [queries](#queries) and [mutations](#mutations) using the hooks provided from your generated React SDK.
|
|
||||||
|
|
||||||
# Queries
|
|
||||||
|
|
||||||
The React generated SDK provides Query hook functions that call and return [`useDataConnectQuery`](https://react-query-firebase.invertase.dev/react/data-connect/querying) hooks from TanStack Query Firebase.
|
|
||||||
|
|
||||||
Calling these hook functions will return a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and the most recent data returned by the Query, among other things. To learn more about these hooks and how to use them, see the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react/data-connect/querying).
|
|
||||||
|
|
||||||
TanStack React Query caches the results of your Queries, so using the same Query hook function in multiple places in your application allows the entire application to automatically see updates to that Query's data.
|
|
||||||
|
|
||||||
Query hooks execute their Queries automatically when called, and periodically refresh, unless you change the `queryOptions` for the Query. To learn how to stop a Query from automatically executing, including how to make a query "lazy", see the [TanStack React Query documentation](https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries).
|
|
||||||
|
|
||||||
To learn more about TanStack React Query's Queries, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/queries).
|
|
||||||
|
|
||||||
## Using Query Hooks
|
|
||||||
Here's a general overview of how to use the generated Query hooks in your code:
|
|
||||||
|
|
||||||
- If the Query has no variables, the Query hook function does not require arguments.
|
|
||||||
- If the Query has any required variables, the Query hook function will require at least one argument: an object that contains all the required variables for the Query.
|
|
||||||
- If the Query has some required and some optional variables, only required variables are necessary in the variables argument object, and optional variables may be provided as well.
|
|
||||||
- If all of the Query's variables are optional, the Query hook function does not require any arguments.
|
|
||||||
- Query hook functions can be called with or without passing in a `DataConnect` instance as an argument. If no `DataConnect` argument is passed in, then the generated SDK will call `getDataConnect(connectorConfig)` behind the scenes for you.
|
|
||||||
- Query hooks functions can be called with or without passing in an `options` argument of type `useDataConnectQueryOptions`. To learn more about the `options` argument, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/query-options).
|
|
||||||
- ***Special case:*** If the Query has all optional variables and you would like to provide an `options` argument to the Query hook function without providing any variables, you must pass `undefined` where you would normally pass the Query's variables, and then may provide the `options` argument.
|
|
||||||
|
|
||||||
Below are examples of how to use the `example` connector's generated Query hook functions to execute each Query. You can also follow the examples from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#operations-react-angular).
|
|
||||||
|
|
||||||
## ListMovies
|
|
||||||
You can execute the `ListMovies` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useListMovies(dc: DataConnect, options?: useDataConnectQueryOptions<ListMoviesData>): UseDataConnectQueryResult<ListMoviesData, undefined>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
```javascript
|
|
||||||
useListMovies(options?: useDataConnectQueryOptions<ListMoviesData>): UseDataConnectQueryResult<ListMoviesData, undefined>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `ListMovies` Query has no variables.
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `ListMovies` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
|
|
||||||
|
|
||||||
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
|
|
||||||
|
|
||||||
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `ListMovies` Query is of type `ListMoviesData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface ListMoviesData {
|
|
||||||
movies: ({
|
|
||||||
id: UUIDString;
|
|
||||||
title: string;
|
|
||||||
imageUrl: string;
|
|
||||||
genre?: string | null;
|
|
||||||
} & Movie_Key)[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
|
|
||||||
|
|
||||||
### Using `ListMovies`'s Query hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig } from '@dataconnect/generated';
|
|
||||||
import { useListMovies } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function ListMoviesComponent() {
|
|
||||||
// You don't have to do anything to "execute" the Query.
|
|
||||||
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
|
|
||||||
const query = useListMovies();
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const query = useListMovies(dataConnect);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useListMovies(options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useListMovies(dataConnect, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Query.
|
|
||||||
if (query.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.isError) {
|
|
||||||
return <div>Error: {query.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
|
|
||||||
if (query.isSuccess) {
|
|
||||||
console.log(query.data.movies);
|
|
||||||
}
|
|
||||||
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ListUsers
|
|
||||||
You can execute the `ListUsers` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useListUsers(dc: DataConnect, options?: useDataConnectQueryOptions<ListUsersData>): UseDataConnectQueryResult<ListUsersData, undefined>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
```javascript
|
|
||||||
useListUsers(options?: useDataConnectQueryOptions<ListUsersData>): UseDataConnectQueryResult<ListUsersData, undefined>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `ListUsers` Query has no variables.
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `ListUsers` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
|
|
||||||
|
|
||||||
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
|
|
||||||
|
|
||||||
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `ListUsers` Query is of type `ListUsersData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface ListUsersData {
|
|
||||||
users: ({
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
} & User_Key)[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
|
|
||||||
|
|
||||||
### Using `ListUsers`'s Query hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig } from '@dataconnect/generated';
|
|
||||||
import { useListUsers } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function ListUsersComponent() {
|
|
||||||
// You don't have to do anything to "execute" the Query.
|
|
||||||
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
|
|
||||||
const query = useListUsers();
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const query = useListUsers(dataConnect);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useListUsers(options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useListUsers(dataConnect, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Query.
|
|
||||||
if (query.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.isError) {
|
|
||||||
return <div>Error: {query.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
|
|
||||||
if (query.isSuccess) {
|
|
||||||
console.log(query.data.users);
|
|
||||||
}
|
|
||||||
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ListUserReviews
|
|
||||||
You can execute the `ListUserReviews` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useListUserReviews(dc: DataConnect, options?: useDataConnectQueryOptions<ListUserReviewsData>): UseDataConnectQueryResult<ListUserReviewsData, undefined>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
```javascript
|
|
||||||
useListUserReviews(options?: useDataConnectQueryOptions<ListUserReviewsData>): UseDataConnectQueryResult<ListUserReviewsData, undefined>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `ListUserReviews` Query has no variables.
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `ListUserReviews` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
|
|
||||||
|
|
||||||
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
|
|
||||||
|
|
||||||
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `ListUserReviews` Query is of type `ListUserReviewsData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface ListUserReviewsData {
|
|
||||||
user?: {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
reviews: ({
|
|
||||||
rating?: number | null;
|
|
||||||
reviewDate: DateString;
|
|
||||||
reviewText?: string | null;
|
|
||||||
movie: {
|
|
||||||
id: UUIDString;
|
|
||||||
title: string;
|
|
||||||
} & Movie_Key;
|
|
||||||
})[];
|
|
||||||
} & User_Key;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
|
|
||||||
|
|
||||||
### Using `ListUserReviews`'s Query hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig } from '@dataconnect/generated';
|
|
||||||
import { useListUserReviews } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function ListUserReviewsComponent() {
|
|
||||||
// You don't have to do anything to "execute" the Query.
|
|
||||||
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
|
|
||||||
const query = useListUserReviews();
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const query = useListUserReviews(dataConnect);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useListUserReviews(options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useListUserReviews(dataConnect, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Query.
|
|
||||||
if (query.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.isError) {
|
|
||||||
return <div>Error: {query.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
|
|
||||||
if (query.isSuccess) {
|
|
||||||
console.log(query.data.user);
|
|
||||||
}
|
|
||||||
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## GetMovieById
|
|
||||||
You can execute the `GetMovieById` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useGetMovieById(dc: DataConnect, vars: GetMovieByIdVariables, options?: useDataConnectQueryOptions<GetMovieByIdData>): UseDataConnectQueryResult<GetMovieByIdData, GetMovieByIdVariables>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
```javascript
|
|
||||||
useGetMovieById(vars: GetMovieByIdVariables, options?: useDataConnectQueryOptions<GetMovieByIdData>): UseDataConnectQueryResult<GetMovieByIdData, GetMovieByIdVariables>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `GetMovieById` Query requires an argument of type `GetMovieByIdVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export interface GetMovieByIdVariables {
|
|
||||||
id: UUIDString;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `GetMovieById` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
|
|
||||||
|
|
||||||
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
|
|
||||||
|
|
||||||
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `GetMovieById` Query is of type `GetMovieByIdData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface GetMovieByIdData {
|
|
||||||
movie?: {
|
|
||||||
id: UUIDString;
|
|
||||||
title: string;
|
|
||||||
imageUrl: string;
|
|
||||||
genre?: string | null;
|
|
||||||
metadata?: {
|
|
||||||
rating?: number | null;
|
|
||||||
releaseYear?: number | null;
|
|
||||||
description?: string | null;
|
|
||||||
};
|
|
||||||
reviews: ({
|
|
||||||
reviewText?: string | null;
|
|
||||||
reviewDate: DateString;
|
|
||||||
rating?: number | null;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
} & User_Key;
|
|
||||||
})[];
|
|
||||||
} & Movie_Key;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
|
|
||||||
|
|
||||||
### Using `GetMovieById`'s Query hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig, GetMovieByIdVariables } from '@dataconnect/generated';
|
|
||||||
import { useGetMovieById } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function GetMovieByIdComponent() {
|
|
||||||
// The `useGetMovieById` Query hook requires an argument of type `GetMovieByIdVariables`:
|
|
||||||
const getMovieByIdVars: GetMovieByIdVariables = {
|
|
||||||
id: ...,
|
|
||||||
};
|
|
||||||
|
|
||||||
// You don't have to do anything to "execute" the Query.
|
|
||||||
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
|
|
||||||
const query = useGetMovieById(getMovieByIdVars);
|
|
||||||
// Variables can be defined inline as well.
|
|
||||||
const query = useGetMovieById({ id: ..., });
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const query = useGetMovieById(dataConnect, getMovieByIdVars);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useGetMovieById(getMovieByIdVars, options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useGetMovieById(dataConnect, getMovieByIdVars, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Query.
|
|
||||||
if (query.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.isError) {
|
|
||||||
return <div>Error: {query.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
|
|
||||||
if (query.isSuccess) {
|
|
||||||
console.log(query.data.movie);
|
|
||||||
}
|
|
||||||
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## SearchMovie
|
|
||||||
You can execute the `SearchMovie` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useSearchMovie(dc: DataConnect, vars?: SearchMovieVariables, options?: useDataConnectQueryOptions<SearchMovieData>): UseDataConnectQueryResult<SearchMovieData, SearchMovieVariables>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
```javascript
|
|
||||||
useSearchMovie(vars?: SearchMovieVariables, options?: useDataConnectQueryOptions<SearchMovieData>): UseDataConnectQueryResult<SearchMovieData, SearchMovieVariables>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `SearchMovie` Query has an optional argument of type `SearchMovieVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export interface SearchMovieVariables {
|
|
||||||
titleInput?: string | null;
|
|
||||||
genre?: string | null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `SearchMovie` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
|
|
||||||
|
|
||||||
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
|
|
||||||
|
|
||||||
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `SearchMovie` Query is of type `SearchMovieData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface SearchMovieData {
|
|
||||||
movies: ({
|
|
||||||
id: UUIDString;
|
|
||||||
title: string;
|
|
||||||
genre?: string | null;
|
|
||||||
imageUrl: string;
|
|
||||||
} & Movie_Key)[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
|
|
||||||
|
|
||||||
### Using `SearchMovie`'s Query hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig, SearchMovieVariables } from '@dataconnect/generated';
|
|
||||||
import { useSearchMovie } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function SearchMovieComponent() {
|
|
||||||
// The `useSearchMovie` Query hook has an optional argument of type `SearchMovieVariables`:
|
|
||||||
const searchMovieVars: SearchMovieVariables = {
|
|
||||||
titleInput: ..., // optional
|
|
||||||
genre: ..., // optional
|
|
||||||
};
|
|
||||||
|
|
||||||
// You don't have to do anything to "execute" the Query.
|
|
||||||
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
|
|
||||||
const query = useSearchMovie(searchMovieVars);
|
|
||||||
// Variables can be defined inline as well.
|
|
||||||
const query = useSearchMovie({ titleInput: ..., genre: ..., });
|
|
||||||
// Since all variables are optional for this Query, you can omit the `SearchMovieVariables` argument.
|
|
||||||
// (as long as you don't want to provide any `options`!)
|
|
||||||
const query = useSearchMovie();
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Query hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const query = useSearchMovie(dataConnect, searchMovieVars);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useSearchMovie(searchMovieVars, options);
|
|
||||||
// If you'd like to provide options without providing any variables, you must
|
|
||||||
// pass `undefined` where you would normally pass the variables.
|
|
||||||
const query = useSearchMovie(undefined, options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = { staleTime: 5 * 1000 };
|
|
||||||
const query = useSearchMovie(dataConnect, searchMovieVars /** or undefined */, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Query.
|
|
||||||
if (query.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.isError) {
|
|
||||||
return <div>Error: {query.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
|
|
||||||
if (query.isSuccess) {
|
|
||||||
console.log(query.data.movies);
|
|
||||||
}
|
|
||||||
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
# Mutations
|
|
||||||
|
|
||||||
The React generated SDK provides Mutations hook functions that call and return [`useDataConnectMutation`](https://react-query-firebase.invertase.dev/react/data-connect/mutations) hooks from TanStack Query Firebase.
|
|
||||||
|
|
||||||
Calling these hook functions will return a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, and the most recent data returned by the Mutation, among other things. To learn more about these hooks and how to use them, see the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react/data-connect/mutations).
|
|
||||||
|
|
||||||
Mutation hooks do not execute their Mutations automatically when called. Rather, after calling the Mutation hook function and getting a `UseMutationResult` object, you must call the `UseMutationResult.mutate()` function to execute the Mutation.
|
|
||||||
|
|
||||||
To learn more about TanStack React Query's Mutations, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/mutations).
|
|
||||||
|
|
||||||
## Using Mutation Hooks
|
|
||||||
Here's a general overview of how to use the generated Mutation hooks in your code:
|
|
||||||
|
|
||||||
- Mutation hook functions are not called with the arguments to the Mutation. Instead, arguments are passed to `UseMutationResult.mutate()`.
|
|
||||||
- If the Mutation has no variables, the `mutate()` function does not require arguments.
|
|
||||||
- If the Mutation has any required variables, the `mutate()` function will require at least one argument: an object that contains all the required variables for the Mutation.
|
|
||||||
- If the Mutation has some required and some optional variables, only required variables are necessary in the variables argument object, and optional variables may be provided as well.
|
|
||||||
- If all of the Mutation's variables are optional, the Mutation hook function does not require any arguments.
|
|
||||||
- Mutation hook functions can be called with or without passing in a `DataConnect` instance as an argument. If no `DataConnect` argument is passed in, then the generated SDK will call `getDataConnect(connectorConfig)` behind the scenes for you.
|
|
||||||
- Mutation hooks also accept an `options` argument of type `useDataConnectMutationOptions`. To learn more about the `options` argument, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/mutations#mutation-side-effects).
|
|
||||||
- `UseMutationResult.mutate()` also accepts an `options` argument of type `useDataConnectMutationOptions`.
|
|
||||||
- ***Special case:*** If the Mutation has no arguments (or all optional arguments and you wish to provide none), and you want to pass `options` to `UseMutationResult.mutate()`, you must pass `undefined` where you would normally pass the Mutation's arguments, and then may provide the options argument.
|
|
||||||
|
|
||||||
Below are examples of how to use the `example` connector's generated Mutation hook functions to execute each Mutation. You can also follow the examples from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#operations-react-angular).
|
|
||||||
|
|
||||||
## CreateMovie
|
|
||||||
You can execute the `CreateMovie` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
|
|
||||||
```javascript
|
|
||||||
useCreateMovie(options?: useDataConnectMutationOptions<CreateMovieData, FirebaseError, CreateMovieVariables>): UseDataConnectMutationResult<CreateMovieData, CreateMovieVariables>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Mutation hook function.
|
|
||||||
```javascript
|
|
||||||
useCreateMovie(dc: DataConnect, options?: useDataConnectMutationOptions<CreateMovieData, FirebaseError, CreateMovieVariables>): UseDataConnectMutationResult<CreateMovieData, CreateMovieVariables>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `CreateMovie` Mutation requires an argument of type `CreateMovieVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export interface CreateMovieVariables {
|
|
||||||
title: string;
|
|
||||||
genre: string;
|
|
||||||
imageUrl: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `CreateMovie` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
|
|
||||||
|
|
||||||
To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
|
|
||||||
|
|
||||||
To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
|
|
||||||
|
|
||||||
To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `CreateMovie` Mutation is of type `CreateMovieData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface CreateMovieData {
|
|
||||||
movie_insert: Movie_Key;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
|
|
||||||
|
|
||||||
### Using `CreateMovie`'s Mutation hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig, CreateMovieVariables } from '@dataconnect/generated';
|
|
||||||
import { useCreateMovie } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function CreateMovieComponent() {
|
|
||||||
// Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
|
|
||||||
const mutation = useCreateMovie();
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Mutation hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const mutation = useCreateMovie(dataConnect);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
const mutation = useCreateMovie(options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
const mutation = useCreateMovie(dataConnect, options);
|
|
||||||
|
|
||||||
// After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
|
|
||||||
// The `useCreateMovie` Mutation requires an argument of type `CreateMovieVariables`:
|
|
||||||
const createMovieVars: CreateMovieVariables = {
|
|
||||||
title: ...,
|
|
||||||
genre: ...,
|
|
||||||
imageUrl: ...,
|
|
||||||
};
|
|
||||||
mutation.mutate(createMovieVars);
|
|
||||||
// Variables can be defined inline as well.
|
|
||||||
mutation.mutate({ title: ..., genre: ..., imageUrl: ..., });
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
mutation.mutate(createMovieVars, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Mutation.
|
|
||||||
if (mutation.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mutation.isError) {
|
|
||||||
return <div>Error: {mutation.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
|
|
||||||
if (mutation.isSuccess) {
|
|
||||||
console.log(mutation.data.movie_insert);
|
|
||||||
}
|
|
||||||
return <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## UpsertUser
|
|
||||||
You can execute the `UpsertUser` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
|
|
||||||
```javascript
|
|
||||||
useUpsertUser(options?: useDataConnectMutationOptions<UpsertUserData, FirebaseError, UpsertUserVariables>): UseDataConnectMutationResult<UpsertUserData, UpsertUserVariables>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Mutation hook function.
|
|
||||||
```javascript
|
|
||||||
useUpsertUser(dc: DataConnect, options?: useDataConnectMutationOptions<UpsertUserData, FirebaseError, UpsertUserVariables>): UseDataConnectMutationResult<UpsertUserData, UpsertUserVariables>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `UpsertUser` Mutation requires an argument of type `UpsertUserVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export interface UpsertUserVariables {
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `UpsertUser` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
|
|
||||||
|
|
||||||
To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
|
|
||||||
|
|
||||||
To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
|
|
||||||
|
|
||||||
To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `UpsertUser` Mutation is of type `UpsertUserData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface UpsertUserData {
|
|
||||||
user_upsert: User_Key;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
|
|
||||||
|
|
||||||
### Using `UpsertUser`'s Mutation hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig, UpsertUserVariables } from '@dataconnect/generated';
|
|
||||||
import { useUpsertUser } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function UpsertUserComponent() {
|
|
||||||
// Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
|
|
||||||
const mutation = useUpsertUser();
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Mutation hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const mutation = useUpsertUser(dataConnect);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
const mutation = useUpsertUser(options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
const mutation = useUpsertUser(dataConnect, options);
|
|
||||||
|
|
||||||
// After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
|
|
||||||
// The `useUpsertUser` Mutation requires an argument of type `UpsertUserVariables`:
|
|
||||||
const upsertUserVars: UpsertUserVariables = {
|
|
||||||
username: ...,
|
|
||||||
};
|
|
||||||
mutation.mutate(upsertUserVars);
|
|
||||||
// Variables can be defined inline as well.
|
|
||||||
mutation.mutate({ username: ..., });
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
mutation.mutate(upsertUserVars, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Mutation.
|
|
||||||
if (mutation.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mutation.isError) {
|
|
||||||
return <div>Error: {mutation.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
|
|
||||||
if (mutation.isSuccess) {
|
|
||||||
console.log(mutation.data.user_upsert);
|
|
||||||
}
|
|
||||||
return <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## AddReview
|
|
||||||
You can execute the `AddReview` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
|
|
||||||
```javascript
|
|
||||||
useAddReview(options?: useDataConnectMutationOptions<AddReviewData, FirebaseError, AddReviewVariables>): UseDataConnectMutationResult<AddReviewData, AddReviewVariables>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Mutation hook function.
|
|
||||||
```javascript
|
|
||||||
useAddReview(dc: DataConnect, options?: useDataConnectMutationOptions<AddReviewData, FirebaseError, AddReviewVariables>): UseDataConnectMutationResult<AddReviewData, AddReviewVariables>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `AddReview` Mutation requires an argument of type `AddReviewVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export interface AddReviewVariables {
|
|
||||||
movieId: UUIDString;
|
|
||||||
rating: number;
|
|
||||||
reviewText: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `AddReview` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
|
|
||||||
|
|
||||||
To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
|
|
||||||
|
|
||||||
To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
|
|
||||||
|
|
||||||
To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `AddReview` Mutation is of type `AddReviewData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface AddReviewData {
|
|
||||||
review_upsert: Review_Key;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
|
|
||||||
|
|
||||||
### Using `AddReview`'s Mutation hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig, AddReviewVariables } from '@dataconnect/generated';
|
|
||||||
import { useAddReview } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function AddReviewComponent() {
|
|
||||||
// Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
|
|
||||||
const mutation = useAddReview();
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Mutation hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const mutation = useAddReview(dataConnect);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
const mutation = useAddReview(options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
const mutation = useAddReview(dataConnect, options);
|
|
||||||
|
|
||||||
// After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
|
|
||||||
// The `useAddReview` Mutation requires an argument of type `AddReviewVariables`:
|
|
||||||
const addReviewVars: AddReviewVariables = {
|
|
||||||
movieId: ...,
|
|
||||||
rating: ...,
|
|
||||||
reviewText: ...,
|
|
||||||
};
|
|
||||||
mutation.mutate(addReviewVars);
|
|
||||||
// Variables can be defined inline as well.
|
|
||||||
mutation.mutate({ movieId: ..., rating: ..., reviewText: ..., });
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
mutation.mutate(addReviewVars, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Mutation.
|
|
||||||
if (mutation.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mutation.isError) {
|
|
||||||
return <div>Error: {mutation.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
|
|
||||||
if (mutation.isSuccess) {
|
|
||||||
console.log(mutation.data.review_upsert);
|
|
||||||
}
|
|
||||||
return <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## DeleteReview
|
|
||||||
You can execute the `DeleteReview` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
|
|
||||||
```javascript
|
|
||||||
useDeleteReview(options?: useDataConnectMutationOptions<DeleteReviewData, FirebaseError, DeleteReviewVariables>): UseDataConnectMutationResult<DeleteReviewData, DeleteReviewVariables>;
|
|
||||||
```
|
|
||||||
You can also pass in a `DataConnect` instance to the Mutation hook function.
|
|
||||||
```javascript
|
|
||||||
useDeleteReview(dc: DataConnect, options?: useDataConnectMutationOptions<DeleteReviewData, FirebaseError, DeleteReviewVariables>): UseDataConnectMutationResult<DeleteReviewData, DeleteReviewVariables>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
The `DeleteReview` Mutation requires an argument of type `DeleteReviewVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export interface DeleteReviewVariables {
|
|
||||||
movieId: UUIDString;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### Return Type
|
|
||||||
Recall that calling the `DeleteReview` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
|
|
||||||
|
|
||||||
To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
|
|
||||||
|
|
||||||
To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
|
|
||||||
|
|
||||||
To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `DeleteReview` Mutation is of type `DeleteReviewData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
|
|
||||||
```javascript
|
|
||||||
export interface DeleteReviewData {
|
|
||||||
review_delete?: Review_Key | null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
|
|
||||||
|
|
||||||
### Using `DeleteReview`'s Mutation hook function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { getDataConnect } from 'firebase/data-connect';
|
|
||||||
import { connectorConfig, DeleteReviewVariables } from '@dataconnect/generated';
|
|
||||||
import { useDeleteReview } from '@dataconnect/generated/react'
|
|
||||||
|
|
||||||
export default function DeleteReviewComponent() {
|
|
||||||
// Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
|
|
||||||
const mutation = useDeleteReview();
|
|
||||||
|
|
||||||
// You can also pass in a `DataConnect` instance to the Mutation hook function.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const mutation = useDeleteReview(dataConnect);
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
const mutation = useDeleteReview(options);
|
|
||||||
|
|
||||||
// You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
|
|
||||||
const dataConnect = getDataConnect(connectorConfig);
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
const mutation = useDeleteReview(dataConnect, options);
|
|
||||||
|
|
||||||
// After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
|
|
||||||
// The `useDeleteReview` Mutation requires an argument of type `DeleteReviewVariables`:
|
|
||||||
const deleteReviewVars: DeleteReviewVariables = {
|
|
||||||
movieId: ...,
|
|
||||||
};
|
|
||||||
mutation.mutate(deleteReviewVars);
|
|
||||||
// Variables can be defined inline as well.
|
|
||||||
mutation.mutate({ movieId: ..., });
|
|
||||||
|
|
||||||
// You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
|
|
||||||
const options = {
|
|
||||||
onSuccess: () => { console.log('Mutation succeeded!'); }
|
|
||||||
};
|
|
||||||
mutation.mutate(deleteReviewVars, options);
|
|
||||||
|
|
||||||
// Then, you can render your component dynamically based on the status of the Mutation.
|
|
||||||
if (mutation.isPending) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mutation.isError) {
|
|
||||||
return <div>Error: {mutation.error.message}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
|
|
||||||
if (mutation.isSuccess) {
|
|
||||||
console.log(mutation.data.review_delete);
|
|
||||||
}
|
|
||||||
return <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { createMovieRef, upsertUserRef, addReviewRef, deleteReviewRef, listMoviesRef, listUsersRef, listUserReviewsRef, getMovieByIdRef, searchMovieRef, connectorConfig } from '../../esm/index.esm.js';
|
|
||||||
import { validateArgs, CallerSdkTypeEnum } from 'firebase/data-connect';
|
|
||||||
import { useDataConnectQuery, useDataConnectMutation, validateReactArgs } from '@tanstack-query-firebase/react/data-connect';
|
|
||||||
|
|
||||||
export function useCreateMovie(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
function refFactory(vars) {
|
|
||||||
return createMovieRef(dcInstance, vars);
|
|
||||||
}
|
|
||||||
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpsertUser(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
function refFactory(vars) {
|
|
||||||
return upsertUserRef(dcInstance, vars);
|
|
||||||
}
|
|
||||||
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAddReview(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
function refFactory(vars) {
|
|
||||||
return addReviewRef(dcInstance, vars);
|
|
||||||
}
|
|
||||||
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteReview(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
function refFactory(vars) {
|
|
||||||
return deleteReviewRef(dcInstance, vars);
|
|
||||||
}
|
|
||||||
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useListMovies(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
const ref = listMoviesRef(dcInstance);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useListUsers(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
const ref = listUsersRef(dcInstance);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useListUserReviews(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
const ref = listUserReviewsRef(dcInstance);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetMovieById(dcOrVars, varsOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputVars, options: inputOpts } = validateReactArgs(connectorConfig, dcOrVars, varsOrOptions, options, true, true);
|
|
||||||
const ref = getMovieByIdRef(dcInstance, inputVars);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearchMovie(dcOrVars, varsOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputVars, options: inputOpts } = validateReactArgs(connectorConfig, dcOrVars, varsOrOptions, options, true, false);
|
|
||||||
const ref = searchMovieRef(dcInstance, inputVars);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"type":"module"}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
const { createMovieRef, upsertUserRef, addReviewRef, deleteReviewRef, listMoviesRef, listUsersRef, listUserReviewsRef, getMovieByIdRef, searchMovieRef, connectorConfig } = require('../index.cjs.js');
|
|
||||||
const { validateArgs, CallerSdkTypeEnum } = require('firebase/data-connect');
|
|
||||||
const { useDataConnectQuery, useDataConnectMutation, validateReactArgs } = require('@tanstack-query-firebase/react/data-connect');
|
|
||||||
|
|
||||||
exports.useCreateMovie = function useCreateMovie(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
function refFactory(vars) {
|
|
||||||
return createMovieRef(dcInstance, vars);
|
|
||||||
}
|
|
||||||
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.useUpsertUser = function useUpsertUser(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
function refFactory(vars) {
|
|
||||||
return upsertUserRef(dcInstance, vars);
|
|
||||||
}
|
|
||||||
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.useAddReview = function useAddReview(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
function refFactory(vars) {
|
|
||||||
return addReviewRef(dcInstance, vars);
|
|
||||||
}
|
|
||||||
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.useDeleteReview = function useDeleteReview(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
function refFactory(vars) {
|
|
||||||
return deleteReviewRef(dcInstance, vars);
|
|
||||||
}
|
|
||||||
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
exports.useListMovies = function useListMovies(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
const ref = listMoviesRef(dcInstance);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.useListUsers = function useListUsers(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
const ref = listUsersRef(dcInstance);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.useListUserReviews = function useListUserReviews(dcOrOptions, options) {
|
|
||||||
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
|
|
||||||
const ref = listUserReviewsRef(dcInstance);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.useGetMovieById = function useGetMovieById(dcOrVars, varsOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputVars, options: inputOpts } = validateReactArgs(connectorConfig, dcOrVars, varsOrOptions, options, true, true);
|
|
||||||
const ref = getMovieByIdRef(dcInstance, inputVars);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.useSearchMovie = function useSearchMovie(dcOrVars, varsOrOptions, options) {
|
|
||||||
const { dc: dcInstance, vars: inputVars, options: inputOpts } = validateReactArgs(connectorConfig, dcOrVars, varsOrOptions, options, true, false);
|
|
||||||
const ref = searchMovieRef(dcInstance, inputVars);
|
|
||||||
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { CreateMovieData, CreateMovieVariables, UpsertUserData, UpsertUserVariables, AddReviewData, AddReviewVariables, DeleteReviewData, DeleteReviewVariables, ListMoviesData, ListUsersData, ListUserReviewsData, GetMovieByIdData, GetMovieByIdVariables, SearchMovieData, SearchMovieVariables } from '../';
|
|
||||||
import { UseDataConnectQueryResult, useDataConnectQueryOptions, UseDataConnectMutationResult, useDataConnectMutationOptions} from '@tanstack-query-firebase/react/data-connect';
|
|
||||||
import { UseQueryResult, UseMutationResult} from '@tanstack/react-query';
|
|
||||||
import { DataConnect } from 'firebase/data-connect';
|
|
||||||
import { FirebaseError } from 'firebase/app';
|
|
||||||
|
|
||||||
|
|
||||||
export function useCreateMovie(options?: useDataConnectMutationOptions<CreateMovieData, FirebaseError, CreateMovieVariables>): UseDataConnectMutationResult<CreateMovieData, CreateMovieVariables>;
|
|
||||||
export function useCreateMovie(dc: DataConnect, options?: useDataConnectMutationOptions<CreateMovieData, FirebaseError, CreateMovieVariables>): UseDataConnectMutationResult<CreateMovieData, CreateMovieVariables>;
|
|
||||||
|
|
||||||
export function useUpsertUser(options?: useDataConnectMutationOptions<UpsertUserData, FirebaseError, UpsertUserVariables>): UseDataConnectMutationResult<UpsertUserData, UpsertUserVariables>;
|
|
||||||
export function useUpsertUser(dc: DataConnect, options?: useDataConnectMutationOptions<UpsertUserData, FirebaseError, UpsertUserVariables>): UseDataConnectMutationResult<UpsertUserData, UpsertUserVariables>;
|
|
||||||
|
|
||||||
export function useAddReview(options?: useDataConnectMutationOptions<AddReviewData, FirebaseError, AddReviewVariables>): UseDataConnectMutationResult<AddReviewData, AddReviewVariables>;
|
|
||||||
export function useAddReview(dc: DataConnect, options?: useDataConnectMutationOptions<AddReviewData, FirebaseError, AddReviewVariables>): UseDataConnectMutationResult<AddReviewData, AddReviewVariables>;
|
|
||||||
|
|
||||||
export function useDeleteReview(options?: useDataConnectMutationOptions<DeleteReviewData, FirebaseError, DeleteReviewVariables>): UseDataConnectMutationResult<DeleteReviewData, DeleteReviewVariables>;
|
|
||||||
export function useDeleteReview(dc: DataConnect, options?: useDataConnectMutationOptions<DeleteReviewData, FirebaseError, DeleteReviewVariables>): UseDataConnectMutationResult<DeleteReviewData, DeleteReviewVariables>;
|
|
||||||
|
|
||||||
export function useListMovies(options?: useDataConnectQueryOptions<ListMoviesData>): UseDataConnectQueryResult<ListMoviesData, undefined>;
|
|
||||||
export function useListMovies(dc: DataConnect, options?: useDataConnectQueryOptions<ListMoviesData>): UseDataConnectQueryResult<ListMoviesData, undefined>;
|
|
||||||
|
|
||||||
export function useListUsers(options?: useDataConnectQueryOptions<ListUsersData>): UseDataConnectQueryResult<ListUsersData, undefined>;
|
|
||||||
export function useListUsers(dc: DataConnect, options?: useDataConnectQueryOptions<ListUsersData>): UseDataConnectQueryResult<ListUsersData, undefined>;
|
|
||||||
|
|
||||||
export function useListUserReviews(options?: useDataConnectQueryOptions<ListUserReviewsData>): UseDataConnectQueryResult<ListUserReviewsData, undefined>;
|
|
||||||
export function useListUserReviews(dc: DataConnect, options?: useDataConnectQueryOptions<ListUserReviewsData>): UseDataConnectQueryResult<ListUserReviewsData, undefined>;
|
|
||||||
|
|
||||||
export function useGetMovieById(vars: GetMovieByIdVariables, options?: useDataConnectQueryOptions<GetMovieByIdData>): UseDataConnectQueryResult<GetMovieByIdData, GetMovieByIdVariables>;
|
|
||||||
export function useGetMovieById(dc: DataConnect, vars: GetMovieByIdVariables, options?: useDataConnectQueryOptions<GetMovieByIdData>): UseDataConnectQueryResult<GetMovieByIdData, GetMovieByIdVariables>;
|
|
||||||
|
|
||||||
export function useSearchMovie(vars?: SearchMovieVariables, options?: useDataConnectQueryOptions<SearchMovieData>): UseDataConnectQueryResult<SearchMovieData, SearchMovieVariables>;
|
|
||||||
export function useSearchMovie(dc: DataConnect, vars?: SearchMovieVariables, options?: useDataConnectQueryOptions<SearchMovieData>): UseDataConnectQueryResult<SearchMovieData, SearchMovieVariables>;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@dataconnect/generated-react",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "Firebase <firebase-support@google.com> (https://firebase.google.com/)",
|
|
||||||
"description": "Generated SDK For example",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": " >=18.0"
|
|
||||||
},
|
|
||||||
"typings": "index.d.ts",
|
|
||||||
"main": "index.cjs.js",
|
|
||||||
"module": "esm/index.esm.js",
|
|
||||||
"browser": "esm/index.esm.js",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@tanstack-query-firebase/react": "^2.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,282 +1,508 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
import { base44 } from "@/api/base44Client";
|
import { base44 } from "@/api/base44Client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Calendar as CalendarIcon, MapPin, Users, Clock, DollarSign, FileText, Plus, RefreshCw, Zap } from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import { format, addDays } from "date-fns";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Tabs, // New import
|
||||||
|
TabsList, // New import
|
||||||
|
TabsTrigger, // New import
|
||||||
|
} from "@/components/ui/tabs"; // New import
|
||||||
|
import {
|
||||||
|
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2
|
||||||
|
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus
|
||||||
|
} from "lucide-react";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import QuickReorderModal from "@/components/events/QuickReorderModal";
|
import { format, parseISO, isValid } from "date-fns";
|
||||||
|
|
||||||
|
const safeParseDate = (dateString) => {
|
||||||
|
if (!dateString) return null;
|
||||||
|
try {
|
||||||
|
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||||
|
return isValid(date) ? date : null;
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeFormatDate = (dateString, formatString) => {
|
||||||
|
const date = safeParseDate(dateString);
|
||||||
|
return date ? format(date, formatString) : '—';
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertTo12Hour = (time24) => {
|
||||||
|
if (!time24) return "-";
|
||||||
|
try {
|
||||||
|
const [hours, minutes] = time24.split(':');
|
||||||
|
const hour = parseInt(hours);
|
||||||
|
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const hour12 = hour % 12 || 12;
|
||||||
|
return `${hour12}:${minutes} ${ampm}`;
|
||||||
|
} catch {
|
||||||
|
return time24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (event) => {
|
||||||
|
if (event.is_rapid) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
|
||||||
|
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||||
|
RAPID
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
'Draft': { bg: 'bg-slate-500', icon: FileText },
|
||||||
|
'Pending': { bg: 'bg-amber-500', icon: Clock },
|
||||||
|
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
|
||||||
|
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
|
||||||
|
'Active': { bg: 'bg-blue-500', icon: Users },
|
||||||
|
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
|
||||||
|
'Canceled': { bg: 'bg-red-500', icon: X },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{event.status}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function ClientOrders() {
|
export default function ClientOrders() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const queryClient = useQueryClient();
|
||||||
const [reorderModalOpen, setReorderModalOpen] = useState(false);
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
|
||||||
|
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
|
||||||
|
|
||||||
const { data: user } = useQuery({
|
const { data: user } = useQuery({
|
||||||
queryKey: ['current-user'],
|
queryKey: ['current-user-client-orders'],
|
||||||
queryFn: () => base44.auth.me(),
|
queryFn: () => base44.auth.me(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: events } = useQuery({
|
const { data: allEvents = [] } = useQuery({
|
||||||
queryKey: ['client-events'],
|
queryKey: ['all-events-client'],
|
||||||
queryFn: () => base44.entities.Event.list('-date'),
|
queryFn: () => base44.entities.Event.list('-date'),
|
||||||
initialData: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter events by current client
|
const clientEvents = useMemo(() => {
|
||||||
const clientEvents = events.filter(e =>
|
return allEvents.filter(e =>
|
||||||
e.client_email === user?.email || e.created_by === user?.email
|
e.client_email === user?.email ||
|
||||||
|
e.business_name === user?.company_name ||
|
||||||
|
e.created_by === user?.email
|
||||||
);
|
);
|
||||||
|
}, [allEvents, user]);
|
||||||
|
|
||||||
const filteredEvents = statusFilter === "all"
|
const cancelOrderMutation = useMutation({
|
||||||
? clientEvents
|
mutationFn: (orderId) => base44.entities.Event.update(orderId, { status: "Canceled" }),
|
||||||
: clientEvents.filter(e => {
|
onSuccess: () => {
|
||||||
if (statusFilter === "rapid_request") return e.is_rapid_request;
|
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
|
||||||
if (statusFilter === "pending") return e.status?.toLowerCase() === "pending" || e.status?.toLowerCase() === "draft";
|
toast({
|
||||||
return e.status?.toLowerCase() === statusFilter;
|
title: "✅ Order Canceled",
|
||||||
|
description: "Your order has been canceled successfully",
|
||||||
|
});
|
||||||
|
setCancelDialogOpen(false); // Updated
|
||||||
|
setOrderToCancel(null); // Updated
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Failed to Cancel",
|
||||||
|
description: "Could not cancel order. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const filteredOrders = useMemo(() => { // Renamed from filteredEvents
|
||||||
const colors = {
|
let filtered = clientEvents;
|
||||||
'pending': 'bg-yellow-100 text-yellow-700',
|
|
||||||
'draft': 'bg-gray-100 text-gray-700',
|
if (searchTerm) {
|
||||||
'confirmed': 'bg-green-100 text-green-700',
|
const lower = searchTerm.toLowerCase();
|
||||||
'active': 'bg-blue-100 text-blue-700',
|
filtered = filtered.filter(e =>
|
||||||
'completed': 'bg-slate-100 text-slate-700',
|
e.event_name?.toLowerCase().includes(lower) ||
|
||||||
'canceled': 'bg-red-100 text-red-700',
|
e.business_name?.toLowerCase().includes(lower) ||
|
||||||
'cancelled': 'bg-red-100 text-red-700',
|
e.hub?.toLowerCase().includes(lower) ||
|
||||||
};
|
e.event_location?.toLowerCase().includes(lower) // Added event_location to search
|
||||||
return colors[status?.toLowerCase()] || 'bg-slate-100 text-slate-700';
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
// Reset time for comparison to only compare dates
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
filtered = filtered.filter(e => {
|
||||||
|
const eventDate = safeParseDate(e.date);
|
||||||
|
const isCompleted = e.status === "Completed";
|
||||||
|
const isCanceled = e.status === "Canceled";
|
||||||
|
const isFutureOrPresent = eventDate && eventDate >= now;
|
||||||
|
|
||||||
|
if (statusFilter === "active") {
|
||||||
|
return !isCompleted && !isCanceled && isFutureOrPresent;
|
||||||
|
} else if (statusFilter === "completed") {
|
||||||
|
return isCompleted;
|
||||||
|
}
|
||||||
|
return true; // For "all" or other statuses
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [clientEvents, searchTerm, statusFilter]);
|
||||||
|
|
||||||
|
const activeOrders = clientEvents.filter(e =>
|
||||||
|
e.status !== "Completed" && e.status !== "Canceled"
|
||||||
|
).length;
|
||||||
|
const completedOrders = clientEvents.filter(e => e.status === "Completed").length;
|
||||||
|
const totalSpent = clientEvents
|
||||||
|
.filter(e => e.status === "Completed")
|
||||||
|
.reduce((sum, e) => sum + (e.total || 0), 0);
|
||||||
|
|
||||||
|
const handleCancelOrder = (order) => {
|
||||||
|
setOrderToCancel(order); // Updated
|
||||||
|
setCancelDialogOpen(true); // Updated
|
||||||
};
|
};
|
||||||
|
|
||||||
const stats = {
|
const confirmCancel = () => {
|
||||||
total: clientEvents.length,
|
if (orderToCancel) { // Updated
|
||||||
rapidRequest: clientEvents.filter(e => e.is_rapid_request).length,
|
cancelOrderMutation.mutate(orderToCancel.id); // Updated
|
||||||
pending: clientEvents.filter(e => e.status === 'Pending' || e.status === 'Draft').length,
|
}
|
||||||
confirmed: clientEvents.filter(e => e.status === 'Confirmed').length,
|
|
||||||
completed: clientEvents.filter(e => e.status === 'Completed').length,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickReorder = (event) => {
|
const canEditOrder = (order) => {
|
||||||
setSelectedEvent(event);
|
const eventDate = safeParseDate(order.date);
|
||||||
setReorderModalOpen(true);
|
const now = new Date();
|
||||||
|
return order.status !== "Completed" &&
|
||||||
|
order.status !== "Canceled" &&
|
||||||
|
eventDate && eventDate > now; // Ensure eventDate is valid before comparison
|
||||||
|
};
|
||||||
|
|
||||||
|
const canCancelOrder = (order) => {
|
||||||
|
return order.status !== "Completed" && order.status !== "Canceled";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAssignmentStatus = (event) => {
|
||||||
|
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||||
|
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
|
const assigned = event.assigned_staff?.length || 0;
|
||||||
|
const percentage = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
|
||||||
|
|
||||||
|
let badgeClass = 'bg-slate-100 text-slate-600'; // Default: no staff, or no roles requested
|
||||||
|
if (assigned > 0 && assigned < totalRequested) {
|
||||||
|
badgeClass = 'bg-orange-500 text-white'; // Partial Staffed
|
||||||
|
} else if (assigned >= totalRequested && totalRequested > 0) {
|
||||||
|
badgeClass = 'bg-emerald-500 text-white'; // Fully Staffed
|
||||||
|
} else if (assigned === 0 && totalRequested > 0) {
|
||||||
|
badgeClass = 'bg-red-500 text-white'; // Requested but 0 assigned
|
||||||
|
} else if (assigned > 0 && totalRequested === 0) {
|
||||||
|
badgeClass = 'bg-blue-500 text-white'; // Staff assigned but no roles explicitly requested (e.g., event set up, staff assigned, but roles not detailed or count is 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
badgeClass,
|
||||||
|
assigned,
|
||||||
|
requested: totalRequested,
|
||||||
|
percentage,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventTimes = (event) => {
|
||||||
|
const firstShift = event.shifts?.[0];
|
||||||
|
const rolesInFirstShift = firstShift?.roles || [];
|
||||||
|
|
||||||
|
let startTime = null;
|
||||||
|
let endTime = null;
|
||||||
|
|
||||||
|
if (rolesInFirstShift.length > 0) {
|
||||||
|
startTime = rolesInFirstShift[0].start_time || null;
|
||||||
|
endTime = rolesInFirstShift[0].end_time || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTime: startTime ? convertTo12Hour(startTime) : "-",
|
||||||
|
endTime: endTime ? convertTo12Hour(endTime) : "-"
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-[1800px] mx-auto space-y-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className=""> {/* Removed mb-6 */}
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">My Orders</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">View and manage all your orders</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {/* Removed mb-6 from here as it's now part of space-y-6 */}
|
||||||
|
<Card className="border border-blue-200 bg-blue-50">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-[#1C323E]">My Orders</h1>
|
<p className="text-xs text-blue-600 font-semibold uppercase">TOTAL</p>
|
||||||
<p className="text-slate-500 mt-1">View and manage your event orders</p>
|
<p className="text-2xl font-bold text-blue-700">{clientEvents.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={() => navigate(createPageUrl("CreateEvent"))}
|
|
||||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Order
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 mb-8">
|
|
||||||
<Card className="border-slate-200">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<FileText className="w-8 h-8 text-[#0A39DF]" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500">Total Orders</p>
|
|
||||||
<p className="text-3xl font-bold text-[#1C323E]">{stats.total}</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-slate-200 bg-gradient-to-br from-red-50 to-white">
|
<Card className="border border-orange-200 bg-orange-50">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center gap-3">
|
||||||
<Zap className="w-8 h-8 text-red-600" />
|
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Clock className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-orange-600 font-semibold uppercase">ACTIVE</p>
|
||||||
|
<p className="text-2xl font-bold text-orange-700">{activeOrders}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500">Rapid Requests</p>
|
|
||||||
<p className="text-3xl font-bold text-red-600">{stats.rapidRequest}</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border border-green-200 bg-green-50">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center gap-3">
|
||||||
<Clock className="w-8 h-8 text-yellow-600" />
|
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-green-600 font-semibold uppercase">COMPLETED</p>
|
||||||
|
<p className="text-2xl font-bold text-green-700">{completedOrders}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500">Pending</p>
|
|
||||||
<p className="text-3xl font-bold text-yellow-600">{stats.pending}</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border border-purple-200 bg-purple-50">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center gap-3">
|
||||||
<CalendarIcon className="w-8 h-8 text-green-600" />
|
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
|
||||||
|
<DollarSign className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-purple-600 font-semibold uppercase">TOTAL SPENT</p>
|
||||||
|
<p className="text-2xl font-bold text-purple-700">${Math.round(totalSpent / 1000)}k</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500">Confirmed</p>
|
|
||||||
<p className="text-3xl font-bold text-green-600">{stats.confirmed}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Users className="w-8 h-8 text-blue-600" />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500">Completed</p>
|
|
||||||
<p className="text-3xl font-bold text-blue-600">{stats.completed}</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
<div className="bg-white rounded-xl p-4 flex items-center gap-4 border shadow-sm">
|
||||||
<div className="flex gap-2 mb-6 flex-wrap">
|
<div className="relative flex-1">
|
||||||
<Button
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" /> {/* Icon size updated */}
|
||||||
variant={statusFilter === "all" ? "default" : "outline"}
|
<Input
|
||||||
onClick={() => setStatusFilter("all")}
|
placeholder="Search orders..." // Placeholder text updated
|
||||||
className={statusFilter === "all" ? "bg-[#0A39DF]" : ""}
|
value={searchTerm}
|
||||||
>
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
All
|
className="pl-10 border-slate-300 h-10" // Class updated
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "rapid_request" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("rapid_request")}
|
|
||||||
className={statusFilter === "rapid_request" ? "bg-red-600 hover:bg-red-700" : ""}
|
|
||||||
>
|
|
||||||
<Zap className="w-4 h-4 mr-2" />
|
|
||||||
Rapid Request
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "pending" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("pending")}
|
|
||||||
className={statusFilter === "pending" ? "bg-[#0A39DF]" : ""}
|
|
||||||
>
|
|
||||||
Pending
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "confirmed" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("confirmed")}
|
|
||||||
className={statusFilter === "confirmed" ? "bg-[#0A39DF]" : ""}
|
|
||||||
>
|
|
||||||
Confirmed
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={statusFilter === "completed" ? "default" : "outline"}
|
|
||||||
onClick={() => setStatusFilter("completed")}
|
|
||||||
className={statusFilter === "completed" ? "bg-[#0A39DF]" : ""}
|
|
||||||
>
|
|
||||||
Completed
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Orders List */}
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
|
||||||
{filteredEvents.length > 0 ? (
|
|
||||||
filteredEvents.map((event) => (
|
|
||||||
<Card key={event.id} className="border-slate-200 hover:shadow-lg transition-shadow">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<h3 className="text-xl font-bold text-[#1C323E]">{event.event_name}</h3>
|
|
||||||
<Badge className={getStatusColor(event.status)}>
|
|
||||||
{event.status}
|
|
||||||
</Badge>
|
|
||||||
{event.is_rapid_request && (
|
|
||||||
<Badge className="bg-red-100 text-red-700 border-red-200 border">
|
|
||||||
<Zap className="w-3 h-3 mr-1" />
|
|
||||||
Rapid Request
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{event.include_backup && (
|
|
||||||
<Badge className="bg-green-100 text-green-700 border-green-200 border">
|
|
||||||
🛡️ {event.backup_staff_count || 0} Backup Staff
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-slate-600 mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
<span>{event.date ? format(new Date(event.date), 'PPP') : 'Date TBD'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
<span>{event.event_location || event.hub || 'Location TBD'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
<span>{event.assigned || 0} of {event.requested || 0} staff</span>
|
|
||||||
</div>
|
|
||||||
{event.total && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
<span className="font-semibold">${event.total.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(createPageUrl("EventDetail") + `?id=${event.id}`)}
|
|
||||||
variant="outline"
|
|
||||||
className="hover:bg-[#0A39DF] hover:text-white"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleQuickReorder(event)}
|
|
||||||
className="bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white shadow-lg"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-5 h-5 mr-2" />
|
|
||||||
Reorder
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{event.notes && (
|
|
||||||
<div className="mt-3 p-3 bg-slate-50 rounded-lg">
|
|
||||||
<p className="text-sm text-slate-600">{event.notes}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Card className="border-slate-200">
|
|
||||||
<CardContent className="p-12 text-center">
|
|
||||||
<FileText className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">No orders found</h3>
|
|
||||||
<p className="text-slate-500 mb-6">Get started by creating your first order</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(createPageUrl("CreateEvent"))}
|
|
||||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Order
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Reorder Modal */}
|
|
||||||
{selectedEvent && (
|
|
||||||
<QuickReorderModal
|
|
||||||
event={selectedEvent}
|
|
||||||
open={reorderModalOpen}
|
|
||||||
onOpenChange={setReorderModalOpen}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="w-fit"> {/* Replaced Select with Tabs */}
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
|
<TabsTrigger value="active">Active</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-slate-200 shadow-sm"> {/* Card class updated */}
|
||||||
|
<CardContent className="p-0"> {/* CardContent padding updated */}
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-50 hover:bg-slate-50"> {/* TableRow class updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700">Order</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700">Date</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700">Location</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700">Time</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700">Status</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700 text-center">Staff</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700 text-center">Invoice</TableHead> {/* Updated */}
|
||||||
|
<TableHead className="font-semibold text-slate-700 text-center">Actions</TableHead> {/* Updated */}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredOrders.length === 0 ? ( // Using filteredOrders
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-12 text-slate-500"> {/* Colspan updated */}
|
||||||
|
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" /> {/* Icon updated */}
|
||||||
|
<p className="font-medium">No orders found</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredOrders.map((order) => { // Using filteredOrders, renamed event to order
|
||||||
|
const assignment = getAssignmentStatus(order);
|
||||||
|
const { startTime, endTime } = getEventTimes(order);
|
||||||
|
const invoiceReady = order.status === "Completed";
|
||||||
|
// const eventDate = safeParseDate(order.date); // Not directly used here, safeFormatDate handles it.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={order.id} className="hover:bg-slate-50">
|
||||||
|
<TableCell> {/* Order cell */}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-900">{order.event_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">{order.business_name || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell> {/* Date cell */}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
{safeFormatDate(order.date, 'MMM dd, yyyy')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{safeFormatDate(order.date, 'EEEE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell> {/* Location cell */}
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
{order.hub || order.event_location || "—"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell> {/* Time cell */}
|
||||||
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
{startTime} - {endTime}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell> {/* Status cell */}
|
||||||
|
{getStatusBadge(order)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center"> {/* Staff cell */}
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Badge className={assignment.badgeClass}>
|
||||||
|
{assignment.assigned} / {assignment.requested}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[10px] text-slate-500 font-medium">
|
||||||
|
{assignment.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center"> {/* Invoice cell */}
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Button // Changed from a div to a Button for better accessibility
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => invoiceReady && navigate(createPageUrl('Invoices'))}
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'} ${invoiceReady ? 'cursor-pointer hover:bg-blue-200' : 'cursor-not-allowed opacity-50'}`}
|
||||||
|
disabled={!invoiceReady}
|
||||||
|
title={invoiceReady ? "View Invoice" : "Invoice not available"}
|
||||||
|
>
|
||||||
|
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell> {/* Actions cell */}
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`EventDetail?id=${order.id}`))}
|
||||||
|
className="hover:bg-slate-100"
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{canEditOrder(order) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`EditEvent?id=${order.id}`))}
|
||||||
|
className="hover:bg-slate-100"
|
||||||
|
title="Edit order"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" /> {/* Changed from Edit2 */}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canCancelOrder(order) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleCancelOrder(order)} // Updated
|
||||||
|
className="hover:bg-red-50 hover:text-red-600"
|
||||||
|
title="Cancel order"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Cancel Order?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to cancel this order? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{orderToCancel && ( // Using orderToCancel
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
|
||||||
|
<p className="font-bold text-slate-900">{orderToCancel.event_name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{orderToCancel.date ? format(new Date(orderToCancel.date), "MMMM d, yyyy") : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{orderToCancel.hub || orderToCancel.event_location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCancelDialogOpen(false)} // Updated
|
||||||
|
>
|
||||||
|
Keep Order
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmCancel}
|
||||||
|
disabled={cancelOrderMutation.isPending}
|
||||||
|
>
|
||||||
|
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,38 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { base44 } from "@/api/base44Client";
|
import { base44 } from "@/api/base44Client";
|
||||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import EventFormWizard from "@/components/events/EventFormWizard";
|
import EventFormWizard from "@/components/events/EventFormWizard";
|
||||||
import AIOrderAssistant from "@/components/events/AIOrderAssistant";
|
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sparkles, FileText, X } from "lucide-react";
|
import { X, AlertTriangle } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { detectAllConflicts, ConflictAlert } from "@/components/scheduling/ConflictDetection";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
export default function CreateEvent() {
|
export default function CreateEvent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [useAI, setUseAI] = useState(false);
|
const [pendingEvent, setPendingEvent] = React.useState(null);
|
||||||
const [aiExtractedData, setAiExtractedData] = useState(null);
|
const [showConflictWarning, setShowConflictWarning] = React.useState(false);
|
||||||
|
|
||||||
const { data: currentUser } = useQuery({
|
const { data: currentUser } = useQuery({
|
||||||
queryKey: ['current-user-create-event'],
|
queryKey: ['current-user-create-event'],
|
||||||
queryFn: () => base44.auth.me(),
|
queryFn: () => base44.auth.me(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allEvents = [] } = useQuery({
|
||||||
|
queryKey: ['events-for-conflict-check'],
|
||||||
|
queryFn: () => base44.entities.Event.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
const createEventMutation = useMutation({
|
const createEventMutation = useMutation({
|
||||||
mutationFn: (eventData) => base44.entities.Event.create(eventData),
|
mutationFn: (eventData) => base44.entities.Event.create(eventData),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||||
toast({
|
toast({
|
||||||
title: "✅ Event Created",
|
title: "✅ Event Created",
|
||||||
description: "Your event has been created successfully.",
|
description: "Your event has been created successfully.",
|
||||||
@@ -42,107 +49,98 @@ export default function CreateEvent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (eventData) => {
|
const handleSubmit = (eventData) => {
|
||||||
|
// Detect conflicts before creating
|
||||||
|
const conflicts = detectAllConflicts(eventData, allEvents);
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
setPendingEvent({ ...eventData, detected_conflicts: conflicts });
|
||||||
|
setShowConflictWarning(true);
|
||||||
|
} else {
|
||||||
createEventMutation.mutate(eventData);
|
createEventMutation.mutate(eventData);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAIDataExtracted = (extractedData) => {
|
const handleConfirmWithConflicts = () => {
|
||||||
setAiExtractedData(extractedData);
|
if (pendingEvent) {
|
||||||
setUseAI(false);
|
createEventMutation.mutate(pendingEvent);
|
||||||
|
setShowConflictWarning(false);
|
||||||
|
setPendingEvent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConflicts = () => {
|
||||||
|
setShowConflictWarning(false);
|
||||||
|
setPendingEvent(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
<div className="max-w-7xl mx-auto p-4 md:p-8">
|
<div className="max-w-7xl mx-auto p-4 md:p-8">
|
||||||
{/* Header with AI Toggle */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-[#1C323E]">Create New Order</h1>
|
<h1 className="text-3xl font-bold text-[#1C323E]">Create Standard Order</h1>
|
||||||
<p className="text-slate-600 mt-1">
|
<p className="text-slate-600 mt-1">
|
||||||
{useAI ? "Use AI to create your order naturally" : "Fill out the form to create your order"}
|
Fill out the details for your planned event
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant={useAI ? "default" : "outline"}
|
|
||||||
onClick={() => setUseAI(true)}
|
|
||||||
className={useAI ? "bg-gradient-to-r from-[#0A39DF] to-purple-600" : ""}
|
|
||||||
>
|
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
AI Assistant
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={!useAI ? "default" : "outline"}
|
|
||||||
onClick={() => setUseAI(false)}
|
|
||||||
className={!useAI ? "bg-[#1C323E]" : ""}
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
|
||||||
Form
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => navigate(createPageUrl("Events"))}
|
onClick={() => navigate(createPageUrl("ClientDashboard"))}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Assistant Interface */}
|
{/* Conflict Warning Modal */}
|
||||||
<AnimatePresence>
|
{showConflictWarning && pendingEvent && (
|
||||||
{useAI && (
|
<Card className="mb-6 border-2 border-orange-500">
|
||||||
<motion.div
|
<CardContent className="p-6">
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<div className="flex items-start gap-4 mb-4">
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
||||||
>
|
|
||||||
<AIOrderAssistant
|
|
||||||
onOrderDataExtracted={handleAIDataExtracted}
|
|
||||||
onClose={() => setUseAI(false)}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Wizard Form */}
|
|
||||||
{!useAI && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
>
|
|
||||||
{aiExtractedData && (
|
|
||||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Sparkles className="w-5 h-5 text-green-600" />
|
|
||||||
<span className="font-semibold text-green-900">AI Pre-filled Data</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-green-700 mb-3">
|
<div>
|
||||||
The form has been pre-filled with information from your conversation. Review and edit as needed.
|
<h3 className="font-bold text-lg text-slate-900 mb-1">
|
||||||
|
Scheduling Conflicts Detected
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<ConflictAlert conflicts={pendingEvent.detected_conflicts} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
onClick={handleCancelConflicts}
|
||||||
onClick={() => {
|
|
||||||
setAiExtractedData(null);
|
|
||||||
setUseAI(true);
|
|
||||||
}}
|
|
||||||
className="border-green-300 text-green-700 hover:bg-green-100"
|
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
Go Back & Edit
|
||||||
Chat with AI Again
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmWithConflicts}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700"
|
||||||
|
>
|
||||||
|
Create Anyway
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EventFormWizard
|
<EventFormWizard
|
||||||
event={aiExtractedData}
|
event={null}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isSubmitting={createEventMutation.isPending}
|
isSubmitting={createEventMutation.isPending}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onCancel={() => navigate(createPageUrl("Events"))}
|
onCancel={() => navigate(createPageUrl("ClientDashboard"))}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,104 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf } from "lucide-react";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf, Eye, Edit, Sparkles, Zap, Clock, AlertTriangle, CheckCircle, FileText, X } from "lucide-react";
|
||||||
import StatsCard from "@/components/staff/StatsCard";
|
import StatsCard from "@/components/staff/StatsCard";
|
||||||
import EcosystemWheel from "@/components/dashboard/EcosystemWheel";
|
import EcosystemWheel from "@/components/dashboard/EcosystemWheel";
|
||||||
import QuickMetrics from "@/components/dashboard/QuickMetrics";
|
import QuickMetrics from "@/components/dashboard/QuickMetrics";
|
||||||
import PageHeader from "@/components/common/PageHeader";
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import { format, parseISO, isValid, isSameDay, startOfDay } from "date-fns";
|
||||||
|
|
||||||
|
const safeParseDate = (dateString) => {
|
||||||
|
if (!dateString) return null;
|
||||||
|
try {
|
||||||
|
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||||
|
return isValid(date) ? date : null;
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeFormatDate = (dateString, formatStr) => {
|
||||||
|
const date = safeParseDate(dateString);
|
||||||
|
if (!date) return "-";
|
||||||
|
try { return format(date, formatStr); } catch { return "-"; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertTo12Hour = (time24) => {
|
||||||
|
if (!time24) return "-";
|
||||||
|
try {
|
||||||
|
const [hours, minutes] = time24.split(':');
|
||||||
|
const hour = parseInt(hours);
|
||||||
|
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const hour12 = hour % 12 || 12;
|
||||||
|
return `${hour12}:${minutes} ${ampm}`;
|
||||||
|
} catch {
|
||||||
|
return time24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (event) => {
|
||||||
|
if (event.is_rapid) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
|
||||||
|
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||||
|
RAPID
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
'Draft': { bg: 'bg-slate-500', icon: FileText },
|
||||||
|
'Pending': { bg: 'bg-amber-500', icon: Clock },
|
||||||
|
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
|
||||||
|
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
|
||||||
|
'Active': { bg: 'bg-blue-500', icon: Users },
|
||||||
|
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
|
||||||
|
'Canceled': { bg: 'bg-red-500', icon: X },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{event.status}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventTimes = (event) => {
|
||||||
|
const firstShift = event.shifts?.[0];
|
||||||
|
const rolesInFirstShift = firstShift?.roles || [];
|
||||||
|
|
||||||
|
let startTime = null;
|
||||||
|
let endTime = null;
|
||||||
|
|
||||||
|
if (rolesInFirstShift.length > 0) {
|
||||||
|
startTime = rolesInFirstShift[0].start_time || null;
|
||||||
|
endTime = rolesInFirstShift[0].end_time || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTime: startTime ? convertTo12Hour(startTime) : "-",
|
||||||
|
endTime: endTime ? convertTo12Hour(endTime) : "-"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAssignmentStatus = (event) => {
|
||||||
|
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||||
|
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
|
const assigned = event.assigned_staff?.length || 0;
|
||||||
|
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
|
||||||
|
|
||||||
|
if (assigned === 0) return { color: 'bg-slate-200 text-slate-600', text: '0', percent: '0%', status: 'empty' };
|
||||||
|
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
|
||||||
|
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
|
||||||
|
return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: '0%', status: 'partial' };
|
||||||
|
};
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -28,6 +121,13 @@ export default function Dashboard() {
|
|||||||
initialData: [],
|
initialData: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter events for today only
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const todaysEvents = events.filter(event => {
|
||||||
|
const eventDate = safeParseDate(event.date);
|
||||||
|
return eventDate && isSameDay(eventDate, today);
|
||||||
|
});
|
||||||
|
|
||||||
const recentStaff = staff.slice(0, 6);
|
const recentStaff = staff.slice(0, 6);
|
||||||
const uniqueDepartments = [...new Set(staff.map(s => s.department).filter(Boolean))];
|
const uniqueDepartments = [...new Set(staff.map(s => s.department).filter(Boolean))];
|
||||||
const uniqueLocations = [...new Set(staff.map(s => s.hub_location).filter(Boolean))];
|
const uniqueLocations = [...new Set(staff.map(s => s.hub_location).filter(Boolean))];
|
||||||
@@ -105,7 +205,7 @@ export default function Dashboard() {
|
|||||||
<Link to={createPageUrl("Events")}>
|
<Link to={createPageUrl("Events")}>
|
||||||
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
|
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
|
||||||
<Calendar className="w-5 h-5 mr-2" />
|
<Calendar className="w-5 h-5 mr-2" />
|
||||||
View All Events
|
View All Orders
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -143,6 +243,133 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Today's Orders Section */}
|
||||||
|
<Card className="mb-8 border-slate-200 shadow-lg">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-[#1C323E] flex items-center gap-2">
|
||||||
|
<Calendar className="w-6 h-6 text-[#0A39DF]" />
|
||||||
|
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
|
||||||
|
</div>
|
||||||
|
<Link to={createPageUrl("Events")}>
|
||||||
|
<Button variant="outline" className="border-slate-300">
|
||||||
|
View All Orders
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{todaysEvents.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||||
|
<p className="font-medium">No orders scheduled for today</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
|
||||||
|
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{todaysEvents.map((event) => {
|
||||||
|
const assignmentStatus = getAssignmentStatus(event);
|
||||||
|
const eventTimes = getEventTimes(event);
|
||||||
|
const eventDate = safeParseDate(event.date);
|
||||||
|
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-500">
|
||||||
|
<MapPin className="w-3.5 h-3.5" />
|
||||||
|
{event.hub || event.event_location || "Main Hub"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
|
||||||
|
<p className="text-xs text-slate-500">{dayOfWeek}</p>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
{getStatusBadge(event)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center py-3">
|
||||||
|
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center py-3">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
|
||||||
|
{assignmentStatus.text}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
|
||||||
|
className="hover:bg-slate-100 h-8 w-8"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
|
||||||
|
className="hover:bg-slate-100 h-8 w-8"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{event.invoice_id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
|
||||||
|
className="hover:bg-slate-100 h-8 w-8"
|
||||||
|
title="View Invoice"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 text-blue-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Ecosystem Puzzle */}
|
{/* Ecosystem Puzzle */}
|
||||||
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
|
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
|
||||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||||
|
|||||||
@@ -1,52 +1,48 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { base44 } from "@/api/base44Client";
|
import { base44 } from "@/api/base44Client";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ArrowLeft, Bell, RefreshCw } from "lucide-react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import ShiftCard from "@/components/events/ShiftCard";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react";
|
||||||
|
import ShiftCard from "@/components/events/ShiftCard";
|
||||||
|
import OrderStatusBadge from "@/components/orders/OrderStatusBadge";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
const statusColors = {
|
const safeFormatDate = (dateString) => {
|
||||||
Draft: "bg-gray-100 text-gray-800",
|
if (!dateString) return "—";
|
||||||
Active: "bg-green-100 text-green-800",
|
|
||||||
Pending: "bg-purple-100 text-purple-800",
|
|
||||||
Confirmed: "bg-blue-100 text-blue-800",
|
|
||||||
Completed: "bg-slate-100 text-slate-800",
|
|
||||||
Canceled: "bg-red-100 text-red-800" // Added Canceled status for completeness
|
|
||||||
};
|
|
||||||
|
|
||||||
// Safe date formatter
|
|
||||||
const safeFormatDate = (dateString, formatStr) => {
|
|
||||||
if (!dateString) return "-";
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateString);
|
return format(new Date(dateString), "MMMM d, yyyy");
|
||||||
if (isNaN(date.getTime())) return "-";
|
|
||||||
return format(date, formatStr);
|
|
||||||
} catch {
|
} catch {
|
||||||
return "-";
|
return "—";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EventDetail() {
|
export default function EventDetail() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showNotifyDialog, setShowNotifyDialog] = useState(false);
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const eventId = urlParams.get('id');
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [notifyDialog, setNotifyDialog] = useState(false);
|
||||||
|
const [cancelDialog, setCancelDialog] = useState(false);
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const eventId = urlParams.get("id");
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-event-detail'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: allEvents, isLoading } = useQuery({
|
const { data: allEvents, isLoading } = useQuery({
|
||||||
queryKey: ['events'],
|
queryKey: ['events'],
|
||||||
@@ -54,208 +50,314 @@ export default function EventDetail() {
|
|||||||
initialData: [],
|
initialData: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: shifts } = useQuery({
|
|
||||||
queryKey: ['shifts', eventId],
|
|
||||||
queryFn: () => base44.entities.Shift.filter({ event_id: eventId }),
|
|
||||||
initialData: [],
|
|
||||||
enabled: !!eventId
|
|
||||||
});
|
|
||||||
|
|
||||||
const event = allEvents.find(e => e.id === eventId);
|
const event = allEvents.find(e => e.id === eventId);
|
||||||
|
|
||||||
const handleReorder = () => {
|
// Cancel order mutation
|
||||||
if (!event) return; // Should not happen if event is loaded, but for safety
|
const cancelOrderMutation = useMutation({
|
||||||
|
mutationFn: () => base44.entities.Event.update(eventId, { status: "Canceled" }),
|
||||||
const reorderData = {
|
onSuccess: () => {
|
||||||
event_name: event.event_name,
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
business_id: event.business_id,
|
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
|
||||||
business_name: event.business_name,
|
|
||||||
hub: event.hub,
|
|
||||||
event_location: event.event_location,
|
|
||||||
event_type: event.event_type,
|
|
||||||
requested: event.requested,
|
|
||||||
client_name: event.client_name,
|
|
||||||
client_email: event.client_email,
|
|
||||||
client_phone: event.client_phone,
|
|
||||||
client_address: event.client_address,
|
|
||||||
notes: event.notes,
|
|
||||||
};
|
|
||||||
|
|
||||||
sessionStorage.setItem('reorderData', JSON.stringify(reorderData));
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Reordering Event",
|
title: "✅ Order Canceled",
|
||||||
description: `Creating new order based on "${event.event_name}"`,
|
description: "Your order has been canceled successfully",
|
||||||
|
});
|
||||||
|
setCancelDialog(false);
|
||||||
|
navigate(createPageUrl("ClientOrders"));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Failed to Cancel",
|
||||||
|
description: "Could not cancel order. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate(createPageUrl("CreateEvent") + "?reorder=true");
|
const handleNotifyStaff = async () => {
|
||||||
|
const assignedStaff = event?.assigned_staff || [];
|
||||||
|
|
||||||
|
for (const staff of assignedStaff) {
|
||||||
|
try {
|
||||||
|
await base44.integrations.Core.SendEmail({
|
||||||
|
to: staff.email || `${staff.staff_name}@example.com`,
|
||||||
|
subject: `Shift Update: ${event.event_name}`,
|
||||||
|
body: `You have an update for: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.event_location || event.hub}\n\nPlease check the platform for details.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send email:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ Notifications Sent",
|
||||||
|
description: `Notified ${assignedStaff.length} staff members`,
|
||||||
|
});
|
||||||
|
setNotifyDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || !event) {
|
const isClient = user?.user_role === 'client' ||
|
||||||
|
event?.created_by === user?.email ||
|
||||||
|
event?.client_email === user?.email;
|
||||||
|
|
||||||
|
const canEditOrder = () => {
|
||||||
|
if (!event) return false;
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
const now = new Date();
|
||||||
|
return isClient &&
|
||||||
|
event.status !== "Completed" &&
|
||||||
|
event.status !== "Canceled" &&
|
||||||
|
eventDate > now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canCancelOrder = () => {
|
||||||
|
if (!event) return false;
|
||||||
|
return isClient &&
|
||||||
|
event.status !== "Completed" &&
|
||||||
|
event.status !== "Canceled";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full" />
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
<div className="max-w-[1600px] mx-auto">
|
<p className="text-xl font-semibold text-slate-900 mb-4">Event not found</p>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<Link to={createPageUrl("Events")}>
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate(createPageUrl("Events"))}>
|
<Button variant="outline">Back to Events</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shifts from event.shifts array (primary source)
|
||||||
|
const eventShifts = event.shifts || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold">{event.event_name}</h1>
|
<div>
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<h1 className="text-3xl font-bold text-slate-900">{event.event_name}</h1>
|
||||||
{(event.status === "Completed" || event.status === "Canceled") && (
|
<p className="text-slate-600 mt-1">Order Details & Information</p>
|
||||||
<Button
|
</div>
|
||||||
onClick={handleReorder}
|
</div>
|
||||||
className="bg-green-600 hover:bg-green-700 text-white"
|
<div className="flex items-center gap-3">
|
||||||
|
<OrderStatusBadge order={event} />
|
||||||
|
{canEditOrder() && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-blue-50 border-2 border-blue-200 rounded-full text-blue-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<Edit3 className="w-5 h-5" />
|
||||||
Reorder
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canCancelOrder() && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCancelDialog(true)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-red-50 border-2 border-red-200 rounded-full text-red-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isClient && event.assigned_staff?.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setNotifyDialog(true)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Notify Staff
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Bell className="w-5 h-5" />
|
|
||||||
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">
|
|
||||||
M
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
{/* Order Details Card */}
|
||||||
<Card className="border-slate-200">
|
<Card className="bg-white border border-slate-200 shadow-md">
|
||||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
<CardHeader className="border-b border-slate-100">
|
||||||
<CardTitle className="text-base">Order Details</CardTitle>
|
<CardTitle className="text-lg font-bold text-slate-900">Order Information</CardTitle>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">PO number</p>
|
|
||||||
<p className="font-medium">{event.po_number || event.po || "#RC-36559419"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Data</p>
|
|
||||||
<p className="font-medium">{safeFormatDate(event.date, "dd.MM.yyyy")}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Status</p>
|
|
||||||
<Badge className={`${statusColors[event.status]} font-medium mt-1`}>
|
|
||||||
{event.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button variant="outline" className="flex-1 text-sm">
|
|
||||||
Edit Order
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="flex-1 text-sm text-red-600 hover:text-red-700">
|
|
||||||
Cancel Order
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-slate-200 lg:col-span-2">
|
|
||||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
|
||||||
<CardTitle className="text-base">Client info</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-4 gap-6">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-xs text-slate-500 mb-1">Client name</p>
|
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||||
<p className="font-medium">{event.client_name || "Legendary"}</p>
|
<Calendar className="w-5 h-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500 mb-1">Number</p>
|
<p className="text-xs text-slate-500">Event Date</p>
|
||||||
<p className="font-medium">{event.client_phone || "(408) 815-9180"}</p>
|
<p className="font-bold text-slate-900">{safeFormatDate(event.date)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-xs text-slate-500 mb-1">Address</p>
|
|
||||||
<p className="font-medium">{event.client_address || event.event_location || "848 E Dash Rd, Ste 264 E San Jose, CA 95122"}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-xs text-slate-500 mb-1">Email</p>
|
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
|
||||||
<p className="font-medium">{event.client_email || "order@legendarysweetssf.com"}</p>
|
<MapPin className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Location</p>
|
||||||
|
<p className="font-bold text-slate-900">{event.hub || event.event_location || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Staff Assigned</p>
|
||||||
|
<p className="font-bold text-slate-900">
|
||||||
|
{event.assigned_staff?.length || 0} / {event.requested || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||||
|
<DollarSign className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Total Cost</p>
|
||||||
|
<p className="font-bold text-slate-900">${(event.total || 0).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border-slate-200 mb-6">
|
{/* Client Information (if not client viewing) */}
|
||||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
{!isClient && (
|
||||||
<CardTitle className="text-base">Event: {event.event_name}</CardTitle>
|
<Card className="bg-white border border-slate-200 shadow-md">
|
||||||
|
<CardHeader className="border-b border-slate-100">
|
||||||
|
<CardTitle className="text-lg font-bold text-slate-900">Client Information</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
<div className="grid grid-cols-3 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-500">Hub</p>
|
<p className="text-xs text-slate-500 mb-1">Business Name</p>
|
||||||
<p className="font-medium">{event.hub || "Hub Name"}</p>
|
<p className="font-bold text-slate-900">{event.business_name || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-500">Name of Department</p>
|
<p className="text-xs text-slate-500 mb-1">Contact Name</p>
|
||||||
<p className="font-medium">Department name</p>
|
<p className="font-bold text-slate-900">{event.client_name || "—"}</p>
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-slate-500 mb-2">Order Addons</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs">Title</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">Travel Time</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">Meal Provided</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Contact Email</p>
|
||||||
|
<p className="font-bold text-slate-900">{event.client_email || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
{/* Shifts - Using event.shifts array */}
|
||||||
{shifts.length > 0 ? (
|
<div className="space-y-4">
|
||||||
shifts.map((shift, idx) => (
|
<h2 className="text-xl font-bold text-slate-900">Event Shifts & Staff Assignment</h2>
|
||||||
<ShiftCard
|
{eventShifts.length > 0 ? (
|
||||||
key={shift.id}
|
eventShifts.map((shift, idx) => (
|
||||||
shift={shift}
|
<ShiftCard key={idx} shift={shift} event={event} />
|
||||||
onNotifyStaff={() => setShowNotifyDialog(true)}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<ShiftCard
|
<Card className="bg-white border border-slate-200">
|
||||||
shift={{
|
<CardContent className="p-12 text-center">
|
||||||
shift_name: "Shift 1",
|
<Users className="w-12 h-12 mx-auto mb-4 text-slate-400" />
|
||||||
assigned_staff: event.assigned_staff || [],
|
<p className="text-slate-600 font-medium mb-2">No shifts defined for this event</p>
|
||||||
location: event.event_location,
|
<p className="text-slate-500 text-sm">Add roles and staff requirements to get started</p>
|
||||||
unpaid_break: 0,
|
</CardContent>
|
||||||
price: 23,
|
</Card>
|
||||||
amount: 120
|
|
||||||
}}
|
|
||||||
onNotifyStaff={() => setShowNotifyDialog(true)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={showNotifyDialog} onOpenChange={setShowNotifyDialog}>
|
{/* Notes */}
|
||||||
<DialogContent className="sm:max-w-md">
|
{event.notes && (
|
||||||
|
<Card className="bg-white border border-slate-200 shadow-md">
|
||||||
|
<CardHeader className="border-b border-slate-100">
|
||||||
|
<CardTitle className="text-lg font-bold text-slate-900">Additional Notes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-slate-700 whitespace-pre-wrap">{event.notes}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notify Staff Dialog */}
|
||||||
|
<Dialog open={notifyDialog} onOpenChange={setNotifyDialog}>
|
||||||
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-center mb-4">
|
<DialogTitle>Notify Assigned Staff</DialogTitle>
|
||||||
<div className="w-12 h-12 bg-pink-500 rounded-full flex items-center justify-center text-white font-bold text-xl">
|
<DialogDescription>
|
||||||
L
|
Send notification to all {event.assigned_staff?.length || 0} assigned staff members about this event.
|
||||||
</div>
|
</DialogDescription>
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-center">Notification Name</DialogTitle>
|
|
||||||
<p className="text-center text-sm text-slate-600">
|
|
||||||
Order #5 Admin (cancelled/replace) Want to proceed?
|
|
||||||
</p>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="flex gap-3 sm:justify-center">
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setShowNotifyDialog(false)} className="flex-1">
|
<Button variant="outline" onClick={() => setNotifyDialog(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setShowNotifyDialog(false)} className="flex-1 bg-blue-600 hover:bg-blue-700">
|
<Button onClick={handleNotifyStaff} className="bg-blue-600 hover:bg-blue-700">
|
||||||
Proceed
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Send Notifications
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Cancel Order Dialog */}
|
||||||
|
<Dialog open={cancelDialog} onOpenChange={setCancelDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Cancel Order?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to cancel this order? This action cannot be undone and the vendor will be notified immediately.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
|
||||||
|
<p className="font-bold text-slate-900">{event.event_name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{safeFormatDate(event.date)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
{event.hub || event.event_location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCancelDialog(false)}
|
||||||
|
>
|
||||||
|
Keep Order
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => cancelOrderMutation.mutate()}
|
||||||
|
disabled={cancelOrderMutation.isPending}
|
||||||
|
>
|
||||||
|
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ const statusColors = {
|
|||||||
'Overdue': 'bg-red-500 text-white',
|
'Overdue': 'bg-red-500 text-white',
|
||||||
'Resolved': 'bg-blue-500 text-white',
|
'Resolved': 'bg-blue-500 text-white',
|
||||||
'Paid': 'bg-green-500 text-white',
|
'Paid': 'bg-green-500 text-white',
|
||||||
'Reconciled': 'bg-yellow-600 text-white',
|
'Reconciled': 'bg-amber-600 text-white', // Changed from bg-yellow-600
|
||||||
'Disputed': 'bg-gray-500 text-white',
|
'Disputed': 'bg-gray-500 text-white',
|
||||||
'Verified': 'bg-teal-500 text-white',
|
'Verified': 'bg-teal-500 text-white',
|
||||||
'Pending': 'bg-amber-500 text-white',
|
'Pending': 'bg-amber-500 text-white',
|
||||||
@@ -161,7 +161,7 @@ export default function Invoices() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => setShowPaymentDialog(true)}
|
onClick={() => setShowPaymentDialog(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-yellow-400 hover:bg-yellow-500 text-slate-900 border-0 font-semibold"
|
className="bg-amber-500 hover:bg-amber-600 text-white border-0 font-semibold" // Changed className
|
||||||
>
|
>
|
||||||
Record Payment
|
Record Payment
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
||||||
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
|
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
|
||||||
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
||||||
Building2, Sparkles, CheckSquare, UserCheck, Store
|
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +31,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import ChatBubble from "@/components/chat/ChatBubble";
|
import ChatBubble from "@/components/chat/ChatBubble";
|
||||||
import RoleSwitcher from "@/components/dev/RoleSwitcher";
|
import RoleSwitcher from "@/components/dev/RoleSwitcher";
|
||||||
import NotificationPanel from "@/components/notifications/NotificationPanel";
|
import NotificationPanel from "@/components/notifications/NotificationPanel";
|
||||||
|
import { NotificationEngine } from "@/components/notifications/NotificationEngine";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
// Navigation items for each role
|
// Navigation items for each role
|
||||||
@@ -44,7 +44,9 @@ const roleNavigationMap = {
|
|||||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||||
|
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
|
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
|
||||||
@@ -57,13 +59,14 @@ const roleNavigationMap = {
|
|||||||
],
|
],
|
||||||
procurement: [
|
procurement: [
|
||||||
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
||||||
|
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
|
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
|
||||||
{ title: "Orders", url: createPageUrl("Events"), icon: Clipboard },
|
|
||||||
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
@@ -71,25 +74,27 @@ const roleNavigationMap = {
|
|||||||
],
|
],
|
||||||
operator: [
|
operator: [
|
||||||
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||||
|
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||||
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
|
||||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||||
],
|
],
|
||||||
sector: [
|
sector: [
|
||||||
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||||
|
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
|
||||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||||
@@ -101,6 +106,7 @@ const roleNavigationMap = {
|
|||||||
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
|
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
|
||||||
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||||
@@ -108,16 +114,17 @@ const roleNavigationMap = {
|
|||||||
],
|
],
|
||||||
vendor: [
|
vendor: [
|
||||||
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
||||||
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
|
||||||
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
|
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
|
||||||
|
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
||||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
|
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
|
||||||
{ title: "Schedule", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
{ title: "Schedule", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||||
|
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||||
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
|
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
|
||||||
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
|
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
|
||||||
{ title: "Tasks", url: createPageUrl("ActivityLog"), icon: CheckSquare },
|
|
||||||
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
|
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
|
||||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||||
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
|
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
|
||||||
@@ -125,8 +132,10 @@ const roleNavigationMap = {
|
|||||||
],
|
],
|
||||||
workforce: [
|
workforce: [
|
||||||
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
||||||
|
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||||
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||||
|
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||||
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
||||||
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
|
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
|
||||||
@@ -281,200 +290,34 @@ export default function Layout({ children }) {
|
|||||||
--muted: 241 245 249;
|
--muted: 241 245 249;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Calendar styling kept as is */
|
.rdp * { border-color: transparent !important; }
|
||||||
.rdp * {
|
.rdp-day { font-size: 0.875rem !important; min-width: 36px !important; height: 36px !important; border-radius: 50% !important; transition: all 0.2s ease !important; font-weight: 500 !important; position: relative !important; }
|
||||||
border-color: transparent !important;
|
.rdp-day button { width: 100% !important; height: 100% !important; border-radius: 50% !important; background-color: transparent !important; }
|
||||||
}
|
.rdp-day_range_start, .rdp-day_range_start > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||||
|
.rdp-day_range_end, .rdp-day_range_end > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||||
.rdp-day {
|
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end), .rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||||
font-size: 0.875rem !important;
|
.rdp-day_selected, .rdp-day_selected > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||||
min-width: 36px !important;
|
.rdp-day_range_middle, .rdp-day_range_middle > button { background-color: #dbeafe !important; background: #dbeafe !important; color: #2563eb !important; font-weight: 600 !important; border-radius: 0 !important; box-shadow: none !important; }
|
||||||
height: 36px !important;
|
.rdp-day_range_start.rdp-day_range_end, .rdp-day_range_start.rdp-day_range_end > button { border-radius: 50% !important; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; }
|
||||||
border-radius: 50% !important;
|
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button { background-color: #eff6ff !important; background: #eff6ff !important; color: #2563eb !important; border-radius: 50% !important; }
|
||||||
transition: all 0.2s ease !important;
|
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after { content: '' !important; position: absolute !important; bottom: 4px !important; left: 50% !important; transform: translateX(-50%) !important; width: 4px !important; height: 4px !important; background-color: #ec4899 !important; border-radius: 50% !important; z-index: 10 !important; }
|
||||||
font-weight: 500 !important;
|
.rdp-day_today.rdp-day_selected, .rdp-day_today.rdp-day_range_start, .rdp-day_today.rdp-day_range_end { color: white !important; }
|
||||||
position: relative !important;
|
.rdp-day_today.rdp-day_selected > button, .rdp-day_today.rdp-day_range_start > button, .rdp-day_today.rdp-day_range_end > button { color: white !important; }
|
||||||
}
|
.rdp-day_outside, .rdp-day_outside > button { color: #cbd5e1 !important; opacity: 0.5 !important; }
|
||||||
|
.rdp-day_disabled, .rdp-day_disabled > button { opacity: 0.3 !important; cursor: not-allowed !important; }
|
||||||
.rdp-day button {
|
.rdp-day_selected, .rdp-day_range_start, .rdp-day_range_end, .rdp-day_range_middle { opacity: 1 !important; visibility: visible !important; z-index: 5 !important; }
|
||||||
width: 100% !important;
|
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before { content: '' !important; position: absolute !important; top: 4px !important; right: 4px !important; width: 4px !important; height: 4px !important; background-color: #2563eb !important; border-radius: 50% !important; }
|
||||||
height: 100% !important;
|
.rdp-day_selected.has-events::before, .rdp-day_range_start.has-events::before, .rdp-day_range_end.has-events::before { background-color: white !important; }
|
||||||
border-radius: 50% !important;
|
.rdp-day_range_middle.has-events::before { background-color: #2563eb !important; }
|
||||||
background-color: transparent !important;
|
.rdp-head_cell { color: #64748b !important; font-weight: 600 !important; font-size: 0.75rem !important; text-transform: uppercase !important; padding: 8px 0 !important; }
|
||||||
}
|
.rdp-caption_label { font-size: 1rem !important; font-weight: 700 !important; color: #0f172a !important; }
|
||||||
|
.rdp-nav_button { width: 32px !important; height: 32px !important; border-radius: 6px !important; transition: all 0.2s ease !important; }
|
||||||
.rdp-day_range_start,
|
.rdp-nav_button:hover { background-color: #eff6ff !important; color: #2563eb !important; }
|
||||||
.rdp-day_range_start > button {
|
.rdp-months { gap: 2rem !important; }
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
.rdp-month { padding: 0.75rem !important; }
|
||||||
color: white !important;
|
.rdp-table { border-spacing: 0 !important; margin-top: 1rem !important; }
|
||||||
font-weight: 700 !important;
|
.rdp-cell { padding: 2px !important; }
|
||||||
border-radius: 50% !important;
|
.rdp-day[style*="background"] { background: transparent !important; }
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_range_end,
|
|
||||||
.rdp-day_range_end > button {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end),
|
|
||||||
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_selected,
|
|
||||||
.rdp-day_selected > button {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_range_middle,
|
|
||||||
.rdp-day_range_middle > button {
|
|
||||||
background-color: #dbeafe !important;
|
|
||||||
background: #dbeafe !important;
|
|
||||||
color: #2563eb !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_range_start.rdp-day_range_end,
|
|
||||||
.rdp-day_range_start.rdp-day_range_end > button {
|
|
||||||
border-radius: 50% !important;
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button {
|
|
||||||
background-color: #eff6ff !important;
|
|
||||||
background: #eff6ff !important;
|
|
||||||
color: #2563eb !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after {
|
|
||||||
content: '' !important;
|
|
||||||
position: absolute !important;
|
|
||||||
bottom: 4px !important;
|
|
||||||
left: 50% !important;
|
|
||||||
transform: translateX(-50%) !important;
|
|
||||||
width: 4px !important;
|
|
||||||
height: 4px !important;
|
|
||||||
background-color: #ec4899 !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
z-index: 10 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_today.rdp-day_selected,
|
|
||||||
.rdp-day_today.rdp-day_range_start,
|
|
||||||
.rdp-day_today.rdp-day_range_end {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_today.rdp-day_selected > button,
|
|
||||||
.rdp-day_today.rdp-day_range_start > button,
|
|
||||||
.rdp-day_today.rdp-day_range_end > button {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_outside,
|
|
||||||
.rdp-day_outside > button {
|
|
||||||
color: #cbd5e1 !important;
|
|
||||||
opacity: 0.5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_disabled,
|
|
||||||
.rdp-day_disabled > button {
|
|
||||||
opacity: 0.3 !important;
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_selected,
|
|
||||||
.rdp-day_range_start,
|
|
||||||
.rdp-day_range_end,
|
|
||||||
.rdp-day_range_middle {
|
|
||||||
opacity: 1 !important;
|
|
||||||
visibility: visible !important;
|
|
||||||
z-index: 5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-head_cell {
|
|
||||||
color: #64748b !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
font-size: 0.75rem !important;
|
|
||||||
text-transform: uppercase !important;
|
|
||||||
padding: 8px 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-caption_label {
|
|
||||||
font-size: 1rem !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
color: #0f172a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-nav_button {
|
|
||||||
width: 32px !important;
|
|
||||||
height: 32px !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: all 0.2s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-nav_button:hover {
|
|
||||||
background-color: #eff6ff !important;
|
|
||||||
color: #2563eb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before {
|
|
||||||
content: '' !important;
|
|
||||||
position: absolute !important;
|
|
||||||
top: 4px !important;
|
|
||||||
right: 4px !important;
|
|
||||||
width: 4px !important;
|
|
||||||
height: 4px !important;
|
|
||||||
background-color: #2563eb !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_selected.has-events::before,
|
|
||||||
.rdp-day_range_start.has-events::before,
|
|
||||||
.rdp-day_range_end.has-events::before {
|
|
||||||
background-color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day_range_middle.has-events::before {
|
|
||||||
background-color: #2563eb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-months {
|
|
||||||
gap: 2rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-month {
|
|
||||||
padding: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-table {
|
|
||||||
border-spacing: 0 !important;
|
|
||||||
margin-top: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-cell {
|
|
||||||
padding: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day[style*="background"] {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
|
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
|
||||||
@@ -490,20 +333,14 @@ export default function Layout({ children }) {
|
|||||||
<div className="border-b border-slate-200 p-6">
|
<div className="border-b border-slate-200 p-6">
|
||||||
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-3 mb-4" onClick={() => setMobileMenuOpen(false)}>
|
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-3 mb-4" onClick={() => setMobileMenuOpen(false)}>
|
||||||
<div className="w-8 h-8 flex items-center justify-center">
|
<div className="w-8 h-8 flex items-center justify-center">
|
||||||
<img
|
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
|
||||||
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
|
|
||||||
alt="KROW Logo"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-bold text-[#1C323E]">KROW</h2>
|
<h2 className="font-bold text-[#1C323E]">KROW</h2>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
|
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
|
||||||
<Avatar className="w-10 h-10">
|
<Avatar className="w-10 h-10">
|
||||||
<AvatarImage src={userAvatar} alt={userName} />
|
<AvatarImage src={userAvatar} alt={userName} />
|
||||||
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">
|
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">{userInitial}</AvatarFallback>
|
||||||
{userInitial}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold text-[#1C323E] text-sm truncate">{userName}</p>
|
<p className="font-semibold text-[#1C323E] text-sm truncate">{userName}</p>
|
||||||
@@ -515,13 +352,8 @@ export default function Layout({ children }) {
|
|||||||
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
|
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 border-t border-slate-200">
|
<div className="p-3 border-t border-slate-200">
|
||||||
<Button
|
<Button variant="ghost" className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {handleLogout(); setMobileMenuOpen(false);}}>
|
||||||
variant="ghost"
|
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
||||||
onClick={() => {handleLogout(); setMobileMenuOpen(false);}}
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
|
||||||
Logout
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -529,11 +361,7 @@ export default function Layout({ children }) {
|
|||||||
|
|
||||||
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||||
<div className="w-8 h-8 flex items-center justify-center">
|
<div className="w-8 h-8 flex items-center justify-center">
|
||||||
<img
|
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
|
||||||
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
|
|
||||||
alt="KROW Logo"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<h1 className="text-base font-bold text-[#1C323E]">KROW Workforce Control Tower</h1>
|
<h1 className="text-base font-bold text-[#1C323E]">KROW Workforce Control Tower</h1>
|
||||||
@@ -543,39 +371,22 @@ export default function Layout({ children }) {
|
|||||||
<div className="hidden md:flex flex-1 max-w-xl">
|
<div className="hidden md:flex flex-1 max-w-xl">
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input type="text" placeholder="Find employees, menu items, settings, and more..." className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0A39DF] focus:border-transparent" />
|
||||||
type="text"
|
|
||||||
placeholder="Find employees, menu items, settings, and more..."
|
|
||||||
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0A39DF] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button onClick={handleRefresh} className="flex items-center gap-2 px-3 py-2 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 rounded-lg transition-all group" title="Unpublished changes - Click to refresh">
|
||||||
onClick={handleRefresh}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 rounded-lg transition-all group"
|
|
||||||
title="Unpublished changes - Click to refresh"
|
|
||||||
>
|
|
||||||
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
|
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
|
||||||
<span className="hidden lg:inline text-sm font-medium">Unpublished changes</span>
|
<span className="hidden lg:inline text-sm font-medium">Unpublished changes</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="md:hidden hover:bg-slate-100" title="Search">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="md:hidden hover:bg-slate-100"
|
|
||||||
title="Search"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5 text-slate-600" />
|
<Search className="w-5 h-5 text-slate-600" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<button onClick={() => setShowNotifications(true)} className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors" title="Notifications">
|
||||||
onClick={() => setShowNotifications(true)}
|
|
||||||
className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|
||||||
title="Notifications"
|
|
||||||
>
|
|
||||||
<Bell className="w-5 h-5 text-slate-600" />
|
<Bell className="w-5 h-5 text-slate-600" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||||
@@ -609,22 +420,21 @@ export default function Layout({ children }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("NotificationSettings")}>
|
||||||
|
<Bell className="w-4 h-4 mr-2" />Notification Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Settings")}>
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Settings")}>
|
||||||
<SettingsIcon className="w-4 h-4 mr-2" />
|
<SettingsIcon className="w-4 h-4 mr-2" />Settings
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-2" />Reports
|
||||||
Reports
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
|
||||||
<Activity className="w-4 h-4 mr-2" />
|
<Activity className="w-4 h-4 mr-2" />Activity Log
|
||||||
Activity Log
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||||
Logout
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -634,9 +444,7 @@ export default function Layout({ children }) {
|
|||||||
<button className="flex items-center gap-2 hover:bg-slate-100 rounded-lg p-1.5 transition-colors" title={`${userName} - ${getRoleName(userRole)}`}>
|
<button className="flex items-center gap-2 hover:bg-slate-100 rounded-lg p-1.5 transition-colors" title={`${userName} - ${getRoleName(userRole)}`}>
|
||||||
<Avatar className="w-8 h-8">
|
<Avatar className="w-8 h-8">
|
||||||
<AvatarImage src={userAvatar} alt={userName} />
|
<AvatarImage src={userAvatar} alt={userName} />
|
||||||
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">
|
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">{userInitial}</AvatarFallback>
|
||||||
{userInitial}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
|
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -651,12 +459,10 @@ export default function Layout({ children }) {
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
|
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
|
||||||
<Home className="w-4 h-4 mr-2" />
|
<Home className="w-4 h-4 mr-2" />Dashboard
|
||||||
Dashboard
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
|
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
|
||||||
<User className="w-4 h-4 mr-2" />
|
<User className="w-4 h-4 mr-2" />My Profile
|
||||||
My Profile
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -686,15 +492,11 @@ export default function Layout({ children }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NotificationPanel
|
<NotificationPanel isOpen={showNotifications} onClose={() => setShowNotifications(false)} />
|
||||||
isOpen={showNotifications}
|
<NotificationEngine />
|
||||||
onClose={() => setShowNotifications(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChatBubble />
|
<ChatBubble />
|
||||||
<RoleSwitcher />
|
<RoleSwitcher />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
271
frontend-web/src/pages/NotificationSettings.jsx
Normal file
271
frontend-web/src/pages/NotificationSettings.jsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Bell, Mail, Calendar, Briefcase, AlertCircle, CheckCircle } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export default function NotificationSettings() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: currentUser } = useQuery({
|
||||||
|
queryKey: ['current-user-notification-settings'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [preferences, setPreferences] = useState(
|
||||||
|
currentUser?.notification_preferences || {
|
||||||
|
email_notifications: true,
|
||||||
|
in_app_notifications: true,
|
||||||
|
shift_assignments: true,
|
||||||
|
shift_reminders: true,
|
||||||
|
shift_changes: true,
|
||||||
|
upcoming_events: true,
|
||||||
|
new_leads: true,
|
||||||
|
invoice_updates: true,
|
||||||
|
system_alerts: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatePreferencesMutation = useMutation({
|
||||||
|
mutationFn: (prefs) => base44.auth.updateMe({ notification_preferences: prefs }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['current-user-notification-settings'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Settings Updated",
|
||||||
|
description: "Your notification preferences have been saved",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Update Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggle = (key) => {
|
||||||
|
setPreferences(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updatePreferencesMutation.mutate(preferences);
|
||||||
|
};
|
||||||
|
|
||||||
|
const userRole = currentUser?.role || currentUser?.user_role || 'admin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Notification Settings</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
Configure how and when you receive notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Global Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
Global Notification Settings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Bell className="w-5 h-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">In-App Notifications</Label>
|
||||||
|
<p className="text-sm text-slate-500">Receive notifications in the app</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.in_app_notifications}
|
||||||
|
onCheckedChange={() => handleToggle('in_app_notifications')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Mail className="w-5 h-5 text-purple-600" />
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Email Notifications</Label>
|
||||||
|
<p className="text-sm text-slate-500">Receive notifications via email</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.email_notifications}
|
||||||
|
onCheckedChange={() => handleToggle('email_notifications')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Staff/Workforce Notifications */}
|
||||||
|
{(userRole === 'workforce' || userRole === 'admin' || userRole === 'vendor') && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
|
Shift Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Shift Assignments</Label>
|
||||||
|
<p className="text-sm text-slate-500">When you're assigned to a new shift</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.shift_assignments}
|
||||||
|
onCheckedChange={() => handleToggle('shift_assignments')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Shift Reminders</Label>
|
||||||
|
<p className="text-sm text-slate-500">24 hours before your shift starts</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.shift_reminders}
|
||||||
|
onCheckedChange={() => handleToggle('shift_reminders')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Shift Changes</Label>
|
||||||
|
<p className="text-sm text-slate-500">When shift details are modified</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.shift_changes}
|
||||||
|
onCheckedChange={() => handleToggle('shift_changes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Client Notifications */}
|
||||||
|
{(userRole === 'client' || userRole === 'admin') && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Briefcase className="w-5 h-5" />
|
||||||
|
Event Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Upcoming Events</Label>
|
||||||
|
<p className="text-sm text-slate-500">Reminders 3 days before your event</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.upcoming_events}
|
||||||
|
onCheckedChange={() => handleToggle('upcoming_events')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Staff Updates</Label>
|
||||||
|
<p className="text-sm text-slate-500">When staff are assigned or changed</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.shift_changes}
|
||||||
|
onCheckedChange={() => handleToggle('shift_changes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vendor Notifications */}
|
||||||
|
{(userRole === 'vendor' || userRole === 'admin') && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Briefcase className="w-5 h-5" />
|
||||||
|
Business Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">New Leads</Label>
|
||||||
|
<p className="text-sm text-slate-500">When new staffing opportunities are available</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.new_leads}
|
||||||
|
onCheckedChange={() => handleToggle('new_leads')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">Invoice Updates</Label>
|
||||||
|
<p className="text-sm text-slate-500">Invoice status changes and payments</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.invoice_updates}
|
||||||
|
onCheckedChange={() => handleToggle('invoice_updates')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* System Notifications */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
System Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="font-semibold">System Alerts</Label>
|
||||||
|
<p className="text-sm text-slate-500">Important platform updates and announcements</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.system_alerts}
|
||||||
|
onCheckedChange={() => handleToggle('system_alerts')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPreferences(currentUser?.notification_preferences || {})}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updatePreferencesMutation.isPending}
|
||||||
|
className="bg-[#0A39DF]"
|
||||||
|
>
|
||||||
|
{updatePreferencesMutation.isPending ? "Saving..." : "Save Preferences"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
505
frontend-web/src/pages/RapidOrder.jsx
Normal file
505
frontend-web/src/pages/RapidOrder.jsx
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createPageUrl } from "@/utils";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles, Mic, X, Calendar as CalendarIcon, ArrowLeft } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
// Helper function to convert 24-hour time to 12-hour format
|
||||||
|
const convertTo12Hour = (time24) => {
|
||||||
|
if (!time24 || time24 === "—") return time24;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = time24.split(':');
|
||||||
|
if (!parts || parts.length < 2) return time24;
|
||||||
|
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return time24;
|
||||||
|
|
||||||
|
const period = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const hours12 = hours % 12 || 12;
|
||||||
|
const minutesStr = minutes.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${hours12}:${minutesStr} ${period}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting time:', error);
|
||||||
|
return time24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RapidOrder() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [conversation, setConversation] = useState([]);
|
||||||
|
const [detectedOrder, setDetectedOrder] = useState(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const [submissionTime, setSubmissionTime] = useState(null);
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-rapid'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: businesses } = useQuery({
|
||||||
|
queryKey: ['user-businesses'],
|
||||||
|
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
|
||||||
|
enabled: !!user,
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRapidOrderMutation = useMutation({
|
||||||
|
mutationFn: (orderData) => base44.entities.Event.create(orderData),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
setSubmissionTime(now);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "✅ RAPID Order Created",
|
||||||
|
description: "Order sent to preferred vendor with priority notification",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message in chat
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: `🚀 **Order Submitted Successfully!**\n\nOrder Number: **${data.id?.slice(-8) || 'RAPID-001'}**\nSubmitted: **${format(now, 'h:mm:ss a')}**\n\nYour preferred vendor has been notified and will assign staff shortly.`,
|
||||||
|
isSuccess: true
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Reset after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(createPageUrl("ClientDashboard"));
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyzeMessage = async (msg) => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
setConversation(prev => [...prev, { role: 'user', content: msg }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await base44.integrations.Core.InvokeLLM({
|
||||||
|
prompt: `You are an order assistant. Analyze this message and extract order details:
|
||||||
|
|
||||||
|
Message: "${msg}"
|
||||||
|
Current user: ${user?.full_name}
|
||||||
|
User's locations: ${businesses.map(b => b.business_name).join(', ')}
|
||||||
|
|
||||||
|
Extract:
|
||||||
|
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
|
||||||
|
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
|
||||||
|
3. Number of staff (if mentioned, parse the number correctly - e.g., "5 cooks" = 5, "need 3 servers" = 3)
|
||||||
|
4. End time (if mentioned, extract the time - e.g., "until 5am" = "05:00", "until 11pm" = "23:00", "until midnight" = "00:00")
|
||||||
|
5. Location (if mentioned, otherwise use first available location)
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Make sure to correctly extract the number of staff from phrases like "need 5 cooks" or "I need 3 bartenders"
|
||||||
|
- If end time is mentioned (e.g., "until 5am", "till 11pm"), extract it in 24-hour format (e.g., "05:00", "23:00")
|
||||||
|
- If no end time is mentioned, leave it as null
|
||||||
|
|
||||||
|
Return a concise summary.`,
|
||||||
|
response_json_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
is_urgent: { type: "boolean" },
|
||||||
|
role: { type: "string" },
|
||||||
|
count: { type: "number" },
|
||||||
|
location: { type: "string" },
|
||||||
|
end_time: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = response;
|
||||||
|
const primaryLocation = businesses[0]?.business_name || "Primary Location";
|
||||||
|
|
||||||
|
// Ensure count is properly set - default to 1 if not detected
|
||||||
|
const staffCount = parsed.count && parsed.count > 0 ? parsed.count : 1;
|
||||||
|
|
||||||
|
// Get current time for start_time (when ASAP)
|
||||||
|
const now = new Date();
|
||||||
|
const currentTime = format(now, 'HH:mm');
|
||||||
|
|
||||||
|
// Handle end_time - use parsed end time or current time as confirmation time
|
||||||
|
const endTime = parsed.end_time || currentTime;
|
||||||
|
|
||||||
|
const order = {
|
||||||
|
is_rapid: parsed.is_urgent || true,
|
||||||
|
role: parsed.role || "Staff Member",
|
||||||
|
count: staffCount,
|
||||||
|
location: parsed.location || primaryLocation,
|
||||||
|
start_time: currentTime, // Always use current time for ASAP orders (24-hour format for storage)
|
||||||
|
end_time: endTime, // Use parsed end time or current time (24-hour format for storage)
|
||||||
|
start_time_display: convertTo12Hour(currentTime), // For display
|
||||||
|
end_time_display: convertTo12Hour(endTime), // For display
|
||||||
|
business_name: primaryLocation,
|
||||||
|
hub: businesses[0]?.hub_building || "Main Hub",
|
||||||
|
submission_time: now // Store the actual submission time
|
||||||
|
};
|
||||||
|
|
||||||
|
setDetectedOrder(order);
|
||||||
|
|
||||||
|
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nStart Time: ${order.start_time_display}\nEnd Time: ${order.end_time_display}`;
|
||||||
|
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: aiMessage,
|
||||||
|
showConfirm: true
|
||||||
|
}]);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
|
||||||
|
}]);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
analyzeMessage(message);
|
||||||
|
setMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVoiceInput = () => {
|
||||||
|
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
||||||
|
toast({
|
||||||
|
title: "Voice not supported",
|
||||||
|
description: "Your browser doesn't support voice input",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
|
||||||
|
recognition.onstart = () => setIsListening(true);
|
||||||
|
recognition.onend = () => setIsListening(false);
|
||||||
|
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
const transcript = event.results[0][0].transcript;
|
||||||
|
setMessage(transcript);
|
||||||
|
analyzeMessage(transcript);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
toast({
|
||||||
|
title: "Voice input failed",
|
||||||
|
description: "Please try typing instead",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmOrder = () => {
|
||||||
|
if (!detectedOrder) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const confirmTime = format(now, 'HH:mm');
|
||||||
|
const confirmTime12Hour = convertTo12Hour(confirmTime);
|
||||||
|
|
||||||
|
// Create comprehensive order data with proper requested field and actual times
|
||||||
|
const orderData = {
|
||||||
|
event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
|
||||||
|
is_rapid: true,
|
||||||
|
status: "Pending",
|
||||||
|
business_name: detectedOrder.business_name,
|
||||||
|
hub: detectedOrder.hub,
|
||||||
|
event_location: detectedOrder.location,
|
||||||
|
date: now.toISOString().split('T')[0],
|
||||||
|
requested: Number(detectedOrder.count), // Ensure it's a number
|
||||||
|
client_name: user?.full_name,
|
||||||
|
client_email: user?.email,
|
||||||
|
notes: `RAPID ORDER - Submitted at ${detectedOrder.start_time_display} - Confirmed at ${confirmTime12Hour}\nStart: ${detectedOrder.start_time_display} | End: ${detectedOrder.end_time_display}`,
|
||||||
|
shifts: [{
|
||||||
|
shift_name: "Emergency Shift",
|
||||||
|
location: detectedOrder.location,
|
||||||
|
roles: [{
|
||||||
|
role: detectedOrder.role,
|
||||||
|
count: Number(detectedOrder.count), // Ensure it's a number
|
||||||
|
start_time: detectedOrder.start_time, // Store in 24-hour format
|
||||||
|
end_time: detectedOrder.end_time // Store in 24-hour format
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating RAPID order with data:', orderData); // Debug log
|
||||||
|
|
||||||
|
createRapidOrderMutation.mutate(orderData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOrder = () => {
|
||||||
|
setConversation(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: "Please describe what you'd like to change."
|
||||||
|
}]);
|
||||||
|
setDetectedOrder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 p-6">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(createPageUrl("ClientDashboard"))}
|
||||||
|
className="hover:bg-white/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<Zap className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-red-700 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-6 h-6" />
|
||||||
|
RAPID Order
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-red-600 mt-1">Emergency staffing in minutes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600 mb-1">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
<span>{format(new Date(), 'EEEE, MMMM d, yyyy')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{format(new Date(), 'h:mm a')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-white border-2 border-red-300 shadow-2xl">
|
||||||
|
<CardHeader className="border-b border-red-200 bg-gradient-to-r from-red-50 to-orange-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg font-bold text-red-700">
|
||||||
|
Tell us what you need
|
||||||
|
</CardTitle>
|
||||||
|
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
|
||||||
|
URGENT
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="space-y-4 mb-6 max-h-[500px] overflow-y-auto">
|
||||||
|
{conversation.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-2xl">
|
||||||
|
<Zap className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-2xl text-slate-900 mb-3">Need staff urgently?</h3>
|
||||||
|
<p className="text-base text-slate-600 mb-6">Type or speak what you need, I'll handle the rest</p>
|
||||||
|
<div className="text-left max-w-lg mx-auto space-y-3">
|
||||||
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-4 rounded-xl border-2 border-blue-200 text-sm">
|
||||||
|
<strong className="text-blue-900">Example:</strong> <span className="text-slate-700">"We had a call out. Need 2 cooks ASAP"</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-r from-purple-50 to-pink-50 p-4 rounded-xl border-2 border-purple-200 text-sm">
|
||||||
|
<strong className="text-purple-900">Example:</strong> <span className="text-slate-700">"Need 5 bartenders ASAP until 5am"</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4 rounded-xl border-2 border-green-200 text-sm">
|
||||||
|
<strong className="text-green-900">Example:</strong> <span className="text-slate-700">"Emergency! Need 3 servers right now till midnight"</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{conversation.map((msg, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={idx}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div className={`max-w-[85%] ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
|
||||||
|
: msg.isSuccess
|
||||||
|
? 'bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300'
|
||||||
|
: 'bg-white border-2 border-red-200'
|
||||||
|
} rounded-2xl p-5 shadow-lg`}>
|
||||||
|
{msg.role === 'assistant' && !msg.isSuccess && (
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-red-600">AI Assistant</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={`text-base whitespace-pre-line ${
|
||||||
|
msg.role === 'user' ? 'text-white' :
|
||||||
|
msg.isSuccess ? 'text-green-900' :
|
||||||
|
'text-slate-900'
|
||||||
|
}`}>
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{msg.showConfirm && detectedOrder && (
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 bg-gradient-to-br from-slate-50 to-blue-50 rounded-xl border-2 border-blue-300">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-semibold">Staff Needed</p>
|
||||||
|
<p className="font-bold text-base text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<MapPin className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-semibold">Location</p>
|
||||||
|
<p className="font-bold text-base text-slate-900">{detectedOrder.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 col-span-2">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<Clock className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-semibold">Time</p>
|
||||||
|
<p className="font-bold text-base text-slate-900">
|
||||||
|
Start: {detectedOrder.start_time_display} | End: {detectedOrder.end_time_display}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmOrder}
|
||||||
|
disabled={createRapidOrderMutation.isPending}
|
||||||
|
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-xl text-base py-6"
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5 mr-2" />
|
||||||
|
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleEditOrder}
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 border-red-300 hover:bg-red-50 text-base py-6"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-5 h-5 mr-2" />
|
||||||
|
EDIT
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{isProcessing && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex justify-start"
|
||||||
|
>
|
||||||
|
<div className="bg-white border-2 border-red-200 rounded-2xl p-5 shadow-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
|
||||||
|
<Sparkles className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-base text-slate-600">Processing your request...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Type or speak... (e.g., 'Need 5 cooks ASAP until 5am')"
|
||||||
|
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base resize-none"
|
||||||
|
rows={3}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleVoiceInput}
|
||||||
|
disabled={isProcessing || isListening}
|
||||||
|
variant="outline"
|
||||||
|
className={`border-2 ${isListening ? 'border-red-500 bg-red-50' : 'border-red-300'} hover:bg-red-50 text-base py-6 px-6`}
|
||||||
|
>
|
||||||
|
<Mic className={`w-5 h-5 mr-2 ${isListening ? 'animate-pulse text-red-600' : ''}`} />
|
||||||
|
{isListening ? 'Listening...' : 'Speak'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!message.trim() || isProcessing}
|
||||||
|
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-xl text-base py-6"
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5 mr-2" />
|
||||||
|
Send Message
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helper Text */}
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 border-2 border-blue-200 rounded-xl">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
|
||||||
|
Optionally add end time like "until 5am" or "till midnight".
|
||||||
|
AI will auto-detect your location and send to your preferred vendor with priority notification.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
36
frontend-web/src/pages/SmartScheduler.jsx
Normal file
36
frontend-web/src/pages/SmartScheduler.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createPageUrl } from "@/utils";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowRight, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SmartScheduler() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 bg-slate-50 min-h-screen flex items-center justify-center">
|
||||||
|
<Card className="max-w-2xl w-full">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Sparkles className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-4">
|
||||||
|
Smart Scheduling is Now Part of Orders
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-slate-600 mb-8">
|
||||||
|
All smart assignment, automation, and scheduling features have been unified into the main Order Management view for a consistent experience.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => navigate(createPageUrl("Events"))}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||||
|
>
|
||||||
|
Go to Order Management
|
||||||
|
<ArrowRight className="w-5 h-5 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend-web/src/pages/StaffOnboarding.jsx
Normal file
197
frontend-web/src/pages/StaffOnboarding.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createPageUrl } from "@/utils";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CheckCircle, Circle } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import ProfileSetupStep from "@/components/onboarding/ProfileSetupStep";
|
||||||
|
import DocumentUploadStep from "@/components/onboarding/DocumentUploadStep";
|
||||||
|
import TrainingStep from "@/components/onboarding/TrainingStep";
|
||||||
|
import CompletionStep from "@/components/onboarding/CompletionStep";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, name: "Profile Setup", description: "Basic information" },
|
||||||
|
{ id: 2, name: "Documents", description: "Upload required documents" },
|
||||||
|
{ id: 3, name: "Training", description: "Complete compliance training" },
|
||||||
|
{ id: 4, name: "Complete", description: "Finish onboarding" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StaffOnboarding() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [onboardingData, setOnboardingData] = useState({
|
||||||
|
profile: {},
|
||||||
|
documents: [],
|
||||||
|
training: { completed: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: currentUser } = useQuery({
|
||||||
|
queryKey: ['current-user-onboarding'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStaffMutation = useMutation({
|
||||||
|
mutationFn: (staffData) => base44.entities.Staff.create(staffData),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
|
toast({
|
||||||
|
title: "✅ Onboarding Complete",
|
||||||
|
description: "Welcome to KROW! Your profile is now active.",
|
||||||
|
});
|
||||||
|
navigate(createPageUrl("WorkforceDashboard"));
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "❌ Onboarding Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNext = (stepData) => {
|
||||||
|
setOnboardingData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[stepData.type]: stepData.data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (currentStep < steps.length) {
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
const staffData = {
|
||||||
|
employee_name: onboardingData.profile.full_name,
|
||||||
|
email: onboardingData.profile.email || currentUser?.email,
|
||||||
|
phone: onboardingData.profile.phone,
|
||||||
|
address: onboardingData.profile.address,
|
||||||
|
city: onboardingData.profile.city,
|
||||||
|
position: onboardingData.profile.position,
|
||||||
|
department: onboardingData.profile.department,
|
||||||
|
hub_location: onboardingData.profile.hub_location,
|
||||||
|
employment_type: onboardingData.profile.employment_type,
|
||||||
|
english: onboardingData.profile.english_level,
|
||||||
|
certifications: onboardingData.documents.filter(d => d.type === 'certification').map(d => ({
|
||||||
|
name: d.name,
|
||||||
|
issued_date: d.issued_date,
|
||||||
|
expiry_date: d.expiry_date,
|
||||||
|
document_url: d.url,
|
||||||
|
})),
|
||||||
|
background_check_status: onboardingData.documents.some(d => d.type === 'background_check') ? 'pending' : 'not_required',
|
||||||
|
notes: `Onboarding completed: ${new Date().toISOString()}. Training modules completed: ${onboardingData.training.completed.length}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
createStaffMutation.mutate(staffData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<ProfileSetupStep
|
||||||
|
data={onboardingData.profile}
|
||||||
|
onNext={handleNext}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<DocumentUploadStep
|
||||||
|
data={onboardingData.documents}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<TrainingStep
|
||||||
|
data={onboardingData.training}
|
||||||
|
onNext={handleNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<CompletionStep
|
||||||
|
data={onboardingData}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onBack={handleBack}
|
||||||
|
isSubmitting={createStaffMutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 md:p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-2">
|
||||||
|
Welcome to KROW! 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Let's get you set up in just a few steps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{steps.map((step, idx) => (
|
||||||
|
<React.Fragment key={step.id}>
|
||||||
|
<div className="flex flex-col items-center flex-1">
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||||
|
currentStep > step.id
|
||||||
|
? "bg-green-500 text-white"
|
||||||
|
: currentStep === step.id
|
||||||
|
? "bg-[#0A39DF] text-white"
|
||||||
|
: "bg-slate-200 text-slate-400"
|
||||||
|
}`}>
|
||||||
|
{currentStep > step.id ? (
|
||||||
|
<CheckCircle className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<span className="font-bold">{step.id}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm font-medium mt-2 ${
|
||||||
|
currentStep >= step.id ? "text-slate-900" : "text-slate-400"
|
||||||
|
}`}>
|
||||||
|
{step.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">{step.description}</p>
|
||||||
|
</div>
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<div className={`flex-1 h-1 ${
|
||||||
|
currentStep > step.id ? "bg-green-500" : "bg-slate-200"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 md:p-8">
|
||||||
|
{renderStep()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
466
frontend-web/src/pages/TaskBoard.jsx
Normal file
466
frontend-web/src/pages/TaskBoard.jsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { base44 } from "@/api/base44Client";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import { Link2, Plus, Users } from "lucide-react";
|
||||||
|
import TaskCard from "@/components/tasks/TaskCard";
|
||||||
|
import TaskColumn from "@/components/tasks/TaskColumn";
|
||||||
|
import TaskDetailModal from "@/components/tasks/TaskDetailModal";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export default function TaskBoard() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [createDialog, setCreateDialog] = useState(false);
|
||||||
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState("pending");
|
||||||
|
const [newTask, setNewTask] = useState({
|
||||||
|
task_name: "",
|
||||||
|
description: "",
|
||||||
|
priority: "normal",
|
||||||
|
due_date: "",
|
||||||
|
progress: 0,
|
||||||
|
assigned_members: []
|
||||||
|
});
|
||||||
|
const [selectedMembers, setSelectedMembers] = useState([]);
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['current-user-taskboard'],
|
||||||
|
queryFn: () => base44.auth.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: teams = [] } = useQuery({
|
||||||
|
queryKey: ['teams'],
|
||||||
|
queryFn: () => base44.entities.Team.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: teamMembers = [] } = useQuery({
|
||||||
|
queryKey: ['team-members'],
|
||||||
|
queryFn: () => base44.entities.TeamMember.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tasks = [] } = useQuery({
|
||||||
|
queryKey: ['tasks'],
|
||||||
|
queryFn: () => base44.entities.Task.list(),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
|
||||||
|
const teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
|
||||||
|
const currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
|
||||||
|
|
||||||
|
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager');
|
||||||
|
const regularMembers = currentTeamMembers.filter(m => m.role === 'member');
|
||||||
|
|
||||||
|
// Get unique departments from team members
|
||||||
|
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))];
|
||||||
|
|
||||||
|
const tasksByStatus = useMemo(() => ({
|
||||||
|
pending: teamTasks.filter(t => t.status === 'pending').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||||
|
in_progress: teamTasks.filter(t => t.status === 'in_progress').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||||
|
on_hold: teamTasks.filter(t => t.status === 'on_hold').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||||
|
completed: teamTasks.filter(t => t.status === 'completed').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||||
|
}), [teamTasks]);
|
||||||
|
|
||||||
|
const overallProgress = useMemo(() => {
|
||||||
|
if (teamTasks.length === 0) return 0;
|
||||||
|
const totalProgress = teamTasks.reduce((sum, t) => sum + (t.progress || 0), 0);
|
||||||
|
return Math.round(totalProgress / teamTasks.length);
|
||||||
|
}, [teamTasks]);
|
||||||
|
|
||||||
|
const createTaskMutation = useMutation({
|
||||||
|
mutationFn: (taskData) => base44.entities.Task.create(taskData),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
setCreateDialog(false);
|
||||||
|
setNewTask({
|
||||||
|
task_name: "",
|
||||||
|
description: "",
|
||||||
|
priority: "normal",
|
||||||
|
due_date: "",
|
||||||
|
progress: 0,
|
||||||
|
assigned_members: []
|
||||||
|
});
|
||||||
|
setSelectedMembers([]);
|
||||||
|
toast({
|
||||||
|
title: "✅ Task Created",
|
||||||
|
description: "New task added to the board",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDragEnd = (result) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const { source, destination, draggableId } = result;
|
||||||
|
|
||||||
|
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = teamTasks.find(t => t.id === draggableId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const newStatus = destination.droppableId;
|
||||||
|
updateTaskMutation.mutate({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
...task,
|
||||||
|
status: newStatus,
|
||||||
|
order_index: destination.index
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTask = () => {
|
||||||
|
if (!newTask.task_name.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Task name required",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTaskMutation.mutate({
|
||||||
|
...newTask,
|
||||||
|
team_id: userTeam?.id,
|
||||||
|
status: selectedStatus,
|
||||||
|
order_index: tasksByStatus[selectedStatus]?.length || 0,
|
||||||
|
assigned_members: selectedMembers.map(m => ({
|
||||||
|
member_id: m.id,
|
||||||
|
member_name: m.member_name,
|
||||||
|
avatar_url: m.avatar_url
|
||||||
|
})),
|
||||||
|
assigned_department: selectedMembers.length > 0 && selectedMembers[0].department ? selectedMembers[0].department : null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-slate-50 min-h-screen">
|
||||||
|
<div className="max-w-[1800px] mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white rounded-xl p-6 mb-6 shadow-sm border border-slate-200">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-600">Lead</span>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{leadMembers.slice(0, 3).map((member, idx) => (
|
||||||
|
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{leadMembers.length > 3 && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
|
||||||
|
+{leadMembers.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-slate-600">Team</span>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{regularMembers.slice(0, 3).map((member, idx) => (
|
||||||
|
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{regularMembers.length > 3 && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
|
||||||
|
+{regularMembers.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" className="border-slate-300">
|
||||||
|
<Link2 className="w-4 h-4 mr-2" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStatus("pending");
|
||||||
|
setCreateDialog(true);
|
||||||
|
}}
|
||||||
|
className="bg-[#0A39DF] hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-[#0A39DF] to-blue-600 transition-all"
|
||||||
|
style={{ width: `${overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-slate-900 ml-4">{overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kanban Board */}
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{['pending', 'in_progress', 'on_hold', 'completed'].map((status) => (
|
||||||
|
<TaskColumn
|
||||||
|
key={status}
|
||||||
|
status={status}
|
||||||
|
tasks={tasksByStatus[status]}
|
||||||
|
onAddTask={(status) => {
|
||||||
|
setSelectedStatus(status);
|
||||||
|
setCreateDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tasksByStatus[status].map((task, index) => (
|
||||||
|
<Draggable key={task.id} draggableId={task.id} index={index}>
|
||||||
|
{(provided) => (
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
provided={provided}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
</TaskColumn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
{teamTasks.length === 0 && (
|
||||||
|
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-300">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||||
|
<Plus className="w-8 h-8 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-xl text-slate-900 mb-2">No tasks yet</h3>
|
||||||
|
<p className="text-slate-600 mb-5">Create your first task to get started</p>
|
||||||
|
<Button onClick={() => setCreateDialog(true)} className="bg-[#0A39DF]">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Task Dialog */}
|
||||||
|
<Dialog open={createDialog} onOpenChange={setCreateDialog}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Task</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<Label>Task Name *</Label>
|
||||||
|
<Input
|
||||||
|
value={newTask.task_name}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, task_name: e.target.value })}
|
||||||
|
placeholder="e.g., Website Design"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={newTask.description}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||||
|
placeholder="Task details..."
|
||||||
|
rows={3}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Priority</Label>
|
||||||
|
<Select value={newTask.priority} onValueChange={(val) => setNewTask({ ...newTask, priority: val })}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="normal">Normal</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Due Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={newTask.due_date}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Initial Progress (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={newTask.progress}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, progress: parseInt(e.target.value) || 0 })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Label>Assign Team Members</Label>
|
||||||
|
{departments.length > 0 && (
|
||||||
|
<Select onValueChange={(dept) => {
|
||||||
|
const deptMembers = currentTeamMembers.filter(m => m.department === dept);
|
||||||
|
setSelectedMembers(deptMembers);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-56">
|
||||||
|
<SelectValue placeholder="Assign entire department" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{departments.map((dept) => {
|
||||||
|
const count = currentTeamMembers.filter(m => m.department === dept).length;
|
||||||
|
return (
|
||||||
|
<SelectItem key={dept} value={dept}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{dept} ({count} members)
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{currentTeamMembers.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">No team members available</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
|
||||||
|
{currentTeamMembers.map((member) => {
|
||||||
|
const isSelected = selectedMembers.some(m => m.id === member.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
|
||||||
|
} else {
|
||||||
|
setSelectedMembers([...selectedMembers, member]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-all ${
|
||||||
|
isSelected ? 'bg-blue-50 border-2 border-[#0A39DF]' : 'hover:bg-slate-50 border-2 border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="w-4 h-4 rounded text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||||
|
/>
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<img
|
||||||
|
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||||
|
alt={member.member_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-slate-900">{member.member_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{member.department ? `${member.department} • ` : ''}{member.role || 'Member'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-slate-50 rounded-lg">
|
||||||
|
{selectedMembers.map((member) => (
|
||||||
|
<Badge key={member.id} className="bg-[#0A39DF] text-white flex items-center gap-1">
|
||||||
|
{member.member_name}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
|
||||||
|
}}
|
||||||
|
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateTask}
|
||||||
|
disabled={createTaskMutation.isPending}
|
||||||
|
className="bg-[#0A39DF]"
|
||||||
|
>
|
||||||
|
Create Task
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Task Detail Modal with Comments */}
|
||||||
|
<TaskDetailModal
|
||||||
|
task={selectedTask}
|
||||||
|
open={!!selectedTask}
|
||||||
|
onClose={() => setSelectedTask(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,16 @@ import VendorDocumentReview from "./VendorDocumentReview";
|
|||||||
|
|
||||||
import VendorMarketplace from "./VendorMarketplace";
|
import VendorMarketplace from "./VendorMarketplace";
|
||||||
|
|
||||||
|
import RapidOrder from "./RapidOrder";
|
||||||
|
|
||||||
|
import SmartScheduler from "./SmartScheduler";
|
||||||
|
|
||||||
|
import StaffOnboarding from "./StaffOnboarding";
|
||||||
|
|
||||||
|
import NotificationSettings from "./NotificationSettings";
|
||||||
|
|
||||||
|
import TaskBoard from "./TaskBoard";
|
||||||
|
|
||||||
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
const PAGES = {
|
const PAGES = {
|
||||||
@@ -244,6 +254,16 @@ const PAGES = {
|
|||||||
|
|
||||||
VendorMarketplace: VendorMarketplace,
|
VendorMarketplace: VendorMarketplace,
|
||||||
|
|
||||||
|
RapidOrder: RapidOrder,
|
||||||
|
|
||||||
|
SmartScheduler: SmartScheduler,
|
||||||
|
|
||||||
|
StaffOnboarding: StaffOnboarding,
|
||||||
|
|
||||||
|
NotificationSettings: NotificationSettings,
|
||||||
|
|
||||||
|
TaskBoard: TaskBoard,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getCurrentPage(url) {
|
function _getCurrentPage(url) {
|
||||||
@@ -391,6 +411,16 @@ function PagesContent() {
|
|||||||
|
|
||||||
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
|
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
|
||||||
|
|
||||||
|
<Route path="/RapidOrder" element={<RapidOrder />} />
|
||||||
|
|
||||||
|
<Route path="/SmartScheduler" element={<SmartScheduler />} />
|
||||||
|
|
||||||
|
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
|
||||||
|
|
||||||
|
<Route path="/NotificationSettings" element={<NotificationSettings />} />
|
||||||
|
|
||||||
|
<Route path="/TaskBoard" element={<TaskBoard />} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
8
internal-api-harness/.env.example
Normal file
8
internal-api-harness/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
VITE_HARNESS_FIREBASE_API_KEY="your-api-key"
|
||||||
|
VITE_HARNESS_FIREBASE_AUTH_DOMAIN="your-auth-domain"
|
||||||
|
VITE_HARNESS_FIREBASE_PROJECT_ID="your-project-id"
|
||||||
|
VITE_HARNESS_FIREBASE_STORAGE_BUCKET="your-storage-bucket"
|
||||||
|
VITE_HARNESS_FIREBASE_MESSAGING_SENDER_ID="your-messaging-sender-id"
|
||||||
|
VITE_HARNESS_FIREBASE_APP_ID="your-app-id"
|
||||||
|
VITE_HARNESS_ENVIRONMENT="dev"
|
||||||
|
VITE_API_BASE_URL="http://localhost:8080"
|
||||||
24
internal-api-harness/.gitignore
vendored
Normal file
24
internal-api-harness/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
internal-api-harness/README.md
Normal file
16
internal-api-harness/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
17
internal-api-harness/components.json
Normal file
17
internal-api-harness/components.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal-api-harness/eslint.config.js
Normal file
29
internal-api-harness/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
internal-api-harness/index.html
Normal file
13
internal-api-harness/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>internal-api-harness</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
internal-api-harness/jsconfig.json
Normal file
10
internal-api-harness/jsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6611
internal-api-harness/package-lock.json
generated
Normal file
6611
internal-api-harness/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
internal-api-harness/package.json
Normal file
45
internal-api-harness/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "internal-api-harness",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"firebase": "^12.6.0",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-firebase-hooks": "^5.1.1",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
|
"shadcn-ui": "^0.9.5",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.18",
|
||||||
|
"vite": "^7.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal-api-harness/postcss.config.js
Normal file
6
internal-api-harness/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
18
internal-api-harness/public/logo.svg
Normal file
18
internal-api-harness/public/logo.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="200 180 280 140" style="enable-background:new 0 0 500 500;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#24303B;}
|
||||||
|
.st1{fill:#002FE3;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M459.81,202.55c-5.03,0.59-9.08,4.49-10.36,9.38l-15.99,59.71l-16.24-56.3
|
||||||
|
c-1.68-5.92-6.22-10.86-12.19-12.34c-1.58-0.39-3.11-0.54-4.64-0.49h-0.15c-1.53-0.05-3.11,0.1-4.64,0.49
|
||||||
|
c-5.97,1.48-10.51,6.42-12.24,12.34l-3.6,12.53l-11.35,39.38l-7.9-27.54c-10.76-37.5-48.56-62.23-88.38-55.32
|
||||||
|
c-33.26,5.82-57.05,35.68-56.99,69.48v0.79c0,4.34,0.39,8.73,1.13,13.18c0.18,1.02,0.37,2.03,0.6,3.03
|
||||||
|
c1.84,8.31,10.93,12.73,18.49,8.8v0c5.36-2.79,7.84-8.89,6.42-14.77c-0.85-3.54-1.28-7.23-1.23-11.03
|
||||||
|
c0-25.02,20.48-45.5,45.55-45.2c7.6,0.1,15.59,2.07,23.59,6.37c13.52,7.3,23.15,20.18,27.34,34.94l13.32,46.34
|
||||||
|
c1.73,5.97,6.22,11,12.24,12.58c9.62,2.62,19-3.06,21.51-12.04l16.09-56.7l0.2-0.1l16.09,56.85c1.63,5.68,5.87,10.41,11.55,11.99
|
||||||
|
c9.13,2.57,18.11-2.66,20.67-11.2l24.13-79.6C475.35,209.85,468.64,201.56,459.81,202.55z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
internal-api-harness/public/vite.svg
Normal file
1
internal-api-harness/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
internal-api-harness/src/App.css
Normal file
42
internal-api-harness/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
45
internal-api-harness/src/App.jsx
Normal file
45
internal-api-harness/src/App.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||||
|
import Layout from "./components/Layout";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import GenerateImage from "./pages/GenerateImage";
|
||||||
|
import ApiPlaceholder from "./pages/ApiPlaceholder";
|
||||||
|
import GetMe from "./pages/auth/GetMe";
|
||||||
|
import SendEmail from "./pages/core/SendEmail";
|
||||||
|
import EntityTester from "./pages/EntityTester";
|
||||||
|
|
||||||
|
import InvokeLLM from "./pages/core/InvokeLLM";
|
||||||
|
import UploadFile from "./pages/core/UploadFile";
|
||||||
|
import UploadPrivateFile from "./pages/core/UploadPrivateFile";
|
||||||
|
import CreateSignedUrl from "./pages/core/CreateSignedUrl";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
|
||||||
|
{/* Auth APIs */}
|
||||||
|
<Route path="/auth/me" element={<GetMe />} />
|
||||||
|
<Route path="/auth/update-me" element={<ApiPlaceholder title="Update Me" />} />
|
||||||
|
|
||||||
|
{/* Core APIs */}
|
||||||
|
<Route path="/core/send-email" element={<SendEmail />} />
|
||||||
|
<Route path="/core/invoke-llm" element={<InvokeLLM />} />
|
||||||
|
<Route path="/core/upload-file" element={<UploadFile />} />
|
||||||
|
<Route path="/core/upload-private-file" element={<UploadPrivateFile />} />
|
||||||
|
<Route path="/core/create-signed-url" element={<CreateSignedUrl />} />
|
||||||
|
<Route path="/core/extract-data" element={<ApiPlaceholder title="Extract Data from File" />} />
|
||||||
|
<Route path="/core/generate-image" element={<GenerateImage />} />
|
||||||
|
|
||||||
|
{/* Entity APIs */}
|
||||||
|
<Route path="/entities" element={<EntityTester />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
22
internal-api-harness/src/api/client.js
Normal file
22
internal-api-harness/src/api/client.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { auth } from "../firebase";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL, // You will need to add this to your .env file
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
const user = auth.currentUser;
|
||||||
|
if (user) {
|
||||||
|
const token = await user.getIdToken();
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
147
internal-api-harness/src/api/krowSDK.js
Normal file
147
internal-api-harness/src/api/krowSDK.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import { auth } from '../firebase';
|
||||||
|
import { signOut } from 'firebase/auth';
|
||||||
|
|
||||||
|
// --- Auth Module ---
|
||||||
|
const authModule = {
|
||||||
|
/**
|
||||||
|
* Fetches the currently authenticated user's profile from the backend.
|
||||||
|
* @returns {Promise<object>} The user profile.
|
||||||
|
*/
|
||||||
|
me: async () => {
|
||||||
|
const { data } = await apiClient.get('/auth/me');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the user out.
|
||||||
|
* @param {string} [redirectUrl] - Optional URL to redirect to after logout.
|
||||||
|
*/
|
||||||
|
logout: async (redirectUrl) => {
|
||||||
|
await signOut(auth);
|
||||||
|
if (redirectUrl) {
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user is currently authenticated.
|
||||||
|
* @returns {boolean} True if a user is authenticated.
|
||||||
|
*/
|
||||||
|
isAuthenticated: () => {
|
||||||
|
return !!auth.currentUser;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Core Integrations Module ---
|
||||||
|
const coreIntegrationsModule = {
|
||||||
|
/**
|
||||||
|
* Sends an email.
|
||||||
|
* @param {object} params - { to, subject, body }
|
||||||
|
* @returns {Promise<object>} API response.
|
||||||
|
*/
|
||||||
|
SendEmail: async (params) => {
|
||||||
|
const { data } = await apiClient.post('/sendEmail', params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes a large language model.
|
||||||
|
* @param {object} params - { prompt, response_json_schema, file_urls }
|
||||||
|
* @returns {Promise<object>} API response.
|
||||||
|
*/
|
||||||
|
InvokeLLM: async (params) => {
|
||||||
|
const { data } = await apiClient.post('/invokeLLM', params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a public file.
|
||||||
|
* @param {File} file - The file to upload.
|
||||||
|
* @returns {Promise<object>} API response with file_url.
|
||||||
|
*/
|
||||||
|
UploadFile: async ({ file }) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const { data } = await apiClient.post('/uploadFile', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a private file.
|
||||||
|
* @param {File} file - The file to upload.
|
||||||
|
* @returns {Promise<object>} API response with file_uri.
|
||||||
|
*/
|
||||||
|
UploadPrivateFile: async ({ file }) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const { data } = await apiClient.post('/uploadPrivateFile', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a temporary signed URL for a private file.
|
||||||
|
* @param {object} params - { file_uri, expires_in }
|
||||||
|
* @returns {Promise<object>} API response with signed_url.
|
||||||
|
*/
|
||||||
|
CreateFileSignedUrl: async (params) => {
|
||||||
|
const { data } = await apiClient.post('/createSignedUrl', params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Entities Module ---
|
||||||
|
// Based on docs/07-reference-base44-api-export.md
|
||||||
|
const entityNames = [
|
||||||
|
"User", "Event", "Staff", "Vendor", "VendorRate", "Invoice", "Business",
|
||||||
|
"Certification", "Team", "Conversation", "Message", "ActivityLog",
|
||||||
|
"Enterprise", "Sector", "Partner", "Order", "Shift"
|
||||||
|
];
|
||||||
|
|
||||||
|
const entitiesModule = {};
|
||||||
|
|
||||||
|
entityNames.forEach(entityName => {
|
||||||
|
// This factory creates a standard set of CRUD-like methods for each entity.
|
||||||
|
// It assumes a consistent RESTful endpoint structure: /entities/{EntityName}/{method}
|
||||||
|
entitiesModule[entityName] = {
|
||||||
|
get: async (params) => {
|
||||||
|
const { data } = await apiClient.get(`/entities/${entityName}/get`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
create: async (params) => {
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/create`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
update: async (params) => {
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/update`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
delete: async (params) => {
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/delete`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
filter: async (params) => {
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/filter`, params);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
list: async () => {
|
||||||
|
// Assuming a 'filter' with no params can act as 'list'
|
||||||
|
const { data } = await apiClient.post(`/entities/${entityName}/filter`, {});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Main SDK Export ---
|
||||||
|
export const krowSDK = {
|
||||||
|
auth: authModule,
|
||||||
|
integrations: {
|
||||||
|
Core: coreIntegrationsModule,
|
||||||
|
},
|
||||||
|
entities: entitiesModule,
|
||||||
|
};
|
||||||
1
internal-api-harness/src/assets/react.svg
Normal file
1
internal-api-harness/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
38
internal-api-harness/src/components/ApiResponse.jsx
Normal file
38
internal-api-harness/src/components/ApiResponse.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const ApiResponse = ({ response, error, loading }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-2">Response</h3>
|
||||||
|
<div className="bg-slate-100 border border-slate-200 rounded-lg p-4">
|
||||||
|
<p className="text-slate-500">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 mb-2">Error</h3>
|
||||||
|
<pre className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-4 text-sm whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(error, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-2">Response</h3>
|
||||||
|
<pre className="bg-slate-100 border border-slate-200 rounded-lg p-4 text-sm whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(response, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiResponse;
|
||||||
125
internal-api-harness/src/components/Layout.jsx
Normal file
125
internal-api-harness/src/components/Layout.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Link, Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { useAuthState } from "react-firebase-hooks/auth";
|
||||||
|
import { auth } from "../firebase";
|
||||||
|
import { krowSDK } from "@/api/krowSDK";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const navLinks = {
|
||||||
|
auth: [
|
||||||
|
{ path: "/auth/me", title: "Get Me" },
|
||||||
|
{ path: "/auth/update-me", title: "Update Me" },
|
||||||
|
],
|
||||||
|
core: [
|
||||||
|
{ path: "/core/send-email", title: "Send Email" },
|
||||||
|
{ path: "/core/invoke-llm", title: "Invoke LLM" },
|
||||||
|
{ path: "/core/upload-file", title: "Upload File" },
|
||||||
|
{ path: "/core/upload-private-file", title: "Upload Private File" },
|
||||||
|
{ path: "/core/create-signed-url", title: "Create Signed URL" },
|
||||||
|
{ path: "/core/extract-data", title: "Extract Data from File" },
|
||||||
|
{ path: "/core/generate-image", title: "Generate Image" },
|
||||||
|
],
|
||||||
|
entities: [
|
||||||
|
{ path: "/entities", title: "Entity API Tester" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const NavSection = ({ title, links }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
|
const navLinkClasses = (path) =>
|
||||||
|
`flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
location.pathname === path
|
||||||
|
? "bg-slate-200 text-slate-900"
|
||||||
|
: "text-slate-600 hover:bg-slate-100"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<CollapsibleTrigger className="w-full">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-1">
|
||||||
|
{links.map((api) => (
|
||||||
|
<Link key={api.path} to={api.path} className={navLinkClasses(api.path)}>
|
||||||
|
{api.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
const [user, loading] = useAuthState(auth);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div>Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
krowSDK.auth.logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-slate-50">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-72 bg-white border-r border-slate-200 flex flex-col">
|
||||||
|
<div className="p-6 border-b border-slate-200">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<img src="/logo.svg" alt="Krow Logo" className="h-12 w-12" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900">KROW</h1>
|
||||||
|
<p className="text-xs text-slate-500">API Test Harness ({import.meta.env.VITE_HARNESS_ENVIRONMENT})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
|
<NavSection title="Auth" links={navLinks.auth} />
|
||||||
|
<NavSection title="Core Integrations" links={navLinks.core} />
|
||||||
|
<NavSection title="Entities" links={navLinks.entities} />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-200">
|
||||||
|
<div className="text-sm text-slate-600 truncate mb-2" title={user.email}>{user.email}</div>
|
||||||
|
<Button onClick={handleLogout} className="w-full">
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
120
internal-api-harness/src/components/ServiceTester.jsx
Normal file
120
internal-api-harness/src/components/ServiceTester.jsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import apiClient from "../api/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const ServiceTester = ({ serviceName, serviceDescription, endpoint, fields }) => {
|
||||||
|
const [formData, setFormData] = useState({});
|
||||||
|
const [response, setResponse] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value, files } = e.target;
|
||||||
|
if (files) {
|
||||||
|
setFormData({ ...formData, [name]: files[0] });
|
||||||
|
} else {
|
||||||
|
setFormData({ ...formData, [name]: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResponse(null);
|
||||||
|
|
||||||
|
const data = new FormData();
|
||||||
|
for (const key in formData) {
|
||||||
|
data.append(key, formData[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(endpoint, data);
|
||||||
|
setResponse(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data || err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">{serviceName}</h1>
|
||||||
|
<p className="text-slate-600 mt-1">{serviceDescription}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Parameters</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Fill in the required parameters to execute the service.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.name} className="grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label htmlFor={field.name}>{field.label}</Label>
|
||||||
|
{field.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
type={field.type}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Executing..." : "Execute"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{response && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-2">Response</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<pre className="text-sm overflow-x-auto bg-slate-900 text-white p-4 rounded-md">
|
||||||
|
{JSON.stringify(response, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-bold text-red-600 mb-2">Error</h2>
|
||||||
|
<Card className="border-red-500">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<pre className="text-sm overflow-x-auto bg-red-50 text-red-800 p-4 rounded-md">
|
||||||
|
{JSON.stringify(error, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceTester;
|
||||||
48
internal-api-harness/src/components/ui/button.jsx
Normal file
48
internal-api-harness/src/components/ui/button.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
50
internal-api-harness/src/components/ui/card.jsx
Normal file
50
internal-api-harness/src/components/ui/card.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
11
internal-api-harness/src/components/ui/collapsible.jsx
Normal file
11
internal-api-harness/src/components/ui/collapsible.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
19
internal-api-harness/src/components/ui/input.jsx
Normal file
19
internal-api-harness/src/components/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
16
internal-api-harness/src/components/ui/label.jsx
Normal file
16
internal-api-harness/src/components/ui/label.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
105
internal-api-harness/src/components/ui/select.jsx
Normal file
105
internal-api-harness/src/components/ui/select.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
}
|
||||||
18
internal-api-harness/src/components/ui/textarea.jsx
Normal file
18
internal-api-harness/src/components/ui/textarea.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
17
internal-api-harness/src/firebase.js
Normal file
17
internal-api-harness/src/firebase.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Import the functions you need from the SDKs you need
|
||||||
|
import { initializeApp } from "firebase/app";
|
||||||
|
import { getAuth } from "firebase/auth";
|
||||||
|
|
||||||
|
// Your web app's Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: import.meta.env.VITE_HARNESS_FIREBASE_API_KEY,
|
||||||
|
authDomain: import.meta.env.VITE_HARNESS_FIREBASE_AUTH_DOMAIN,
|
||||||
|
projectId: import.meta.env.VITE_HARNESS_FIREBASE_PROJECT_ID,
|
||||||
|
storageBucket: import.meta.env.VITE_HARNESS_FIREBASE_STORAGE_BUCKET,
|
||||||
|
messagingSenderId: import.meta.env.VITE_HARNESS_FIREBASE_MESSAGING_SENDER_ID,
|
||||||
|
appId: import.meta.env.VITE_HARNESS_FIREBASE_APP_ID
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
export const auth = getAuth(app);
|
||||||
76
internal-api-harness/src/index.css
Normal file
76
internal-api-harness/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal-api-harness/src/lib/utils.js
Normal file
6
internal-api-harness/src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user