Fixed some bugs with menus and updated the build-nocodb to migrate data.

This commit is contained in:
admin 2025-09-10 12:33:55 -06:00
parent e3611c8300
commit 56b1600c37
14 changed files with 1276 additions and 180 deletions

View File

@ -143,6 +143,11 @@
./build-nocodb.sh
```
**For migrating from an existing NocoDB base:**
```bash
./build-nocodb.sh --migrate-data
```
This creates six tables:
- **Locations** - Main map data with geo-location, contact info, support levels
- **Login** - User authentication (email, name, admin flag)
@ -151,6 +156,49 @@
- **Shift Signups** - User shift registrations
- **Cuts** - Geographic polygon overlays for map regions
### Data Migration Options
The build script supports data migration from existing NocoDB bases:
**Interactive Mode (Default):**
```bash
./build-nocodb.sh
```
- Prompts you to choose between fresh installation or data migration
- Automatically detects current base from .env file
- Provides guided setup with clear options
**Fresh Installation:**
- Creates new base with sample data
- Sets up default admin user (admin@thebunkerops.ca / admin123)
- Configures default settings
**Migration from Existing Base:**
```bash
./build-nocodb.sh --migrate-data # Skip prompt, go direct to migration
```
- Lists all available bases in your NocoDB instance
- Highlights current base from .env file for easy selection
- Allows you to select source base for migration
- Choose specific tables to migrate (locations, login, settings, etc.)
- Filters out auto-generated columns to prevent conflicts
- Preserves your existing data while updating to new schema
- Original base remains unchanged as backup
**Migration Process:**
1. Script displays available bases with IDs and descriptions
2. Select source base by entering the corresponding number
3. Choose tables to migrate (comma-separated numbers or 'all')
4. Data is exported from source and imported to new base
5. .env file automatically updated with new URLs
**Important Migration Notes:**
- ✅ Original data remains untouched (creates new base)
- ✅ Auto-generates new IDs to prevent conflicts
- ✅ Validates table structure compatibility
- ⚠️ Review migrated data before using in production
- ⚠️ Existing admin passwords may need to be reset
4. **Get Table URLs**
After the script completes:

View File

@ -56,7 +56,7 @@ class UsersController {
async create(req, res) {
try {
const { email, password, name, isAdmin, userType, expireDays } = req.body;
const { email, password, name, phone, isAdmin, userType, expireDays } = req.body;
if (!email || !password) {
return res.status(400).json({
@ -98,6 +98,8 @@ class UsersController {
password: password,
Name: name || '',
name: name || '',
Phone: phone || '',
phone: phone || '',
Admin: isAdmin === true,
admin: isAdmin === true,
'User Type': userType || 'user', // Handle space in field name
@ -121,6 +123,7 @@ class UsersController {
ID: extractId(response),
Email: email,
Name: name,
Phone: phone,
Admin: isAdmin,
'User Type': userType, // Handle space in field name
UserType: userType,
@ -157,6 +160,7 @@ class UsersController {
id: extractId(response),
email: email,
name: name,
phone: phone,
admin: isAdmin,
userType: userType,
expiresAt: expiresAt

View File

@ -23,10 +23,12 @@
<div id="app">
<!-- Header -->
<header class="header">
<button id="mobile-menu-toggle" class="mobile-menu-toggle">
<button id="mobile-menu-toggle" class="mobile-menu-toggle" aria-label="Toggle menu">
<span class="hamburger-icon">
<span></span>
<span></span>
<span></span>
</span>
</button>
<h1>Admin Panel</h1>
<div class="header-actions">
@ -590,6 +592,10 @@
<label for="user-name">Name</label>
<input type="text" id="user-name" required>
</div>
<div class="form-group">
<label for="user-phone">Phone Number</label>
<input type="tel" id="user-phone" placeholder="+1 (555) 123-4567">
</div>
<div class="form-group">
<label for="user-password">Password</label>
<input type="password" id="user-password" required>

View File

@ -298,6 +298,22 @@
color: var(--secondary-color);
}
/* Volunteer Names Display */
.volunteer-names {
font-size: 0.85em;
color: var(--primary-color);
font-weight: normal;
opacity: 0.8;
font-style: italic;
}
.volunteer-count {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* Shift Status Colors */
.status-open {
color: var(--success-color);

View File

@ -154,35 +154,90 @@
/* Mobile Menu Components */
.mobile-menu-toggle {
display: none;
background: none;
border: none;
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: 4px;
padding: 8px;
cursor: pointer;
position: relative;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
z-index: 10002;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
flex-shrink: 0;
min-width: 44px;
min-height: 44px;
-webkit-appearance: none;
appearance: none;
outline: none;
isolation: isolate;
transition: all 0.3s ease;
}
.mobile-menu-toggle span {
display: block;
/* Hamburger Icon */
.hamburger-icon {
display: flex;
flex-direction: column;
justify-content: space-around;
width: 24px;
height: 20px;
position: relative;
}
.hamburger-icon span {
display: block;
height: 3px;
background: white;
margin: 5px auto;
transition: var(--transition);
width: 100%;
background-color: white;
border-radius: 2px;
transition: all 0.3s ease;
}
.mobile-menu-toggle.active span:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
/* Active state animation */
.mobile-menu-toggle.active .hamburger-icon span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.mobile-menu-toggle.active span:nth-child(2) {
.mobile-menu-toggle.active .hamburger-icon span:nth-child(2) {
opacity: 0;
}
.mobile-menu-toggle.active span:nth-child(3) {
transform: rotate(-45deg) translate(7px, -6px);
.mobile-menu-toggle.active .hamburger-icon span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
/* Hover effect */
.mobile-menu-toggle:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: white;
}
/* Ensure button is visible on mobile */
@media (max-width: 768px) {
.mobile-menu-toggle {
display: flex !important;
align-items: center;
justify-content: center;
margin-right: 10px;
}
/* Make sure header has proper layout on mobile */
.header {
display: flex;
align-items: center;
position: relative;
z-index: 10001;
padding: 10px 15px;
}
.header h1 {
margin-left: 10px;
font-size: 1.5rem;
}
}
/* Sidebar Header & Footer (mobile) */

View File

@ -50,7 +50,51 @@
@media (max-width: 768px) {
/* Show mobile menu toggle */
.mobile-menu-toggle {
display: block;
display: flex !important;
}
/* Sidebar as overlay */
.admin-sidebar {
position: fixed !important;
top: var(--header-height, 60px);
left: -300px; /* Changed from -100% to fixed value that's larger than width */
width: 280px !important;
max-width: 80vw !important;
min-width: 250px !important;
height: calc(100vh - var(--header-height, 60px));
height: calc(var(--app-height, 100vh) - var(--header-height, 60px));
z-index: 10000;
transition: left 0.3s ease;
box-shadow: 2px 0 10px rgba(0,0,0,0.1);
overflow-y: auto;
transform: translateX(0); /* Ensure no transform issues */
}
.admin-sidebar.active {
left: 0 !important;
transform: translateX(0); /* Ensure it's fully visible */
}
/* Show mobile sidebar elements */
.sidebar-header {
display: flex !important;
}
.sidebar-footer {
display: block !important;
}
/* Prevent body scroll when sidebar is open */
body.sidebar-open {
overflow: hidden;
position: fixed;
width: 100%;
}
/* Admin content takes full width */
.admin-content {
width: 100%;
margin-left: 0;
}
/* Header adjustments */
@ -84,7 +128,9 @@
height: calc(var(--app-height) - 50px);
}
/* Sidebar as overlay */
/* Remove duplicate sidebar styles - keep only the first one above */
/* DELETE or comment out this duplicate block: */
/*
.admin-sidebar {
position: fixed;
top: 0;
@ -104,6 +150,7 @@
.admin-sidebar.active {
left: 0;
}
*/
/* Show sidebar header and footer on mobile */
.sidebar-header {
@ -272,6 +319,19 @@
justify-content: flex-end;
}
/* Volunteer names mobile styling */
.volunteer-count {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.volunteer-names {
font-size: 0.8em;
line-height: 1.2;
word-break: break-word;
}
/* Walk sheet container mobile */
.walk-sheet-container {
display: flex !important;
@ -363,7 +423,23 @@
width: 100%;
}
.user-actions .btn {
.user-communication-actions {
justify-content: center;
gap: 8px;
}
.user-communication-actions .btn {
min-width: 40px;
padding: 8px 12px;
font-size: 16px;
}
.user-admin-actions {
flex-direction: column;
gap: 6px;
}
.user-admin-actions .btn {
font-size: var(--font-size-xs);
padding: 8px 10px;
width: 100%;
@ -399,6 +475,24 @@
.volunteer-actions {
align-self: flex-end;
gap: 6px;
}
.volunteer-communication-actions {
justify-content: center;
gap: 8px;
margin-bottom: 6px;
}
.volunteer-communication-actions .btn {
min-width: 40px;
padding: 8px 12px;
font-size: 16px;
}
.volunteer-admin-actions {
flex-direction: column;
gap: 6px;
}
.processing-actions {
@ -409,11 +503,15 @@
/* Very Small Screens (under 480px) */
@media (max-width: 480px) {
.admin-sidebar {
width: 260px;
left: -260px;
width: 260px !important; /* Added !important to override */
left: -280px !important; /* Increased to ensure complete hiding */
padding: 12px;
}
.admin-sidebar.active {
left: 0 !important;
}
.admin-nav {
gap: 6px;
margin: 15px 0;
@ -490,8 +588,12 @@
/* Ultra Small Screens (under 360px) */
@media (max-width: 360px) {
.admin-sidebar {
width: 240px;
left: -240px;
width: 240px !important; /* Added !important */
left: -260px !important; /* Increased to ensure complete hiding */
}
.admin-sidebar.active {
left: 0 !important;
}
.admin-nav a {
@ -681,11 +783,23 @@
.user-actions {
justify-content: flex-end;
margin-top: 8px;
gap: 6px;
}
.user-actions .btn {
.user-communication-actions {
margin-bottom: 6px;
}
.user-communication-actions .btn {
min-width: 32px;
padding: 6px 8px;
}
.user-admin-actions .btn {
width: auto !important;
min-width: 80px;
flex: none;
font-size: 10px;
padding: 6px 8px;
}
}

View File

@ -130,9 +130,22 @@
/* User Actions */
.user-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-communication-actions {
display: flex;
gap: 4px;
justify-content: center;
}
.user-admin-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.user-actions .btn {
padding: 6px 12px;
font-size: var(--font-size-xs);
@ -140,6 +153,55 @@
font-weight: 500;
}
.user-communication-actions .btn {
padding: 4px 8px;
font-size: 14px;
min-width: 32px;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.user-communication-actions .btn-outline-primary {
color: var(--primary-color);
border: 1px solid var(--primary-color);
background: white;
}
.user-communication-actions .btn-outline-primary:hover {
background: var(--primary-color);
color: white;
}
.user-communication-actions .btn-outline-secondary {
color: #6c757d;
border: 1px solid #6c757d;
background: white;
}
.user-communication-actions .btn-outline-secondary:hover:not(.disabled) {
background: #6c757d;
color: white;
}
.user-communication-actions .btn-outline-success {
color: var(--success-color);
border: 1px solid var(--success-color);
background: white;
}
.user-communication-actions .btn-outline-success:hover:not(.disabled) {
background: var(--success-color);
color: white;
}
.user-communication-actions .btn.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Users List Header */
.users-list-header {
display: flex;
@ -191,9 +253,71 @@
.volunteer-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.volunteer-communication-actions {
display: flex;
gap: 4px;
justify-content: center;
}
.volunteer-admin-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.volunteer-communication-actions .btn {
padding: 4px 8px;
font-size: 14px;
min-width: 32px;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.volunteer-communication-actions .btn-outline-primary {
color: var(--primary-color);
border: 1px solid var(--primary-color);
background: white;
}
.volunteer-communication-actions .btn-outline-primary:hover {
background: var(--primary-color);
color: white;
}
.volunteer-communication-actions .btn-outline-secondary {
color: #6c757d;
border: 1px solid #6c757d;
background: white;
}
.volunteer-communication-actions .btn-outline-secondary:hover:not(.disabled) {
background: #6c757d;
color: white;
}
.volunteer-communication-actions .btn-outline-success {
color: var(--success-color);
border: 1px solid var(--success-color);
background: white;
}
.volunteer-communication-actions .btn-outline-success:hover:not(.disabled) {
background: var(--success-color);
color: white;
}
.volunteer-communication-actions .btn.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.no-volunteers {
text-align: center;
color: #666;

View File

@ -43,49 +43,127 @@ function setAdminViewportDimensions() {
// Add mobile menu functionality
function setupMobileMenu() {
console.log('🔧 Setting up mobile menu...');
const menuToggle = document.getElementById('mobile-menu-toggle');
const sidebar = document.getElementById('admin-sidebar');
const closeSidebar = document.getElementById('close-sidebar');
const adminNavLinks = document.querySelectorAll('.admin-nav a');
if (menuToggle && sidebar) {
// Toggle menu
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('active');
menuToggle.classList.toggle('active');
document.body.classList.toggle('sidebar-open');
console.log('📱 Mobile menu elements found:', {
menuToggle: !!menuToggle,
sidebar: !!sidebar,
closeSidebar: !!closeSidebar,
adminNavLinks: adminNavLinks.length
});
if (menuToggle && sidebar) {
console.log('✅ Setting up mobile menu event listeners...');
// Remove any existing listeners to prevent duplicates
const newMenuToggle = menuToggle.cloneNode(true);
menuToggle.parentNode.replaceChild(newMenuToggle, menuToggle);
// Toggle menu function
const toggleMobileMenu = (e) => {
console.log('🔄 Mobile menu toggle triggered!', e.type);
e.preventDefault();
e.stopPropagation();
const sidebar = document.getElementById('admin-sidebar');
const menuToggle = document.getElementById('mobile-menu-toggle');
if (!sidebar || !menuToggle) {
console.error('❌ Sidebar or menu toggle not found during toggle');
return;
}
const isActive = sidebar.classList.contains('active');
console.log('📱 Current menu state:', isActive ? 'open' : 'closed');
if (isActive) {
sidebar.classList.remove('active');
menuToggle.classList.remove('active');
document.body.classList.remove('sidebar-open');
console.log('✅ Menu closed');
} else {
sidebar.classList.add('active');
menuToggle.classList.add('active');
document.body.classList.add('sidebar-open');
console.log('✅ Menu opened');
}
};
// Use pointer events for better mobile support
const toggleButton = document.getElementById('mobile-menu-toggle');
// Add click event for all devices
toggleButton.addEventListener('click', toggleMobileMenu, { passive: false });
// Add pointer events for better mobile support
toggleButton.addEventListener('pointerdown', (e) => {
// Visual feedback
e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
}, { passive: true });
toggleButton.addEventListener('pointerup', (e) => {
// Remove visual feedback
e.currentTarget.style.backgroundColor = '';
}, { passive: true });
// Close sidebar button
if (closeSidebar) {
closeSidebar.addEventListener('click', () => {
const sidebar = document.getElementById('admin-sidebar');
const menuToggle = document.getElementById('mobile-menu-toggle');
if (sidebar && menuToggle) {
sidebar.classList.remove('active');
menuToggle.classList.remove('active');
document.body.classList.remove('sidebar-open');
console.log('✅ Sidebar closed via close button');
}
});
}
// Close sidebar when clicking outside
document.addEventListener('click', (e) => {
if (sidebar.classList.contains('active') &&
const sidebar = document.getElementById('admin-sidebar');
const menuToggle = document.getElementById('mobile-menu-toggle');
if (sidebar && menuToggle && sidebar.classList.contains('active') &&
!sidebar.contains(e.target) &&
!menuToggle.contains(e.target)) {
sidebar.classList.remove('active');
menuToggle.classList.remove('active');
document.body.classList.remove('sidebar-open');
console.log('✅ Sidebar closed by clicking outside');
}
});
// Close sidebar when navigation link is clicked on mobile
adminNavLinks.forEach(link => {
const navLinks = document.querySelectorAll('.admin-nav a');
navLinks.forEach(link => {
link.addEventListener('click', () => {
if (window.innerWidth <= 768) {
const sidebar = document.getElementById('admin-sidebar');
const menuToggle = document.getElementById('mobile-menu-toggle');
if (sidebar && menuToggle) {
sidebar.classList.remove('active');
menuToggle.classList.remove('active');
document.body.classList.remove('sidebar-open');
console.log('✅ Sidebar closed after navigation');
}
}
});
});
console.log('✅ Mobile menu setup complete!');
} else {
console.error('❌ Mobile menu elements not found:', {
menuToggle: !!menuToggle,
sidebar: !!sidebar
});
}
}
@ -371,29 +449,42 @@ async function loadDashboardDataFromDashboardModule() {
// Initialize the admin core when DOM is loaded
function initializeAdminCore() {
console.log('🚀 Initializing Admin Core...');
// Set initial viewport dimensions and listen for resize events
setAdminViewportDimensions();
window.addEventListener('resize', setAdminViewportDimensions);
window.addEventListener('orientationchange', () => {
// Add a small delay for orientation change to complete
setTimeout(setAdminViewportDimensions, 100);
});
// Setup navigation first
setupNavigation();
// Setup mobile menu with a small delay to ensure DOM is ready
setTimeout(() => {
setupMobileMenu();
}, 100);
// Check if URL has a hash to show specific section
const hash = window.location.hash;
if (hash === '#walk-sheet') {
console.log('Direct navigation to walk-sheet section');
showSection('walk-sheet');
} else if (hash === '#convert-data') {
showSection('convert-data');
} else if (hash === '#cuts') {
showSection('cuts');
} else {
// Default to dashboard
showSection('dashboard');
} else if (hash) {
const sectionId = hash.substring(1);
showSection(sectionId);
}
console.log('✅ Admin Core initialized');
}
// Make sure we wait for DOM to be fully loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeAdminCore);
} else {
// DOM is already loaded
initializeAdminCore();
}
// Export functions for use by other modules

View File

@ -69,21 +69,13 @@ function populateUserSelect() {
async function showShiftUserModal(shiftId, shiftData) {
currentShiftData = { ...shiftData, ID: shiftId };
// Update modal title and info
const modalTitle = document.getElementById('modal-shift-title');
const modalDetails = document.getElementById('modal-shift-details');
if (modalTitle) modalTitle.textContent = shiftData.Title;
if (modalDetails) {
const shiftDate = safeAdminCore('createLocalDate', shiftData.Date) || new Date(shiftData.Date);
modalDetails.textContent =
`${shiftDate.toLocaleDateString()} | ${shiftData['Start Time']} - ${shiftData['End Time']} | ${shiftData.Location || 'TBD'}`;
}
// Update modal title and info using the new function
updateModalTitle();
// Load users if not already loaded
if (allUsers.length === 0) {
await loadAllUsers();
populateUserSelect();
}
// Display current volunteers
@ -114,6 +106,26 @@ function displayCurrentVolunteers(volunteers) {
<div class="volunteer-email">${safeAdminCore('escapeHtml', volunteer['User Email']) || volunteer['User Email'] || ''}</div>
</div>
<div class="volunteer-actions">
<div class="volunteer-communication-actions">
<a href="mailto:${safeAdminCore('escapeHtml', volunteer['User Email']) || volunteer['User Email'] || ''}"
class="btn btn-sm btn-outline-primary"
title="Email ${safeAdminCore('escapeHtml', volunteer['User Name'] || volunteer['User Email'] || 'Volunteer') || 'Volunteer'}">
📧
</a>
<button class="btn btn-sm btn-outline-secondary sms-volunteer-btn"
data-volunteer-email="${volunteer['User Email']}"
data-volunteer-name="${safeAdminCore('escapeHtml', volunteer['User Name'] || volunteer['User Email'] || 'Volunteer') || 'Volunteer'}"
title="Text ${safeAdminCore('escapeHtml', volunteer['User Name'] || volunteer['User Email'] || 'Volunteer') || 'Volunteer'} (Phone lookup required)">
💬
</button>
<button class="btn btn-sm btn-outline-success call-volunteer-btn"
data-volunteer-email="${volunteer['User Email']}"
data-volunteer-name="${safeAdminCore('escapeHtml', volunteer['User Name'] || volunteer['User Email'] || 'Volunteer') || 'Volunteer'}"
title="Call ${safeAdminCore('escapeHtml', volunteer['User Name'] || volunteer['User Email'] || 'Volunteer') || 'Volunteer'} (Phone lookup required)">
📞
</button>
</div>
<div class="volunteer-admin-actions">
<button class="btn btn-danger btn-sm remove-volunteer-btn"
data-volunteer-id="${volunteer.ID || volunteer.id}"
data-volunteer-email="${volunteer['User Email']}">
@ -121,6 +133,7 @@ function displayCurrentVolunteers(volunteers) {
</button>
</div>
</div>
</div>
`).join('');
// Add event listeners for remove buttons
@ -137,6 +150,14 @@ function setupVolunteerActionListeners() {
const volunteerId = e.target.getAttribute('data-volunteer-id');
const volunteerEmail = e.target.getAttribute('data-volunteer-email');
removeVolunteerFromShift(volunteerId, volunteerEmail);
} else if (e.target.classList.contains('sms-volunteer-btn')) {
const volunteerEmail = e.target.getAttribute('data-volunteer-email');
const volunteerName = e.target.getAttribute('data-volunteer-name');
openVolunteerSMS(volunteerEmail, volunteerName);
} else if (e.target.classList.contains('call-volunteer-btn')) {
const volunteerEmail = e.target.getAttribute('data-volunteer-email');
const volunteerName = e.target.getAttribute('data-volunteer-name');
callVolunteer(volunteerEmail, volunteerName);
}
});
}
@ -177,6 +198,9 @@ async function addUserToShift() {
try {
await refreshCurrentShiftData();
console.log('Refreshed shift data after adding user');
// Also update the modal title to reflect new volunteer count
updateModalTitle();
} catch (refreshError) {
console.error('Error during refresh after adding user:', refreshError);
// Still show success since the add operation worked
@ -216,6 +240,9 @@ async function removeVolunteerFromShift(volunteerId, volunteerEmail) {
try {
await refreshCurrentShiftData();
console.log('Refreshed shift data after removing volunteer');
// Also update the modal title to reflect new volunteer count
updateModalTitle();
} catch (refreshError) {
console.error('Error during refresh after removing volunteer:', refreshError);
// Still show success since the remove operation worked
@ -272,13 +299,27 @@ function updateShiftInList(updatedShift) {
if (shiftItem) {
const signupCount = updatedShift.signups ? updatedShift.signups.length : 0;
// Generate list of first names for volunteers (same logic as displayAdminShifts)
const firstNames = updatedShift.signups ? updatedShift.signups.map(volunteer => {
const fullName = volunteer['User Name'] || volunteer['User Email'] || 'Unknown';
// Extract first name (everything before first space, or email username if no space)
const firstName = fullName.includes(' ') ? fullName.split(' ')[0] :
fullName.includes('@') ? fullName.split('@')[0] : fullName;
return safeAdminCore('escapeHtml', firstName) || firstName;
}).slice(0, 8) : []; // Limit to first 8 names to avoid overflow
const namesDisplay = firstNames.length > 0 ?
`<span class="volunteer-names">(${firstNames.join(', ')}${firstNames.length === 8 && signupCount > 8 ? '...' : ''})</span>` :
'';
// Find the volunteer count paragraph (contains 👥)
const volunteerCountElement = Array.from(shiftItem.querySelectorAll('p')).find(p =>
p.textContent.includes('👥')
p.textContent.includes('👥') || p.classList.contains('volunteer-count')
);
if (volunteerCountElement) {
volunteerCountElement.textContent = `👥 ${signupCount}/${updatedShift['Max Volunteers']} volunteers`;
volunteerCountElement.innerHTML = `👥 ${signupCount}/${updatedShift['Max Volunteers']} volunteers ${namesDisplay}`;
volunteerCountElement.className = 'volunteer-count'; // Ensure class is set
}
// Update the data attribute with new shift data
@ -290,6 +331,32 @@ function updateShiftInList(updatedShift) {
}
}
// Update modal title with current volunteer count
function updateModalTitle() {
if (!currentShiftData) return;
const modalTitle = document.getElementById('modal-shift-title');
const modalDetails = document.getElementById('modal-shift-details');
if (modalTitle) {
const signupCount = currentShiftData.signups ? currentShiftData.signups.length : 0;
modalTitle.textContent = `Manage Volunteers - ${currentShiftData.Title} (${signupCount}/${currentShiftData['Max Volunteers']})`;
}
if (modalDetails) {
const shiftDate = safeAdminCore('createLocalDate', currentShiftData.Date);
const dateStr = shiftDate ? shiftDate.toLocaleDateString() : currentShiftData.Date;
const signupCount = currentShiftData.signups ? currentShiftData.signups.length : 0;
modalDetails.innerHTML = `
<p><strong>Date:</strong> ${dateStr}</p>
<p><strong>Time:</strong> ${currentShiftData['Start Time']} - ${currentShiftData['End Time']}</p>
<p><strong>Location:</strong> ${safeAdminCore('escapeHtml', currentShiftData.Location || 'TBD') || currentShiftData.Location || 'TBD'}</p>
<p><strong>Current Signups:</strong> ${signupCount} / ${currentShiftData['Max Volunteers']}</p>
`;
}
}
// Close modal
function closeShiftUserModal() {
const modal = document.getElementById('shift-user-modal');
@ -303,6 +370,61 @@ function closeShiftUserModal() {
console.log('Modal closed - shifts list should already be current');
}
// Communication functions for individual volunteers
async function openVolunteerSMS(volunteerEmail, volunteerName) {
try {
// Look up the volunteer's phone number from the users database
const user = await getUserByEmail(volunteerEmail);
if (user && (user.phone || user.Phone)) {
const phoneNumber = user.phone || user.Phone;
const smsUrl = `sms:${phoneNumber}`;
window.open(smsUrl, '_self');
} else {
safeAdminCore('showStatus', `No phone number found for ${volunteerName}`, 'warning');
}
} catch (error) {
console.error('Error looking up volunteer phone number:', error);
safeAdminCore('showStatus', 'Failed to lookup volunteer phone number', 'error');
}
}
async function callVolunteer(volunteerEmail, volunteerName) {
try {
// Look up the volunteer's phone number from the users database
const user = await getUserByEmail(volunteerEmail);
if (user && (user.phone || user.Phone)) {
const phoneNumber = user.phone || user.Phone;
const telUrl = `tel:${phoneNumber}`;
window.open(telUrl, '_self');
} else {
safeAdminCore('showStatus', `No phone number found for ${volunteerName}`, 'warning');
}
} catch (error) {
console.error('Error looking up volunteer phone number:', error);
safeAdminCore('showStatus', 'Failed to lookup volunteer phone number', 'error');
}
}
// Helper function to get user details by email
async function getUserByEmail(email) {
try {
const response = await fetch('/api/users');
const data = await response.json();
if (data.success && data.users) {
return data.users.find(user =>
(user.email === email || user.Email === email)
);
}
return null;
} catch (error) {
console.error('Error fetching users:', error);
return null;
}
}
// Email shift details to all volunteers
async function emailShiftDetails() {
if (!currentShiftData) {
@ -589,10 +711,15 @@ try {
emailShiftDetails,
setupVolunteerModalEventListeners,
loadAllUsers,
openVolunteerSMS,
callVolunteer,
getUserByEmail,
updateModalTitle,
updateShiftInList,
getCurrentShiftData: () => currentShiftData,
getAllUsers: () => allUsers,
// Add module info for debugging
moduleVersion: '1.0',
moduleVersion: '1.2',
loadedAt: new Date().toISOString()
};

View File

@ -59,13 +59,26 @@ function displayAdminShifts(shifts) {
console.log(`Shift "${shift.Title}" (ID: ${shift.ID}) has ${signupCount} volunteers:`, shift.signups?.map(s => s['User Email']) || []);
// Generate list of first names for volunteers
const firstNames = shift.signups ? shift.signups.map(volunteer => {
const fullName = volunteer['User Name'] || volunteer['User Email'] || 'Unknown';
// Extract first name (everything before first space, or email username if no space)
const firstName = fullName.includes(' ') ? fullName.split(' ')[0] :
fullName.includes('@') ? fullName.split('@')[0] : fullName;
return window.adminCore.escapeHtml(firstName);
}).slice(0, 8) : []; // Limit to first 8 names to avoid overflow
const namesDisplay = firstNames.length > 0 ?
`<span class="volunteer-names">(${firstNames.join(', ')}${firstNames.length === 8 && signupCount > 8 ? '...' : ''})</span>` :
'';
return `
<div class="shift-admin-item">
<div class="shift-admin-item" data-shift-id="${shift.ID}">
<div>
<h4>${window.adminCore.escapeHtml(shift.Title)}</h4>
<p>📅 ${shiftDate.toLocaleDateString()} | ${shift['Start Time']} - ${shift['End Time']}</p>
<p>📍 ${window.adminCore.escapeHtml(shift.Location || 'TBD')}</p>
<p>👥 ${signupCount}/${shift['Max Volunteers']} volunteers</p>
<p class="volunteer-count">👥 ${signupCount}/${shift['Max Volunteers']} volunteers ${namesDisplay}</p>
<p class="status-${(shift.Status || 'open').toLowerCase()}">${shift.Status || 'Open'}</p>
<p class="${isPublic ? 'public-shift' : 'private-shift'}">${isPublic ? '🌐 Public' : '🔒 Private'}</p>
${isPublic ? `

View File

@ -72,6 +72,7 @@ function displayUsers(users) {
<tr>
<th>Email</th>
<th>Name</th>
<th>Phone</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
@ -105,6 +106,7 @@ function displayUsers(users) {
<tr ${user.ExpiresAt && new Date(user.ExpiresAt) < new Date() ? 'class="expired"' : (user.ExpiresAt && new Date(user.ExpiresAt) - new Date() < 3 * 24 * 60 * 60 * 1000 ? 'class="expires-soon"' : '')}>
<td data-label="Email">${window.adminCore.escapeHtml(user.email || user.Email || 'N/A')}</td>
<td data-label="Name">${window.adminCore.escapeHtml(user.name || user.Name || 'N/A')}</td>
<td data-label="Phone">${window.adminCore.escapeHtml(user.phone || user.Phone || 'N/A')}</td>
<td data-label="Role">
<span class="user-role ${userType}">
${userType.charAt(0).toUpperCase() + userType.slice(1)}
@ -114,13 +116,32 @@ function displayUsers(users) {
<td data-label="Created">${formattedDate}</td>
<td data-label="Actions">
<div class="user-actions">
<button class="btn btn-secondary send-login-btn" data-user-id="${userId}" data-user-email="${window.adminCore.escapeHtml(user.email || user.Email)}">
<div class="user-communication-actions">
<a href="mailto:${window.adminCore.escapeHtml(user.email || user.Email)}"
class="btn btn-sm btn-outline-primary"
title="Email ${window.adminCore.escapeHtml(user.name || user.Name || 'User')}">
📧
</a>
<a href="sms:${window.adminCore.escapeHtml(user.phone || user.Phone || '')}"
class="btn btn-sm btn-outline-secondary ${!(user.phone || user.Phone) ? 'disabled' : ''}"
title="Text ${window.adminCore.escapeHtml(user.name || user.Name || 'User')}${!(user.phone || user.Phone) ? ' (No phone number)' : ''}">
💬
</a>
<a href="tel:${window.adminCore.escapeHtml(user.phone || user.Phone || '')}"
class="btn btn-sm btn-outline-success ${!(user.phone || user.Phone) ? 'disabled' : ''}"
title="Call ${window.adminCore.escapeHtml(user.name || user.Name || 'User')}${!(user.phone || user.Phone) ? ' (No phone number)' : ''}">
📞
</a>
</div>
<div class="user-admin-actions">
<button class="btn btn-secondary btn-sm send-login-btn" data-user-id="${userId}" data-user-email="${window.adminCore.escapeHtml(user.email || user.Email)}">
Send Login Details
</button>
<button class="btn btn-danger delete-user-btn" data-user-id="${userId}" data-user-email="${window.adminCore.escapeHtml(user.email || user.Email)}">
<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${userId}" data-user-email="${window.adminCore.escapeHtml(user.email || user.Email)}">
Delete
</button>
</div>
</div>
</td>
</tr>
`;
@ -222,6 +243,7 @@ async function createUser(e) {
const emailInput = document.getElementById('user-email');
const passwordInput = document.getElementById('user-password');
const nameInput = document.getElementById('user-name');
const phoneInput = document.getElementById('user-phone');
const userTypeSelect = document.getElementById('user-type');
const expireDaysInput = document.getElementById('user-expire-days');
const adminCheckbox = document.getElementById('user-is-admin');
@ -229,6 +251,7 @@ async function createUser(e) {
const email = emailInput?.value.trim();
const password = passwordInput?.value;
const name = nameInput?.value.trim();
const phone = phoneInput?.value.trim();
const userType = userTypeSelect?.value;
const expireDays = userType === 'temp' ?
parseInt(expireDaysInput?.value) : null;
@ -254,6 +277,7 @@ async function createUser(e) {
email,
password,
name: name || '',
phone: phone || '',
isAdmin: userType === 'admin' || admin,
userType,
expireDays

View File

@ -12,10 +12,19 @@
# 5. shift_signups - Table for tracking signups to shifts with source tracking and phone numbers
# 6. cuts - Table for storing polygon overlays for the map
#
# Updated: August 2025 - Added public shift support, signup source tracking, phone numbers
# Updated: September 2025 - Added data migration option from existing NocoDB bases
# Usage:
# ./build-nocodb.sh # Create new base only
# ./build-nocodb.sh --migrate-data # Create new base with data migration option
# ./build-nocodb.sh --help # Show usage information
set -e # Exit on any error
# Global variables for migration
MIGRATE_DATA=false
SOURCE_BASE_ID=""
SOURCE_TABLE_IDS=""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@ -40,6 +49,62 @@ print_error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
# Function to show usage information
show_usage() {
cat << EOF
NocoDB Auto-Setup Script
USAGE:
$0 [OPTIONS]
OPTIONS:
--migrate-data Skip interactive prompt and enable data migration mode
--help Show this help message
DESCRIPTION:
This script creates a new NocoDB base with the required tables for the Map Viewer application.
Interactive mode (default): Prompts you to choose between fresh installation or data migration.
With --migrate-data option, skips the prompt and goes directly to migration setup, allowing
you to select an existing base and migrate data from specific tables to the new base.
EXAMPLES:
$0 # Interactive mode - choose fresh or migration
$0 --migrate-data # Skip prompt, go directly to migration setup
$0 --help # Show this help
MIGRATION FEATURES:
- Automatically detects current base from .env file settings
- Interactive base and table selection with clear guidance
- Filters out auto-generated columns (CreatedAt, UpdatedAt, etc.)
- Preserves original data (creates new base, doesn't modify existing)
- Progress tracking during import with detailed success/failure reporting
EOF
}
# Parse command line arguments
parse_arguments() {
while [[ $# -gt 0 ]]; do
case $1 in
--migrate-data)
MIGRATE_DATA=true
shift
;;
--help)
show_usage
exit 0
;;
*)
print_error "Unknown option: $1"
show_usage
exit 1
;;
esac
done
}
# Load environment variables
if [ -f ".env" ]; then
# Use set -a to automatically export variables
@ -58,6 +123,33 @@ if [ -z "$NOCODB_API_URL" ] || [ -z "$NOCODB_API_TOKEN" ]; then
exit 1
fi
# Check for required dependencies
check_dependencies() {
local missing_deps=()
# Check for jq (required for JSON parsing in migration)
if ! command -v jq &> /dev/null; then
missing_deps+=("jq")
fi
# Check for curl (should be available but let's verify)
if ! command -v curl &> /dev/null; then
missing_deps+=("curl")
fi
if [[ ${#missing_deps[@]} -gt 0 ]]; then
print_error "Missing required dependencies: ${missing_deps[*]}"
print_error "Please install the missing dependencies before running this script"
print_status "On Ubuntu/Debian: sudo apt-get install ${missing_deps[*]}"
print_status "On CentOS/RHEL: sudo yum install ${missing_deps[*]}"
print_status "On macOS: brew install ${missing_deps[*]}"
exit 1
fi
}
# Check dependencies
check_dependencies
# Extract base URL from API URL and set up v2 API endpoints
BASE_URL=$(echo "$NOCODB_API_URL" | sed 's|/api/v1||')
API_BASE_V1="$NOCODB_API_URL"
@ -205,6 +297,332 @@ test_api_connectivity() {
fi
}
# Function to list all available bases
list_available_bases() {
print_status "Fetching available NocoDB bases..."
local response
response=$(make_api_call "GET" "/meta/bases" "" "Fetching bases list" "v2")
if [[ $? -eq 0 && -n "$response" ]]; then
echo "$response"
return 0
else
print_error "Failed to fetch bases list"
return 1
fi
}
# Function to list tables in a specific base
list_base_tables() {
local base_id=$1
print_status "Fetching tables for base: $base_id"
local response
response=$(make_api_call "GET" "/meta/bases/$base_id/tables" "" "Fetching tables list" "v2")
if [[ $? -eq 0 && -n "$response" ]]; then
echo "$response"
return 0
else
print_error "Failed to fetch tables list for base: $base_id"
return 1
fi
}
# Function to export data from a table
export_table_data() {
local base_id=$1
local table_id=$2
local table_name=$3
local limit=${4:-1000} # Default limit of 1000 records
print_status "Exporting data from table: $table_name (ID: $table_id)"
local response
response=$(make_api_call "GET" "/tables/$table_id/records?limit=$limit" "" "Exporting data from $table_name" "v2")
if [[ $? -eq 0 && -n "$response" ]]; then
echo "$response"
return 0
else
print_error "Failed to export data from table: $table_name"
return 1
fi
}
# Function to import data into a table
import_table_data() {
local base_id=$1
local table_id=$2
local table_name=$3
local data=$4
# Check if data contains records
local record_count=$(echo "$data" | grep -o '"list":\[' | wc -l)
if [[ $record_count -eq 0 ]]; then
print_warning "No records found in source table: $table_name"
return 0
fi
# Extract the records array from the response
local records_array
records_array=$(echo "$data" | jq -r '.list' 2>/dev/null)
if [[ -z "$records_array" || "$records_array" == "[]" || "$records_array" == "null" ]]; then
print_warning "No records to import for table: $table_name"
return 0
fi
print_status "Importing data into table: $table_name (ID: $table_id)"
# Count total records first
local total_records
total_records=$(echo "$records_array" | jq 'length' 2>/dev/null)
print_status "Found $total_records records to import"
local import_count=0
local success_count=0
# Create temporary file to track results across subshell
local temp_file="/tmp/nocodb_import_$$"
echo "0" > "$temp_file"
# Parse records and import them one by one (to handle potential ID conflicts)
echo "$records_array" | jq -c '.[]' 2>/dev/null | while read -r record; do
import_count=$((import_count + 1))
# Remove auto-generated and system columns that can cause conflicts
local cleaned_record
cleaned_record=$(echo "$record" | jq '
del(.Id) |
del(.id) |
del(.ID) |
del(.CreatedAt) |
del(.UpdatedAt) |
del(.created_at) |
del(.updated_at) |
del(.ncRecordId) |
del(.ncRecordHash)
' 2>/dev/null)
if [[ -z "$cleaned_record" || "$cleaned_record" == "{}" || "$cleaned_record" == "null" ]]; then
print_warning "Skipping empty record $import_count in $table_name"
continue
fi
# Use a simpler call without the make_api_call wrapper for better error handling
local response
local http_code
response=$(curl -s -w "%{http_code}" -X "POST" \
-H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
--max-time 30 \
-d "$cleaned_record" \
"$API_BASE_V2/tables/$table_id/records" 2>/dev/null)
http_code="${response: -3}"
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
success_count=$(cat "$temp_file")
success_count=$((success_count + 1))
echo "$success_count" > "$temp_file"
print_status "✓ Imported record $import_count/$total_records"
else
local response_body="${response%???}"
print_warning "✗ Failed to import record $import_count/$total_records: $response_body"
fi
done
# Read final success count
local final_success_count=$(cat "$temp_file" 2>/dev/null || echo "0")
rm -f "$temp_file"
print_success "Data import completed for table: $table_name ($final_success_count/$total_records records imported)"
}
# Function to prompt user for base selection
select_source_base() {
print_status "Fetching available bases for migration..."
local bases_response
bases_response=$(list_available_bases)
if [[ $? -ne 0 ]]; then
print_error "Could not fetch available bases"
return 1
fi
# Parse and display available bases
local bases_info
bases_info=$(echo "$bases_response" | jq -r '.list[] | "\(.id)|\(.title)|\(.description // "No description")"' 2>/dev/null)
if [[ -z "$bases_info" ]]; then
print_warning "No existing bases found for migration"
return 1
fi
# Try to detect current base from .env file
local current_base_id=""
if [[ -n "$NOCODB_VIEW_URL" ]]; then
current_base_id=$(extract_base_id_from_url "$NOCODB_VIEW_URL")
fi
echo ""
print_status "Available bases for data migration:"
print_status "====================================="
local counter=1
local suggested_option=""
echo "$bases_info" | while IFS='|' read -r base_id title description; do
local marker=""
if [[ "$base_id" == "$current_base_id" ]]; then
marker=" ⭐ [CURRENT]"
suggested_option="$counter"
fi
echo " $counter) $title$marker"
echo " ID: $base_id"
echo " Description: $description"
echo ""
counter=$((counter + 1))
done
echo ""
if [[ -n "$current_base_id" ]]; then
print_warning "⭐ Detected current base from .env file (marked above)"
echo -n "Enter the number of the base to migrate from (or 'skip'): "
else
echo -n "Enter the number of the base you want to migrate from (or 'skip'): "
fi
read -r selection
if [[ "$selection" == "skip" ]]; then
print_status "Skipping data migration"
return 1
fi
if ! [[ "$selection" =~ ^[0-9]+$ ]]; then
print_error "Invalid selection. Please enter a number or 'skip'"
return 1
fi
# Get the selected base ID
local selected_base_id
selected_base_id=$(echo "$bases_info" | sed -n "${selection}p" | cut -d'|' -f1)
if [[ -z "$selected_base_id" ]]; then
print_error "Invalid selection"
return 1
fi
SOURCE_BASE_ID="$selected_base_id"
print_success "Selected base ID: $SOURCE_BASE_ID"
return 0
}
# Function to select tables for migration
select_migration_tables() {
local source_base_id=$1
print_status "Fetching tables from source base..."
local tables_response
tables_response=$(list_base_tables "$source_base_id")
if [[ $? -ne 0 ]]; then
print_error "Could not fetch tables from source base"
return 1
fi
# Parse and display available tables
local tables_info
tables_info=$(echo "$tables_response" | jq -r '.list[] | "\(.id)|\(.title)|\(.table_name)"' 2>/dev/null)
if [[ -z "$tables_info" ]]; then
print_warning "No tables found in source base"
return 1
fi
echo ""
print_status "Available tables in source base:"
print_status "================================"
local counter=1
echo "$tables_info" | while IFS='|' read -r table_id title table_name; do
echo " $counter) $title ($table_name)"
echo " Table ID: $table_id"
echo ""
counter=$((counter + 1))
done
echo ""
print_status "Select tables to migrate (comma-separated numbers, or 'all' for all tables):"
echo -n "Selection: "
read -r table_selection
if [[ "$table_selection" == "all" ]]; then
SOURCE_TABLE_IDS=$(echo "$tables_info" | cut -d'|' -f1 | tr '\n' ',' | sed 's/,$//')
else
local selected_ids=""
IFS=',' read -ra selections <<< "$table_selection"
for selection in "${selections[@]}"; do
selection=$(echo "$selection" | xargs) # Trim whitespace
if [[ "$selection" =~ ^[0-9]+$ ]]; then
local table_id
table_id=$(echo "$tables_info" | sed -n "${selection}p" | cut -d'|' -f1)
if [[ -n "$table_id" ]]; then
selected_ids="$selected_ids$table_id,"
fi
fi
done
SOURCE_TABLE_IDS=$(echo "$selected_ids" | sed 's/,$//')
fi
if [[ -z "$SOURCE_TABLE_IDS" ]]; then
print_error "No valid tables selected"
return 1
fi
print_success "Selected table IDs: $SOURCE_TABLE_IDS"
return 0
}
# Function to migrate data from source to destination
migrate_table_data() {
local source_base_id=$1
local dest_base_id=$2
local source_table_id=$3
local dest_table_id=$4
local table_name=$5
print_status "Migrating data from $table_name..."
# Export data from source table
local exported_data
exported_data=$(export_table_data "$source_base_id" "$source_table_id" "$table_name")
if [[ $? -ne 0 ]]; then
print_error "Failed to export data from source table: $table_name"
return 1
fi
# Import data to destination table
import_table_data "$dest_base_id" "$dest_table_id" "$table_name" "$exported_data"
if [[ $? -eq 0 ]]; then
print_success "Successfully migrated data for table: $table_name"
return 0
else
print_error "Failed to migrate data for table: $table_name"
return 1
fi
}
# Function to create new project with timestamp
create_new_project() {
# Generate unique project name with timestamp
@ -410,6 +828,15 @@ create_login_table() {
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "phone",
"title": "Phone",
"uidt": "PhoneNumber",
"rqd": false,
"meta": {
"validate": true
}
},
{
"column_name": "admin",
"title": "Admin",
@ -1038,11 +1465,86 @@ update_env_file() {
fi
}
# Function to extract base ID from URL
extract_base_id_from_url() {
local url="$1"
echo "$url" | grep -o '/nc/[^/]*' | sed 's|/nc/||'
}
# Function to prompt user about data migration
prompt_migration_choice() {
print_status "NocoDB Auto-Setup - Migration Options"
print_status "====================================="
echo ""
print_status "This script will create a new NocoDB base with fresh tables."
echo ""
print_status "Migration Options:"
print_status " 1) Fresh installation (create new base with default data)"
print_status " 2) Migrate from existing base (preserve your current data)"
echo ""
# Check if we have existing URLs in .env to suggest migration
if [[ -n "$NOCODB_VIEW_URL" ]]; then
local current_base_id=$(extract_base_id_from_url "$NOCODB_VIEW_URL")
print_warning "Detected existing base in .env: $current_base_id"
print_warning "You may want to migrate data from your current base."
fi
echo ""
echo -n "Choose option (1 or 2): "
read -r choice
case $choice in
1)
print_status "Selected: Fresh installation"
MIGRATE_DATA=false
return 0
;;
2)
print_status "Selected: Data migration"
MIGRATE_DATA=true
return 0
;;
*)
print_error "Invalid choice. Please enter 1 or 2."
prompt_migration_choice
;;
esac
}
# Main execution
main() {
# Parse command line arguments
parse_arguments "$@"
print_status "Starting NocoDB Auto-Setup..."
print_status "================================"
# Always prompt for migration choice unless --migrate-data was explicitly passed
if [[ "$MIGRATE_DATA" != "true" ]]; then
prompt_migration_choice
fi
# Handle data migration setup if requested
if [[ "$MIGRATE_DATA" == "true" ]]; then
print_status ""
print_status "=== Data Migration Setup ==="
if select_source_base; then
if select_migration_tables "$SOURCE_BASE_ID"; then
print_success "Migration setup completed"
print_warning "Data will be migrated after creating the new base and tables"
else
print_warning "Table selection failed, proceeding without migration"
MIGRATE_DATA=false
fi
else
print_warning "Base selection failed, proceeding without migration"
MIGRATE_DATA=false
fi
print_status ""
fi
# Always create a new project
print_status "Creating new base..."
print_warning "This script creates a NEW base and does NOT modify existing data"
@ -1079,7 +1581,45 @@ main() {
# Wait a moment for tables to be fully created
sleep 3
# Create default data
# Handle data migration if enabled
if [[ "$MIGRATE_DATA" == "true" && -n "$SOURCE_BASE_ID" && -n "$SOURCE_TABLE_IDS" ]]; then
print_status "================================"
print_status "Starting data migration..."
print_status "================================"
# Create mapping of table names to new table IDs
declare -A new_table_map=(
["locations"]="$LOCATIONS_TABLE_ID"
["login"]="$LOGIN_TABLE_ID"
["settings"]="$SETTINGS_TABLE_ID"
["shifts"]="$SHIFTS_TABLE_ID"
["shift_signups"]="$SHIFT_SIGNUPS_TABLE_ID"
["cuts"]="$CUTS_TABLE_ID"
)
# Get source table information
local source_tables_response
source_tables_response=$(list_base_tables "$SOURCE_BASE_ID")
# Migrate each selected table
IFS=',' read -ra table_ids <<< "$SOURCE_TABLE_IDS"
for source_table_id in "${table_ids[@]}"; do
# Get table name from source
local table_info
table_info=$(echo "$source_tables_response" | jq -r ".list[] | select(.id == \"$source_table_id\") | .table_name" 2>/dev/null)
if [[ -n "$table_info" && -n "${new_table_map[$table_info]}" ]]; then
migrate_table_data "$SOURCE_BASE_ID" "$BASE_ID" "$source_table_id" "${new_table_map[$table_info]}" "$table_info"
else
print_warning "Skipping migration for unknown table: $table_info (ID: $source_table_id)"
fi
done
print_status "================================"
print_success "Data migration completed!"
print_status "================================"
else
# Create default data only if not migrating
print_status "Setting up default data..."
# Create default admin user
@ -1087,6 +1627,7 @@ main() {
# Create default settings row (includes both start location and walk sheet config)
create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID"
fi
# Update .env file with new table URLs
update_env_file "$BASE_ID" "$LOCATIONS_TABLE_ID" "$LOGIN_TABLE_ID" "$SETTINGS_TABLE_ID" "$SHIFTS_TABLE_ID" "$SHIFT_SIGNUPS_TABLE_ID" "$CUTS_TABLE_ID"
@ -1100,15 +1641,30 @@ main() {
print_status "Next steps:"
print_status "1. Login to your NocoDB instance at: $BASE_URL"
print_status "2. Your .env file has been automatically updated with the new table URLs!"
if [[ "$MIGRATE_DATA" == "true" && -n "$SOURCE_BASE_ID" && -n "$SOURCE_TABLE_IDS" ]]; then
print_status "3. Your existing data has been migrated to the new base!"
print_status "4. Review the migrated data and verify everything transferred correctly"
print_status "5. If you had custom admin users, you may need to update passwords"
else
print_status "3. The default admin user is: admin@thebunkerops.ca with password: admin123"
print_status "4. IMPORTANT: Change the default password after first login!"
print_status "5. Start adding your location data!"
fi
print_warning ""
print_warning "IMPORTANT: This script created a NEW base. Your existing data was NOT modified."
print_warning "Your .env file has been automatically updated with the new table URLs."
print_warning "A backup of your previous .env file was created with a timestamp."
if [[ "$MIGRATE_DATA" != "true" ]]; then
print_warning "SECURITY: Change the default admin password immediately after first login!"
fi
if [[ "$MIGRATE_DATA" == "true" ]]; then
print_warning "DATA MIGRATION: Verify all migrated data is correct before using in production!"
print_warning "The original base remains unchanged as a backup."
fi
}
# Check if script is being run directly

View File

@ -24,7 +24,14 @@ Documents the development and requirements of the NocoDB automation script for t
# build-nocodb.sh
Bash script to automate creation of required NocoDB tables and default data for the app.
Bash script to automate creation of required NocoDB tables and default data for the app. Features:
- Creates fresh NocoDB base with 6 required tables (locations, login, settings, shifts, shift_signups, cuts)
- Optional data migration from existing NocoDB bases (--migrate-data flag)
- Interactive base and table selection for migration
- Preserves original data while creating new base
- Auto-updates .env file with new table URLs
- Dependency checking (requires jq and curl)
- Comprehensive error handling and user feedback
# combined.log

View File

@ -1,89 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Print Debug Test</title>
<script>
// Simple test to verify our changes
function testPrintUtils() {
console.log('Testing CutPrintUtils improvements...');
// Mock objects for testing
const mockMap = {
getContainer: () => document.createElement('div'),
getBounds: () => ({
getNorth: () => 53.6,
getSouth: () => 53.4,
getEast: () => -113.3,
getWest: () => -113.7
}),
getCenter: () => ({ lat: 53.5, lng: -113.5 }),
getZoom: () => 12
};
const mockLocationManager = {
currentCutId: '123',
currentCutLocations: [
{ latitude: 53.5, longitude: -113.5, first_name: 'Test', last_name: 'User', support_level: '1' }
],
showingLocations: false,
loadCutLocations: async () => console.log('Mock loadCutLocations called'),
displayLocationsOnMap: (locations) => {
console.log('Mock displayLocationsOnMap called with', locations.length, 'locations');
mockLocationManager.showingLocations = true;
},
getSupportColor: (level) => '#28a745'
};
const mockCutsManager = {
allCuts: [{ id: '123', name: 'Test Cut' }],
currentCutLayer: null
};
// Test our enhanced print utils
const printUtils = new CutPrintUtils(mockMap, mockCutsManager, mockLocationManager);
console.log('CutPrintUtils created successfully with enhanced features');
console.log('Available methods:', Object.getOwnPropertyNames(CutPrintUtils.prototype));
return true;
}
// Run test when page loads
window.addEventListener('load', () => {
if (typeof CutPrintUtils !== 'undefined') {
testPrintUtils();
} else {
console.log('CutPrintUtils not loaded - this is expected in test environment');
}
});
</script>
</head>
<body>
<h1>Print Debug Test</h1>
<p>Check the browser console for test results.</p>
<p>This tests the enhanced CutPrintUtils functionality.</p>
<h2>Key Improvements Made:</h2>
<ul>
<li>✅ Auto-load locations when printing if not already loaded</li>
<li>✅ Auto-display locations on map for print capture</li>
<li>✅ Enhanced map capture with html2canvas (priority #1)</li>
<li>✅ Improved dom-to-image capture with better filtering</li>
<li>✅ Better UI state management (toggle button updates)</li>
<li>✅ Enhanced debugging and logging</li>
<li>✅ Auto-show locations when viewing cuts (if enabled)</li>
</ul>
<h2>Root Cause Analysis:</h2>
<p>The issue was that locations were not automatically displayed on the map when viewing a cut or printing.
The print function expected locations to be visible but they were only shown when the user manually clicked "Show Locations".</p>
<h2>Solution:</h2>
<ol>
<li><strong>Print Enhancement:</strong> The print function now ensures locations are loaded and displayed before capturing the map</li>
<li><strong>View Enhancement:</strong> When viewing a cut, locations are automatically loaded if the cut has show_locations enabled</li>
<li><strong>Capture Enhancement:</strong> Improved map capture methods with html2canvas as primary method</li>
<li><strong>State Management:</strong> Better synchronization between location visibility and UI state</li>
</ol>
</body>
</html>