<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Management Tracker (Local Storage)</title>
<!-- Load Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom styles for mobile view */
body {
/* Ensures space for the fixed nav bar on mobile */
padding-bottom: 70px;
margin: 0;
min-height: 100vh;
}
.data-switch {
/* Styling for the custom toggle switch */
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
transition: transform 200ms ease;
padding: 2px;
}
.data-switch:checked {
background-color: #4f46e5; /* indigo-600 */
}
.data-switch::before {
content: '';
display: block;
width: 1rem;
height: 1rem;
background-color: white;
border-radius: 9999px;
transition: transform 200ms ease;
}
.data-switch:checked::before {
transform: translateX(100%);
}
/* Ensure the main container is centered and takes up available space */
.app-wrapper {
max-width: 480px; /* Mobile width constraint */
margin-left: auto;
margin-right: auto;
/* Give a little padding below the top for message box */
padding-top: 1rem;
}
/* Style the appearance-none switch for mobile responsiveness */
.data-switch {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
}
</style>
<script>
// --- IMPORTANT: INITIAL DATA SETUP ---
// Since we removed the backend, you must paste your CSV data here,
// converted into a JavaScript Array of Objects (JSON format).
// Each object MUST have a unique 'id' field.
const MOCK_PARTICIPANTS = [
{ id: "p1", NAME: "Jane Doe", ROLE: "Model", WALKING_FOR: "Charity A", ARRIVAL: "9:00 AM", ROOM: "R101", NEEDS_GLAM_TRACKING: true, NEEDS_FITTING: true, CHECKED_IN: false, CHECK_IN_TIME: null, HAIR_DONE: false, MAKEUP_DONE: false, FITTING_DONE: false, ISSUED_BY: '', HONORARIUM_ISSUE_TIME: null },
{ id: "p2", NAME: "John Smith", ROLE: "Volunteer", WALKING_FOR: "Operations", ARRIVAL: "8:30 AM", ROOM: "HQ", NEEDS_GLAM_TRACKING: false, NEEDS_FITTING: false, CHECKED_IN: true, CHECK_IN_TIME: new Date().toISOString(), HAIR_DONE: false, MAKEUP_DONE: false, FITTING_DONE: false, ISSUED_BY: 'SH', HONORARIUM_ISSUE_TIME: new Date().toISOString() },
{ id: "p3", NAME: "Sarah Connor", ROLE: "Model", WALKING_FOR: "Charity B", ARRIVAL: "10:00 AM", ROOM: "R102", NEEDS_GLAM_TRACKING: true, NEEDS_FITTING: false, CHECKED_IN: false, CHECK_IN_TIME: null, HAIR_DONE: false, MAKEUP_DONE: false, FITTING_DONE: false, ISSUED_BY: '', HONORARIUM_ISSUE_TIME: null },
// PASTE YOUR CONVERTED CSV/JSON DATA HERE
];
// --- IMPORTANT: LOCAL STORAGE KEYS ---
const PARTICIPANTS_STORAGE_KEY = 'eventTrackerParticipants';
const MESSAGES_STORAGE_KEY = 'eventTrackerMessages';
const STAFF_INITIALS = ["SH", "NC", "JE", "DB", "MT"]; // Staff initials for the Honorarium dropdown
// --- State management ---
const state = {
participants: [],
messages: [],
currentParticipant: null,
currentTab: 'participants' // 'participants' or 'messages'
};
let currentParticipantId = null;
// --- Utility Functions ---
function showMessage(message, isError = false) {
const messageBox = document.getElementById('messageBox');
const messageText = document.getElementById('messageText');
messageText.textContent = message;
messageBox.classList.remove('hidden', 'bg-green-100', 'bg-red-100', 'text-green-800', 'text-red-800');
if (isError) {
messageBox.classList.add('bg-red-100', 'text-red-800');
} else {
messageBox.classList.add('bg-green-100', 'text-green-800');
}
// Auto-hide after 3 seconds
setTimeout(() => {
messageBox.classList.add('hidden');
}, 3000);
}
function formatDate(timestamp) {
if (!timestamp) return 'N/A';
const date = new Date(timestamp);
// Check if date is valid
if (isNaN(date.getTime())) return 'N/A';
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function getParticipantById(id) {
return state.participants.find(p => p.id === id);
}
function navigateTo(tab) {
if (tab !== 'participants') {
currentParticipantId = null;
}
state.currentTab = tab;
renderApp();
}
function navigateToDetail(participantId) {
currentParticipantId = participantId;
renderApp();
}
// --- Local Storage Data Handlers ---
function loadInitialData() {
// Load Participants
const storedParticipants = localStorage.getItem(PARTICIPANTS_STORAGE_KEY);
if (storedParticipants) {
try {
state.participants = JSON.parse(storedParticipants);
// Ensure dates stored as strings are converted back if needed, but for simplicity, we keep ISO strings
} catch (e) {
console.error("Error parsing stored participants, using mock data.", e);
state.participants = MOCK_PARTICIPANTS;
}
} else {
// If no data exists, use the mock data and save it
state.participants = MOCK_PARTICIPANTS;
saveParticipants();
}
// Load Messages
const storedMessages = localStorage.getItem(MESSAGES_STORAGE_KEY);
if (storedMessages) {
try {
state.messages = JSON.parse(storedMessages);
} catch (e) {
console.error("Error parsing stored messages, starting fresh.", e);
state.messages = [];
}
}
// Sort participants once loaded
state.participants.sort((a, b) => a.NAME.localeCompare(b.NAME));
}
function saveParticipants() {
localStorage.setItem(PARTICIPANTS_STORAGE_KEY, JSON.stringify(state.participants));
}
function saveMessages() {
localStorage.setItem(MESSAGES_STORAGE_KEY, JSON.stringify(state.messages));
}
function updateLocalParticipant(data) {
if (!currentParticipantId) return;
const index = state.participants.findIndex(p => p.id === currentParticipantId);
if (index !== -1) {
// Merge new data into the existing participant object
state.participants[index] = { ...state.participants[index], ...data };
saveParticipants();
// Re-render to reflect changes
renderApp();
showMessage('Update successful!');
}
}
// --- Rendering Functions ---
function renderApp() {
// Ensure data is loaded
if (state.participants.length === 0) {
loadInitialData();
}
// Render the navigation bar first
renderNavigation();
const appContainer = document.getElementById('appContainer');
if (currentParticipantId) {
renderDetailView(appContainer);
} else if (state.currentTab === 'messages') {
renderMessageBoard(appContainer);
} else {
renderListView(appContainer);
}
// Hide the loading spinner once rendered
document.getElementById('loading').classList.add('hidden');
appContainer.classList.remove('hidden');
}
function renderNavigation() {
const nav = document.getElementById('navBar');
nav.innerHTML = `
<button onclick="navigateTo('participants')" class="flex-1 p-2 text-center transition duration-150 ease-in-out ${state.currentTab === 'participants' ? 'text-indigo-600 border-t-2 border-indigo-600 bg-indigo-50' : 'text-gray-500 hover:bg-gray-100'}">
<svg class="w-6 h-6 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20h-5v-2a3 3 0 00-5.356-1.857M9 20H4V6a2 2 0 012-2h4a2 2 0 012 2v14zm0 0l-1.5-1.5M15 15l-1.5-1.5m0 0l-1.5 1.5"></path></svg>
<span class="text-xs font-medium">Participants</span>
</button>
<button onclick="navigateTo('messages')" class="flex-1 p-2 text-center transition duration-150 ease-in-out ${state.currentTab === 'messages' ? 'text-indigo-600 border-t-2 border-indigo-600 bg-indigo-50' : 'text-gray-500 hover:bg-gray-100'}">
<svg class="w-6 h-6 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4v-4z"></path></svg>
<span class="text-xs font-medium">Messages</span>
</button>
`;
}
function renderListView(container) {
container.innerHTML = `
<div class="p-4 bg-white shadow-xl rounded-xl space-y-4">
<h2 class="text-2xl font-extrabold text-indigo-700">Event Participant List</h2>
<input type="text" id="searchBar" placeholder="Search by name, role, or walking for..." class="w-full p-3 border border-gray-300 rounded-xl focus:ring-indigo-500 focus:border-indigo-500 shadow-sm">
<div id="participantList" class="space-y-3">
${state.participants.length === 0 ? '<p class="text-center text-gray-500 py-4">No participants loaded. Check the MOCK_PARTICIPANTS data.</p>' : ''}
<!-- List items will be injected here -->
</div>
</div>
`;
document.getElementById('searchBar').addEventListener('input', filterList);
updateParticipantList();
}
function filterList() {
const searchTerm = document.getElementById('searchBar').value.toLowerCase();
const listContainer = document.getElementById('participantList');
if (!listContainer) return;
listContainer.innerHTML = '';
// Filter against mandatory fields from the CSV/JSON
const filtered = state.participants.filter(p =>
p.NAME.toLowerCase().includes(searchTerm) ||
p.ROLE.toLowerCase().includes(searchTerm) ||
p.WALKING_FOR.toLowerCase().includes(searchTerm)
);
filtered.forEach(p => {
listContainer.appendChild(createListItem(p));
});
}
function updateParticipantList() {
const listContainer = document.getElementById('participantList');
if (!listContainer) return;
// Only update if not currently searching (search function calls filterList directly)
if (document.getElementById('searchBar').value === '') {
listContainer.innerHTML = '';
state.participants.forEach(p => {
listContainer.appendChild(createListItem(p));
});
} else {
filterList(); // Re-apply filter if search is active
}
}
function createListItem(participant) {
const item = document.createElement('div');
const checkedIn = participant.CHECKED_IN;
const statusColor = checkedIn ? 'bg-green-100 border-green-500' : 'bg-gray-100 border-gray-300';
const icon = checkedIn ? '✅' : '⚪';
item.className = `flex justify-between items-center p-4 border-l-4 rounded-xl shadow-sm cursor-pointer transition duration-150 ease-in-out hover:bg-indigo-50 ${statusColor}`;
item.innerHTML = `
<div>
<p class="font-semibold text-gray-800">${participant.NAME}</p>
<p class="text-sm text-gray-600">${participant.ROLE} / ${participant.WALKING_FOR}</p>
</div>
<span class="text-2xl">${icon}</span>
`;
item.addEventListener('click', () => navigateToDetail(participant.id));
return item;
}
function renderDetailView(container) {
state.currentParticipant = getParticipantById(currentParticipantId);
if (!state.currentParticipant) {
currentParticipantId = null;
renderListView(container);
return;
}
const p = state.currentParticipant;
const isCheckedIn = p.CHECKED_IN;
// Ensure boolean flags from CSV are handled correctly (true/false)
const needsGlam = p.NEEDS_GLAM_TRACKING === true || p.NEEDS_GLAM_TRACKING === 'TRUE';
const needsFitting = p.NEEDS_FITTING === true || p.NEEDS_FITTING === 'TRUE';
const honorariumIssued = p.ISSUED_BY;
const honorariumTime = p.HONORARIUM_ISSUE_TIME;
container.innerHTML = `
<div class="p-5 bg-white shadow-2xl rounded-xl">
<div class="flex justify-between items-start mb-4">
<h2 class="text-3xl font-extrabold text-indigo-800">${p.NAME}</h2>
<button id="backButton" class="p-2 text-gray-500 hover:text-indigo-600 rounded-full bg-gray-50 transition duration-150 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
</button>
</div>
<!-- General Details -->
<div class="space-y-2 mb-6 p-4 bg-indigo-50 rounded-lg text-gray-700">
<p><strong>Role:</strong> ${p.ROLE}</p>
<p><strong>Walking For:</strong> ${p.WALKING_FOR}</p>
<p><strong>Arrival:</strong> ${p.ARRIVAL}</p>
<p><strong>Room:</strong> ${p.ROOM}</p>
</div>
<!-- Main Check-In Button -->
${!isCheckedIn ? `
<button id="checkInButton" class="w-full py-3 mb-6 font-bold text-white bg-green-600 rounded-xl shadow-lg hover:bg-green-700 transition duration-150 ease-in-out">
✅ Check In Now
</button>` : `
<div class="p-4 mb-6 font-bold text-center text-green-700 bg-green-200 rounded-xl shadow-inner">
Checked In at: ${formatDate(p.CHECK_IN_TIME)}
</div>
`}
<!-- Glam & Fitting Tracking (Conditional Visibility Logic) -->
<div class="space-y-4 p-4 bg-gray-50 rounded-xl shadow-inner">
<h3 class="text-xl font-bold text-indigo-700 border-b pb-2">Tracking Milestones</h3>
${(needsGlam || needsFitting) ? `
${needsGlam ? createSwitch('Hair Complete', 'HAIR_DONE', p.HAIR_DONE) : ''}
${needsGlam ? createSwitch('Makeup Complete', 'MAKEUP_DONE', p.MAKEUP_DONE) : ''}
${needsFitting ? createSwitch('Fitting Complete', 'FITTING_DONE', p.FITTING_DONE) : ''}
` : '<p class="text-sm text-gray-500">No glam or fitting tracking required.</p>'}
</div>
<!-- Honorarium Tracking -->
<div class="space-y-4 mt-6 p-4 bg-yellow-50 rounded-xl shadow-inner border border-yellow-200">
<h3 class="text-xl font-bold text-yellow-700 border-b border-yellow-200 pb-2">Honorarium Payment</h3>
<div class="flex flex-col space-y-1">
<label for="issuedBy" class="text-sm font-medium text-gray-700">Issued By (Staff Initials)</label>
<select id="issuedBy" class="p-3 border border-yellow-300 rounded-lg focus:ring-yellow-500 focus:border-yellow-500 shadow-sm">
<option value="" ${!honorariumIssued ? 'selected' : ''}>-- Select Staff --</option>
${STAFF_INITIALS.map(initial => `
<option value="${initial}" ${honorariumIssued === initial ? 'selected' : ''}>${initial}</option>
`).join('')}
</select>
</div>
<div id="honorariumTimeDisplay" class="text-sm text-gray-600 p-2 bg-yellow-100 rounded-md">
<strong>Issue Time:</strong> ${honorariumTime ? formatDate(honorariumTime) : 'Not Yet Stamped'}
</div>
<div class="space-y-2">
<button id="stampTimeButton" class="w-full py-2 font-semibold text-white bg-yellow-600 rounded-xl shadow-md hover:bg-yellow-700 transition duration-150 ease-in-out ${!honorariumIssued ? 'opacity-50 cursor-not-allowed' : ''}" ${!honorariumIssued ? 'disabled' : ''}>
${honorariumIssued ? 'Update Stamp Time' : 'Select Staff to Stamp Time'}
</button>
<button id="resetHonorariumButton" class="w-full py-2 font-semibold text-gray-700 border border-gray-300 bg-white rounded-xl shadow-md hover:bg-gray-100 transition duration-150 ease-in-out ${!honorariumIssued ? 'hidden' : ''}">
Reset Honorarium Data
</button>
</div>
</div>
</div>
`;
// --- Attach Detail View Listeners ---
document.getElementById('backButton').addEventListener('click', () => {
currentParticipantId = null;
renderApp();
});
if (!isCheckedIn) {
document.getElementById('checkInButton').addEventListener('click', handleCheckIn);
}
if (needsGlam || needsFitting) {
document.querySelectorAll('.data-switch').forEach(switchEl => {
switchEl.addEventListener('change', (e) => handleSwitchToggle(e.target.dataset.field, e.target.checked));
});
}
document.getElementById('issuedBy').addEventListener('change', handleIssuedByChange);
if (honorariumIssued) {
document.getElementById('stampTimeButton').addEventListener('click', handleStampTime);
document.getElementById('resetHonorariumButton').addEventListener('click', handleResetHonorarium);
}
}
function createSwitch(label, field, checked) {
return `
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm border border-gray-200">
<label for="${field}" class="text-gray-700 font-medium">${label}</label>
<input type="checkbox" id="${field}" data-field="${field}" ${checked ? 'checked' : ''} class="data-switch h-6 w-11 appearance-none rounded-full bg-gray-300 transition duration-200 ease-in-out checked:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2">
</div>
`;
}
function renderMessageBoard(container) {
container.innerHTML = `
<div class="p-4 bg-white shadow-xl rounded-xl space-y-4">
<h2 class="text-2xl font-extrabold text-indigo-700">Team Message Board</h2>
<!-- Message Submission Form -->
<div class="p-4 border-b border-gray-200 space-y-3 bg-indigo-50 rounded-lg shadow-inner">
<input type="text" id="authorInput" placeholder="Your Name/Initials" value="${localStorage.getItem('messageAuthor') || ''}" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 text-sm shadow-sm">
<textarea id="messageInput" rows="3" placeholder="Type your message here..." class="w-full p-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 text-sm shadow-sm"></textarea>
<button id="postMessageButton" class="w-full py-2 font-bold text-white bg-indigo-600 rounded-xl shadow-md hover:bg-indigo-700 transition duration-150 ease-in-out">
Post Message
</button>
</div>
<!-- Message List -->
<div id="messageList" class="space-y-4 pt-2 max-h-[60vh] overflow-y-auto">
${state.messages.length === 0 ? '<p class="text-center text-gray-500 py-4">No messages yet. Be the first!</p>' : ''}
${state.messages.map(m => `
<div class="p-3 bg-gray-50 rounded-xl shadow-sm border border-gray-200">
<p class="text-gray-800">${m.MESSAGE_TEXT}</p>
<p class="mt-1 text-xs text-gray-500 font-semibold">${m.AUTHOR_NAME} - ${formatDate(m.TIMESTAMP)}</p>
</div>
`).join('')}
</div>
</div>
`;
document.getElementById('postMessageButton').addEventListener('click', handleAddMessage);
document.getElementById('authorInput').addEventListener('change', (e) => {
localStorage.setItem('messageAuthor', e.target.value);
});
}
// --- Event Handlers (Updates) ---
function handleAddMessage() {
const author = document.getElementById('authorInput').value.trim();
const message = document.getElementById('messageInput').value.trim();
if (!author || !message) {
showMessage('Please enter your name and a message.', true);
return;
}
try {
// Save author name locally
localStorage.setItem('messageAuthor', author);
const newMessage = {
AUTHOR_NAME: author,
MESSAGE_TEXT: message,
TIMESTAMP: new Date().toISOString()
};
// Add message to state and save
state.messages.unshift(newMessage); // Add to the beginning
saveMessages();
document.getElementById('messageInput').value = ''; // Clear input field
showMessage('Message posted successfully! (Only visible on this device/browser)');
renderApp(); // Re-render message board
} catch (error) {
console.error("Error posting message:", error);
showMessage('Error posting message: ' + error.message, true);
}
}
// --- Event Handlers (Participant Details) ---
function handleCheckIn() {
const data = {
CHECKED_IN: true,
CHECK_IN_TIME: new Date().toISOString(), // Store as ISO string
};
updateLocalParticipant(data);
}
function handleSwitchToggle(field, value) {
const data = {};
data[field] = value;
updateLocalParticipant(data);
}
function handleIssuedByChange(event) {
const value = event.target.value;
const participant = getParticipantById(currentParticipantId);
const timestampData = {};
// If staff is assigned and time is NOT set yet, stamp it
if (value && !participant.HONORARIUM_ISSUE_TIME) {
timestampData.HONORARIUM_ISSUE_TIME = new Date().toISOString();
showMessage('Honorarium assigned and time stamped.');
} else if (!value) {
// If staff is removed, clear time
timestampData.HONORARIUM_ISSUE_TIME = null;
}
updateLocalParticipant({ ISSUED_BY: value, ...timestampData });
// Re-render to update button state (Stamp/Reset)
renderApp();
}
function handleStampTime() {
updateLocalParticipant({ HONORARIUM_ISSUE_TIME: new Date().toISOString() });
showMessage('Honorarium time stamped!');
}
function handleResetHonorarium() {
const data = {
ISSUED_BY: '', // Clear initials
HONORARIUM_ISSUE_TIME: null // Clear timestamp
};
updateLocalParticipant(data);
showMessage('Honorarium data reset!');
}
// --- Initialization ---
function initApp() {
// Load all data from localStorage or mock if non-existent
loadInitialData();
// Initial Render
renderApp();
}
// Expose functions globally for HTML event handlers
window.onload = initApp;
window.navigateTo = navigateTo;
window.navigateToDetail = navigateToDetail;
</script>
</head>
<body class="bg-gray-100 min-h-screen font-sans antialiased">
<!-- Message Box (Top, Fixed) -->
<div id="messageBox" class="hidden fixed top-0 left-0 right-0 max-w-md mx-auto p-4 text-center text-sm font-semibold z-50 rounded-b-lg shadow-lg">
<span id="messageText"></span>
</div>
<!-- Main Application Container (Centered, Mobile-First) -->
<div id="appContainer" class="app-wrapper mb-2 hidden">
<!-- Content will be injected here -->
</div>
<!-- Loading Spinner -->
<div id="loading" class="app-wrapper p-8 text-center text-gray-500">
<svg class="animate-spin h-8 w-8 text-indigo-500 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-3">Loading application...</p>
</div>
<!-- Fixed Bottom Navigation Bar (Centered) -->
<nav id="navBar" class="fixed bottom-0 left-0 right-0 max-w-md mx-auto h-16 bg-white border-t border-gray-200 flex shadow-2xl z-40 rounded-t-xl">
<!-- Navigation Buttons will be injected here -->
</nav>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Management Tracker</title>
<!-- Load Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom styles for mobile view */
body {
/* Ensures space for the fixed nav bar on mobile */
padding-bottom: 70px;
margin: 0;
}
.data-switch {
/* Styling for the custom toggle switch */
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
transition: transform 200ms ease;
padding: 2px;
}
.data-switch:checked {
background-color: #4f46e5; /* indigo-600 */
}
.data-switch::before {
content: '';
display: block;
width: 1rem;
height: 1rem;
background-color: white;
border-radius: 9999px;
transition: transform 200ms ease;
}
.data-switch:checked::before {
transform: translateX(100%);
}
/* Ensure the main container is centered and takes up available space */
.app-wrapper {
max-width: 480px; /* Mobile width constraint */
margin-left: auto;
margin-right: auto;
/* Give a little padding below the top for message box */
padding-top: 1rem;
}
</style>
<!-- Load Firebase SDKs -->
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, onSnapshot, collection, query, updateDoc, setLogLevel, addDoc, orderBy, limit } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// Global variables provided by the environment
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-event-app';
const firebaseConfig = JSON.parse(typeof __firebase_config !== 'undefined' ? __firebase_config : '{}');
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
let db, auth, userId = null;
let unsubscribeParticipants = null;
let unsubscribeMessages = null;
let currentParticipantId = null;
// --- Core Data Model ---
// Firestore Collection References (Public Data)
const getParticipantsCollectionRef = () => collection(db, `artifacts/${appId}/public/data/participants`);
const getMessagesCollectionRef = () => collection(db, `artifacts/${appId}/public/data/messages`);
// Staff initials for the Honorarium dropdown
const STAFF_INITIALS = ["SH", "NC", "JE", "DB", "MT"]; // Expanded initials
// State management
const state = {
participants: [],
messages: [],
currentParticipant: null,
currentTab: 'participants' // 'participants' or 'messages'
};
// --- Utility Functions ---
function showMessage(message, isError = false) {
const messageBox = document.getElementById('messageBox');
const messageText = document.getElementById('messageText');
messageText.textContent = message;
messageBox.classList.remove('hidden', 'bg-green-100', 'bg-red-100', 'text-green-800', 'text-red-800');
if (isError) {
messageBox.classList.add('bg-red-100', 'text-red-800');
} else {
messageBox.classList.add('bg-green-100', 'text-green-800');
}
// Auto-hide after 3 seconds
setTimeout(() => {
messageBox.classList.add('hidden');
}, 3000);
}
function formatDate(timestamp) {
if (!timestamp) return 'N/A';
const date = timestamp.toDate ? timestamp.toDate() : new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function getParticipantById(id) {
return state.participants.find(p => p.id === id);
}
function navigateTo(tab) {
// Only clear detail view if navigating away from participants tab
if (tab !== 'participants') {
currentParticipantId = null;
}
state.currentTab = tab;
renderApp();
}
function navigateToDetail(participantId) {
currentParticipantId = participantId;
renderApp();
}
// --- Rendering Functions ---
function renderApp() {
if (!userId) return; // Wait for authentication
// Render the navigation bar first
renderNavigation();
const appContainer = document.getElementById('appContainer');
if (currentParticipantId) {
renderDetailView(appContainer);
} else if (state.currentTab === 'messages') {
renderMessageBoard(appContainer);
} else {
renderListView(appContainer);
}
}
function renderNavigation() {
const nav = document.getElementById('navBar');
nav.innerHTML = `
<button onclick="navigateTo('participants')" class="flex-1 p-2 text-center transition duration-150 ease-in-out ${state.currentTab === 'participants' ? 'text-indigo-600 border-t-2 border-indigo-600 bg-indigo-50' : 'text-gray-500 hover:bg-gray-100'}">
<svg class="w-6 h-6 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20h-5v-2a3 3 0 00-5.356-1.857M9 20H4V6a2 2 0 012-2h4a2 2 0 012 2v14zm0 0l-1.5-1.5M15 15l-1.5-1.5m0 0l-1.5 1.5"></path></svg>
<span class="text-xs font-medium">Participants</span>
</button>
<button onclick="navigateTo('messages')" class="flex-1 p-2 text-center transition duration-150 ease-in-out ${state.currentTab === 'messages' ? 'text-indigo-600 border-t-2 border-indigo-600 bg-indigo-50' : 'text-gray-500 hover:bg-gray-100'}">
<svg class="w-6 h-6 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4v-4z"></path></svg>
<span class="text-xs font-medium">Messages</span>
</button>
`;
}
function renderListView(container) {
container.innerHTML = `
<div class="p-4 bg-white shadow-xl rounded-xl space-y-4">
<h2 class="text-2xl font-extrabold text-indigo-700">Event Participant List</h2>
<input type="text" id="searchBar" placeholder="Search by name, role, or walking for..." class="w-full p-3 border border-gray-300 rounded-xl focus:ring-indigo-500 focus:border-indigo-500 shadow-sm">
<div id="participantList" class="space-y-3">
${state.participants.length === 0 ? '<p class="text-center text-gray-500 py-4">No participants loaded yet. Check your CSV import.</p>' : ''}
<!-- List items will be injected here -->
</div>
</div>
`;
document.getElementById('searchBar').addEventListener('input', filterList);
updateParticipantList();
}
function filterList() {
const searchTerm = document.getElementById('searchBar').value.toLowerCase();
const listContainer = document.getElementById('participantList');
if (!listContainer) return;
listContainer.innerHTML = '';
const filtered = state.participants.filter(p =>
p.NAME.toLowerCase().includes(searchTerm) ||
p.ROLE.toLowerCase().includes(searchTerm) ||
p.WALKING_FOR.toLowerCase().includes(searchTerm)
);
filtered.forEach(p => {
listContainer.appendChild(createListItem(p));
});
}
function updateParticipantList() {
const listContainer = document.getElementById('participantList');
if (!listContainer) return;
// Only update if not currently searching (search function calls filterList directly)
if (document.getElementById('searchBar').value === '') {
listContainer.innerHTML = '';
state.participants.forEach(p => {
listContainer.appendChild(createListItem(p));
});
} else {
filterList(); // Re-apply filter if search is active
}
}
function createListItem(participant) {
const item = document.createElement('div');
const checkedIn = participant.CHECKED_IN;
const statusColor = checkedIn ? 'bg-green-100 border-green-500' : 'bg-gray-100 border-gray-300';
const icon = checkedIn ? '✅' : '⚪';
item.className = `flex justify-between items-center p-4 border-l-4 rounded-xl shadow-sm cursor-pointer transition duration-150 ease-in-out hover:bg-indigo-50 ${statusColor}`;
item.innerHTML = `
<div>
<p class="font-semibold text-gray-800">${participant.NAME}</p>
<p class="text-sm text-gray-600">${participant.ROLE} / ${participant.WALKING_FOR}</p>
</div>
<span class="text-2xl">${icon}</span>
`;
item.addEventListener('click', () => navigateToDetail(participant.id));
return item;
}
function renderDetailView(container) {
state.currentParticipant = getParticipantById(currentParticipantId);
if (!state.currentParticipant) {
currentParticipantId = null;
renderListView(container);
return;
}
const p = state.currentParticipant;
const isCheckedIn = p.CHECKED_IN;
const needsGlam = p.NEEDS_GLAM_TRACKING;
const needsFitting = p.NEEDS_FITTING;
const honorariumIssued = p.ISSUED_BY;
const honorariumTime = p.HONORARIUM_ISSUE_TIME;
container.innerHTML = `
<div class="p-5 bg-white shadow-2xl rounded-xl">
<div class="flex justify-between items-start mb-4">
<h2 class="text-3xl font-extrabold text-indigo-800">${p.NAME}</h2>
<button id="backButton" class="p-2 text-gray-500 hover:text-indigo-600 rounded-full bg-gray-50 transition duration-150 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
</button>
</div>
<!-- General Details -->
<div class="space-y-2 mb-6 p-4 bg-indigo-50 rounded-lg text-gray-700">
<p><strong>Role:</strong> ${p.ROLE}</p>
<p><strong>Walking For:</strong> ${p.WALKING_FOR}</p>
<p><strong>Arrival:</strong> ${p.ARRIVAL}</p>
<p><strong>Room:</strong> ${p.ROOM}</p>
</div>
<!-- Main Check-In Button -->
${!isCheckedIn ? `
<button id="checkInButton" class="w-full py-3 mb-6 font-bold text-white bg-green-600 rounded-xl shadow-lg hover:bg-green-700 transition duration-150 ease-in-out">
✅ Check In Now
</button>` : `
<div class="p-4 mb-6 font-bold text-center text-green-700 bg-green-200 rounded-xl shadow-inner">
Checked In at: ${formatDate(p.CHECK_IN_TIME)}
</div>
`}
<!-- Glam & Fitting Tracking (Conditional Visibility Logic) -->
<div class="space-y-4 p-4 bg-gray-50 rounded-xl shadow-inner">
<h3 class="text-xl font-bold text-indigo-700 border-b pb-2">Tracking Milestones</h3>
${(needsGlam || needsFitting) ? `
${needsGlam ? createSwitch('Hair Complete', 'HAIR_DONE', p.HAIR_DONE) : ''}
${needsGlam ? createSwitch('Makeup Complete', 'MAKEUP_DONE', p.MAKEUP_DONE) : ''}
${needsFitting ? createSwitch('Fitting Complete', 'FITTING_DONE', p.FITTING_DONE) : ''}
` : '<p class="text-sm text-gray-500">No glam or fitting tracking required.</p>'}
</div>
<!-- Honorarium Tracking -->
<div class="space-y-4 mt-6 p-4 bg-yellow-50 rounded-xl shadow-inner border border-yellow-200">
<h3 class="text-xl font-bold text-yellow-700 border-b border-yellow-200 pb-2">Honorarium Payment</h3>
<div class="flex flex-col space-y-1">
<label for="issuedBy" class="text-sm font-medium text-gray-700">Issued By (Staff Initials)</label>
<select id="issuedBy" class="p-3 border border-yellow-300 rounded-lg focus:ring-yellow-500 focus:border-yellow-500 shadow-sm">
<option value="" ${!honorariumIssued ? 'selected' : ''}>-- Select Staff --</option>
${STAFF_INITIALS.map(initial => `
<option value="${initial}" ${honorariumIssued === initial ? 'selected' : ''}>${initial}</option>
`).join('')}
</select>
</div>
<div id="honorariumTimeDisplay" class="text-sm text-gray-600 p-2 bg-yellow-100 rounded-md">
<strong>Issue Time:</strong> ${honorariumTime ? formatDate(honorariumTime) : 'Not Yet Stamped'}
</div>
<div class="space-y-2">
${honorariumIssued ? `
<button id="stampTimeButton" class="w-full py-2 font-semibold text-white bg-yellow-600 rounded-xl shadow-md hover:bg-yellow-700 transition duration-150 ease-in-out">
Update Stamp Time
</button>
<button id="resetHonorariumButton" class="w-full py-2 font-semibold text-gray-700 border border-gray-300 bg-white rounded-xl shadow-md hover:bg-gray-100 transition duration-150 ease-in-out">
Reset Honorarium Data
</button>
` : ''}
</div>
</div>
</div>
`;
// --- Attach Detail View Listeners ---
document.getElementById('backButton').addEventListener('click', () => {
currentParticipantId = null;
renderListView(container);
});
if (!isCheckedIn) {
document.getElementById('checkInButton').addEventListener('click', handleCheckIn);
}
if (needsGlam || needsFitting) {
document.querySelectorAll('.data-switch').forEach(switchEl => {
switchEl.addEventListener('change', (e) => handleSwitchToggle(e.target.dataset.field, e.target.checked));
});
}
document.getElementById('issuedBy').addEventListener('change', handleIssuedByChange);
if (honorariumIssued) {
document.getElementById('stampTimeButton').addEventListener('click', handleStampTime);
document.getElementById('resetHonorariumButton').addEventListener('click', handleResetHonorarium);
}
// Re-render immediately if the Issued By value changes to update buttons
document.getElementById('issuedBy').addEventListener('change', () => {
// Short delay to allow Firestore update to potentially fire, though we rely on onSnapshot
setTimeout(renderDetailView.bind(null, container), 50);
});
}
function createSwitch(label, field, checked) {
return `
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm border border-gray-200">
<label for="${field}" class="text-gray-700 font-medium">${label}</label>
<input type="checkbox" id="${field}" data-field="${field}" ${checked ? 'checked' : ''} class="data-switch h-6 w-11 appearance-none rounded-full bg-gray-300 transition duration-200 ease-in-out checked:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2">
</div>
`;
}
function renderMessageBoard(container) {
container.innerHTML = `
<div class="p-4 bg-white shadow-xl rounded-xl space-y-4">
<h2 class="text-2xl font-extrabold text-indigo-700">Team Message Board</h2>
<!-- Message Submission Form -->
<div class="p-4 border-b border-gray-200 space-y-3 bg-indigo-50 rounded-lg shadow-inner">
<input type="text" id="authorInput" placeholder="Your Name/Initials" value="${localStorage.getItem('messageAuthor') || ''}" class="w-full p-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 text-sm shadow-sm">
<textarea id="messageInput" rows="3" placeholder="Type your message here..." class="w-full p-2 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 text-sm shadow-sm"></textarea>
<button id="postMessageButton" class="w-full py-2 font-bold text-white bg-indigo-600 rounded-xl shadow-md hover:bg-indigo-700 transition duration-150 ease-in-out">
Post Message
</button>
</div>
<!-- Message List -->
<div id="messageList" class="space-y-4 pt-2 max-h-[60vh] overflow-y-auto">
${state.messages.length === 0 ? '<p class="text-center text-gray-500 py-4">No messages yet. Be the first!</p>' : ''}
${state.messages.map(m => `
<div class="p-3 bg-gray-50 rounded-xl shadow-sm border border-gray-200">
<p class="text-gray-800">${m.MESSAGE_TEXT}</p>
<p class="mt-1 text-xs text-gray-500 font-semibold">${m.AUTHOR_NAME} - ${formatDate(m.TIMESTAMP)}</p>
</div>
`).join('')}
</div>
</div>
`;
document.getElementById('postMessageButton').addEventListener('click', handleAddMessage);
document.getElementById('authorInput').addEventListener('change', (e) => {
localStorage.setItem('messageAuthor', e.target.value);
});
}
// --- Firestore Handlers (Updates) ---
async function updateParticipant(data) {
try {
const docRef = doc(db, getParticipantsCollectionRef().id, currentParticipantId);
await updateDoc(docRef, data);
showMessage('Update successful!');
} catch (error) {
console.error("Error updating document:", error);
showMessage('Error saving data: ' + error.message, true);
}
}
async function handleAddMessage() {
const author = document.getElementById('authorInput').value.trim();
const message = document.getElementById('messageInput').value.trim();
if (!author || !message) {
showMessage('Please enter your name and a message.', true);
return;
}
try {
// Save author name locally
localStorage.setItem('messageAuthor', author);
await addDoc(getMessagesCollectionRef(), {
AUTHOR_NAME: author,
MESSAGE_TEXT: message,
TIMESTAMP: new Date()
});
document.getElementById('messageInput').value = ''; // Clear input field
showMessage('Message posted successfully!');
} catch (error) {
console.error("Error posting message:", error);
showMessage('Error posting message: ' + error.message, true);
}
}
// --- Event Handlers (Participant Details) ---
async function handleCheckIn() {
const data = {
CHECKED_IN: true,
CHECK_IN_TIME: new Date(),
};
await updateParticipant(data);
}
async function handleSwitchToggle(field, value) {
const data = {};
data[field] = value;
await updateParticipant(data);
}
async function handleIssuedByChange(event) {
const value = event.target.value;
// Only update time if staff is assigned and time is not set
const timestampData = {};
if (value && !state.currentParticipant.HONORARIUM_ISSUE_TIME) {
timestampData.HONORARIUM_ISSUE_TIME = new Date();
showMessage('Honorarium assigned and time stamped.');
} else if (!value) {
// If staff is removed, clear time as well (full reset recommended via button)
// This is just for safety/consistency on change
timestampData.HONORARIUM_ISSUE_TIME = null;
}
await updateParticipant({ ISSUED_BY: value, ...timestampData });
}
async function handleStampTime() {
await updateParticipant({ HONORARIUM_ISSUE_TIME: new Date() });
showMessage('Honorarium time stamped!');
}
async function handleResetHonorarium() {
const data = {
ISSUED_BY: '', // Clear initials
HONORARIUM_ISSUE_TIME: null // Clear timestamp
};
await updateParticipant(data);
showMessage('Honorarium data reset!');
}
// --- Initialization ---
async function initFirebase() {
if (!firebaseConfig || !firebaseConfig.apiKey) {
console.error("Firebase configuration is missing or invalid.");
document.getElementById('appContainer').innerHTML = `<div class="p-4 bg-red-100 text-red-800 rounded-lg app-wrapper">Error: Firebase config is missing. Please check the environment variables.</div>`;
return;
}
try {
const app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
setLogLevel('error');
// 1. Authenticate
if (initialAuthToken) {
await signInWithCustomToken(auth, initialAuthToken);
} else {
await signInAnonymously(auth);
}
// 2. Wait for Auth State and get User ID
await new Promise((resolve) => {
onAuthStateChanged(auth, (user) => {
if (user) {
userId = user.uid;
}
resolve();
});
});
// 3. Start Firestore Listeners
startParticipantsListener();
startMessagesListener();
// 4. Initial Render
renderApp();
} catch (error) {
console.error("Firebase initialization failed:", error);
document.getElementById('appContainer').innerHTML = `<div class="p-4 bg-red-100 text-red-800 rounded-lg app-wrapper">Critical Error: ${error.message}</div>`;
}
}
function startParticipantsListener() {
if (unsubscribeParticipants) unsubscribeParticipants();
const q = query(getParticipantsCollectionRef());
unsubscribeParticipants = onSnapshot(q, (snapshot) => {
const updatedParticipants = snapshot.docs.map(doc => {
const data = doc.data();
// Ensure all fields are present and correctly typed (default to safe values)
return {
id: doc.id,
NAME: data.NAME || 'Untitled',
ROLE: data.ROLE || 'N/A',
WALKING_FOR: data.WALKING_FOR || 'N/A',
ARRIVAL: data.ARRIVAL || 'N/A',
ROOM: data.ROOM || 'N/A',
CHECKED_IN: !!data.CHECKED_IN,
CHECK_IN_TIME: data.CHECK_IN_TIME || null,
HAIR_DONE: !!data.HAIR_DONE,
MAKEUP_DONE: !!data.MAKEUP_DONE,
FITTING_DONE: !!data.FITTING_DONE,
// Ensure CSV boolean flags are treated as booleans
NEEDS_GLAM_TRACKING: data.NEEDS_GLAM_TRACKING === 'TRUE' || data.NEEDS_GLAM_TRACKING === true,
NEEDS_FITTING: data.NEEDS_FITTING === 'TRUE' || data.NEEDS_FITTING === true,
ISSUED_BY: data.ISSUED_BY || '',
HONORARIUM_ISSUE_TIME: data.HONORARIUM_ISSUE_TIME || null,
};
});
updatedParticipants.sort((a, b) => a.NAME.localeCompare(b.NAME));
state.participants = updatedParticipants;
renderApp();
}, (error) => {
console.error("Firestore snapshot error (Participants):", error);
showMessage('Error loading participant data: ' + error.message, true);
});
}
function startMessagesListener() {
if (unsubscribeMessages) unsubscribeMessages();
// Order by timestamp descending and limit to latest 50 for performance
const q = query(getMessagesCollectionRef(), orderBy('TIMESTAMP', 'desc'), limit(50));
unsubscribeMessages = onSnapshot(q, (snapshot) => {
state.messages = snapshot.docs.map(doc => ({
id: doc.id,
AUTHOR_NAME: doc.data().AUTHOR_NAME || 'Unknown',
MESSAGE_TEXT: doc.data().MESSAGE_TEXT || 'No Content',
TIMESTAMP: doc.data().TIMESTAMP,
}));
renderApp();
}, (error) => {
console.error("Firestore snapshot error (Messages):", error);
showMessage('Error loading message data: ' + error.message, true);
});
}
// Expose functions globally for HTML event handlers
window.onload = initFirebase;
window.navigateTo = navigateTo;
window.navigateToDetail = navigateToDetail;
</script>
</head>
<body class="bg-gray-100 min-h-screen font-sans antialiased">
<!-- Message Box (Top, Fixed) -->
<div id="messageBox" class="hidden fixed top-0 left-0 right-0 max-w-md mx-auto p-4 text-center text-sm font-semibold z-50 rounded-b-lg shadow-lg">
<span id="messageText"></span>
</div>
<!-- Main Application Container (Centered, Mobile-First) -->
<div id="appContainer" class="app-wrapper mb-2">
<div class="p-8 text-center text-gray-500">
<svg class="animate-spin h-8 w-8 text-indigo-500 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-3">Loading application...</p>
</div>
</div>
<!-- Fixed Bottom Navigation Bar (Centered) -->
<nav id="navBar" class="fixed bottom-0 left-0 right-0 max-w-md mx-auto h-16 bg-white border-t border-gray-200 flex shadow-2xl z-40 rounded-t-xl">
<!-- Navigation Buttons will be injected here by renderNavigation() -->
</nav>
</body>
</html>