Created
November 16, 2025 00:50
-
-
Save joshvasquez/c198161f9d193b90f3d45e07945fe4f6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Approval Workflow Manager</title> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: | |
| -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| padding: 20px; | |
| background: #f5f5f5; | |
| } | |
| .container { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| } | |
| h1 { | |
| margin-bottom: 24px; | |
| color: #333; | |
| } | |
| .instructions { | |
| background: #e3f2fd; | |
| border-left: 4px solid #2196f3; | |
| padding: 12px 16px; | |
| margin-bottom: 20px; | |
| border-radius: 4px; | |
| font-size: 14px; | |
| color: #1565c0; | |
| } | |
| .workspace { | |
| display: flex; | |
| gap: 20px; | |
| align-items: flex-start; | |
| } | |
| .unassigned-section { | |
| flex: 0 0 280px; | |
| background: white; | |
| border-radius: 8px; | |
| padding: 16px; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| .workers-section { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | |
| gap: 16px; | |
| } | |
| .section-title { | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: #666; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| margin-bottom: 12px; | |
| } | |
| .worker-column { | |
| background: white; | |
| border-radius: 8px; | |
| padding: 16px; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| min-height: 200px; | |
| } | |
| .worker-column.drag-over { | |
| background: #f0f7ff; | |
| border: 2px dashed #3b82f6; | |
| } | |
| .worker-name { | |
| font-size: 16px; | |
| font-weight: 600; | |
| color: #333; | |
| margin-bottom: 4px; | |
| } | |
| .worker-count { | |
| font-size: 12px; | |
| color: #999; | |
| margin-bottom: 12px; | |
| } | |
| .task-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| min-height: 60px; | |
| } | |
| .task-card { | |
| background: #fafafa; | |
| border: 1px solid #e5e5e5; | |
| border-radius: 6px; | |
| padding: 12px; | |
| cursor: move; | |
| transition: | |
| box-shadow 0.2s, | |
| background 0.2s; | |
| position: relative; | |
| } | |
| .task-card:hover { | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); | |
| } | |
| .task-card:focus { | |
| outline: 2px solid #2196f3; | |
| outline-offset: 2px; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); | |
| } | |
| .task-card.dragging { | |
| opacity: 0.5; | |
| } | |
| .task-card.selected { | |
| background: #fff3e0; | |
| border-color: #ff9800; | |
| } | |
| .task-workflow { | |
| font-weight: 600; | |
| color: #333; | |
| margin-bottom: 4px; | |
| font-size: 14px; | |
| } | |
| .task-person { | |
| color: #666; | |
| font-size: 13px; | |
| margin-bottom: 4px; | |
| } | |
| .task-time { | |
| color: #999; | |
| font-size: 12px; | |
| } | |
| .empty-state { | |
| color: #ccc; | |
| font-size: 13px; | |
| text-align: center; | |
| padding: 20px; | |
| } | |
| .assignment-menu { | |
| display: none; | |
| position: absolute; | |
| top: 100%; | |
| left: 0; | |
| margin-top: 4px; | |
| background: white; | |
| border: 1px solid #ddd; | |
| border-radius: 6px; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
| z-index: 1000; | |
| min-width: 200px; | |
| } | |
| .assignment-menu.visible { | |
| display: block; | |
| } | |
| .assignment-menu-title { | |
| padding: 8px 12px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: #666; | |
| border-bottom: 1px solid #eee; | |
| } | |
| .assignment-option { | |
| padding: 10px 12px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| color: #333; | |
| border: none; | |
| background: none; | |
| width: 100%; | |
| text-align: left; | |
| display: block; | |
| } | |
| .assignment-option:hover, | |
| .assignment-option:focus { | |
| background: #f5f5f5; | |
| outline: none; | |
| } | |
| .assignment-option.current { | |
| background: #e3f2fd; | |
| color: #1976d2; | |
| font-weight: 500; | |
| } | |
| .sr-only { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| white-space: nowrap; | |
| border-width: 0; | |
| } | |
| .loading { | |
| text-align: center; | |
| padding: 40px; | |
| color: #666; | |
| } | |
| .error { | |
| background: #ffebee; | |
| border-left: 4px solid #f44336; | |
| padding: 12px 16px; | |
| margin-bottom: 20px; | |
| border-radius: 4px; | |
| color: #c62828; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Approval Workflow Manager</h1> | |
| <div class="instructions"> | |
| <strong>Keyboard shortcuts:</strong> Select a task with Tab/Arrow keys, | |
| press Enter or Space to assign, Escape to cancel | |
| </div> | |
| <div id="error-container"></div> | |
| <div id="app"></div> | |
| </div> | |
| <script> | |
| // ==================== API FUNCTIONS ==================== | |
| async function fetchTasks() { | |
| // Simulating API call with mock data | |
| return new Promise((resolve) => { | |
| setTimeout(() => { | |
| resolve([ | |
| { | |
| id: 1, | |
| workflow: "PTO Request", | |
| person: "Sarah Johnson", | |
| queueTime: "2h 15m", | |
| assignedWorker: "", | |
| }, | |
| { | |
| id: 2, | |
| workflow: "Expense Report", | |
| person: "Mike Chen", | |
| queueTime: "45m", | |
| assignedWorker: "", | |
| }, | |
| { | |
| id: 3, | |
| workflow: "Budget Approval", | |
| person: "Emily Davis", | |
| queueTime: "3h 30m", | |
| assignedWorker: "", | |
| }, | |
| { | |
| id: 4, | |
| workflow: "Vendor Contract", | |
| person: "James Wilson", | |
| queueTime: "1h 5m", | |
| assignedWorker: "", | |
| }, | |
| { | |
| id: 5, | |
| workflow: "Equipment Purchase", | |
| person: "Lisa Martinez", | |
| queueTime: "5h 20m", | |
| assignedWorker: "Alice Cooper", | |
| }, | |
| { | |
| id: 6, | |
| workflow: "PTO Request", | |
| person: "David Brown", | |
| queueTime: "30m", | |
| assignedWorker: "Alice Cooper", | |
| }, | |
| { | |
| id: 7, | |
| workflow: "Training Request", | |
| person: "Anna Lee", | |
| queueTime: "1h 45m", | |
| assignedWorker: "Bob Smith", | |
| }, | |
| ]); | |
| }, 500); | |
| }); | |
| // Real API call would look like: | |
| // const response = await fetch('/api/tasks'); | |
| // return await response.json(); | |
| } | |
| async function updateTaskAssignment(taskId, workerName) { | |
| // Simulating API call | |
| return new Promise((resolve) => { | |
| setTimeout(() => { | |
| resolve({ success: true }); | |
| }, 300); | |
| }); | |
| // Real API call would look like: | |
| // const response = await fetch(`/api/tasks/${taskId}`, { | |
| // method: 'PATCH', | |
| // headers: { 'Content-Type': 'application/json' }, | |
| // body: JSON.stringify({ assignedWorker: workerName }) | |
| // }); | |
| // return await response.json(); | |
| } | |
| // ==================== STATE FUNCTIONS (PURE) ==================== | |
| function deriveWorkflowState(tasks, existingWorkers = new Set()) { | |
| const workerMap = new Map(); | |
| const unassigned = []; | |
| const allWorkers = new Set(existingWorkers); | |
| tasks.forEach((task) => { | |
| if (!task.assignedWorker || task.assignedWorker.trim() === "") { | |
| unassigned.push(task); | |
| } else { | |
| allWorkers.add(task.assignedWorker); | |
| if (!workerMap.has(task.assignedWorker)) { | |
| workerMap.set(task.assignedWorker, []); | |
| } | |
| workerMap.get(task.assignedWorker).push(task); | |
| } | |
| }); | |
| // Initialize empty arrays for all known workers | |
| allWorkers.forEach((worker) => { | |
| if (!workerMap.has(worker)) { | |
| workerMap.set(worker, []); | |
| } | |
| }); | |
| return { workerMap, unassigned, tasks, workers: allWorkers }; | |
| } | |
| function getWorkers(state) { | |
| return Array.from(state.workers).sort(); | |
| } | |
| function getTasksForWorker(state, workerName) { | |
| return state.workerMap.get(workerName) || []; | |
| } | |
| function assignTaskInState(state, taskId, newWorkerName) { | |
| const updatedTasks = state.tasks.map((task) => | |
| task.id === taskId | |
| ? { ...task, assignedWorker: newWorkerName || "" } | |
| : task, | |
| ); | |
| // Preserve existing workers when reassigning | |
| return deriveWorkflowState(updatedTasks, state.workers); | |
| } | |
| // ==================== APPLICATION STATE ==================== | |
| let appState = null; | |
| let draggedTaskId = null; | |
| let selectedTaskId = null; | |
| let activeMenu = null; | |
| // ==================== UI FUNCTIONS ==================== | |
| function showError(message) { | |
| const errorContainer = document.getElementById("error-container"); | |
| errorContainer.innerHTML = `<div class="error">${message}</div>`; | |
| setTimeout(() => { | |
| errorContainer.innerHTML = ""; | |
| }, 5000); | |
| } | |
| function getWorkerNameForTask(task) { | |
| if (!task.assignedWorker || task.assignedWorker.trim() === "") { | |
| return "Unassigned"; | |
| } | |
| return task.assignedWorker; | |
| } | |
| function createAssignmentMenu(task, card) { | |
| const menu = document.createElement("div"); | |
| menu.className = "assignment-menu"; | |
| menu.role = "menu"; | |
| menu.setAttribute("aria-label", "Assign task to worker"); | |
| const title = document.createElement("div"); | |
| title.className = "assignment-menu-title"; | |
| title.textContent = "Assign to:"; | |
| menu.appendChild(title); | |
| // Unassigned option | |
| const unassignedBtn = document.createElement("button"); | |
| unassignedBtn.className = "assignment-option"; | |
| unassignedBtn.role = "menuitem"; | |
| unassignedBtn.textContent = "Unassigned"; | |
| if (!task.assignedWorker || task.assignedWorker.trim() === "") { | |
| unassignedBtn.classList.add("current"); | |
| unassignedBtn.setAttribute("aria-current", "true"); | |
| } | |
| unassignedBtn.addEventListener("click", () => { | |
| handleAssignment(task.id, ""); | |
| closeMenu(); | |
| }); | |
| menu.appendChild(unassignedBtn); | |
| // Worker options | |
| const workers = getWorkers(appState); | |
| workers.forEach((workerName) => { | |
| const btn = document.createElement("button"); | |
| btn.className = "assignment-option"; | |
| btn.role = "menuitem"; | |
| btn.textContent = workerName; | |
| if (task.assignedWorker === workerName) { | |
| btn.classList.add("current"); | |
| btn.setAttribute("aria-current", "true"); | |
| } | |
| btn.addEventListener("click", () => { | |
| handleAssignment(task.id, workerName); | |
| closeMenu(); | |
| }); | |
| menu.appendChild(btn); | |
| }); | |
| return menu; | |
| } | |
| function openMenu(task, card) { | |
| closeMenu(); | |
| const menu = createAssignmentMenu(task, card); | |
| card.appendChild(menu); | |
| menu.classList.add("visible"); | |
| activeMenu = menu; | |
| selectedTaskId = task.id; | |
| card.classList.add("selected"); | |
| const firstOption = menu.querySelector(".assignment-option"); | |
| if (firstOption) firstOption.focus(); | |
| const options = menu.querySelectorAll(".assignment-option"); | |
| options.forEach((option, index) => { | |
| option.addEventListener("keydown", (e) => { | |
| if (e.key === "ArrowDown") { | |
| e.preventDefault(); | |
| const next = options[index + 1] || options[0]; | |
| next.focus(); | |
| } else if (e.key === "ArrowUp") { | |
| e.preventDefault(); | |
| const prev = options[index - 1] || options[options.length - 1]; | |
| prev.focus(); | |
| } else if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| option.click(); | |
| } else if (e.key === "Escape") { | |
| e.preventDefault(); | |
| closeMenu(); | |
| card.focus(); | |
| } | |
| }); | |
| }); | |
| } | |
| function closeMenu() { | |
| if (activeMenu) { | |
| activeMenu.remove(); | |
| activeMenu = null; | |
| } | |
| document.querySelectorAll(".task-card.selected").forEach((card) => { | |
| card.classList.remove("selected"); | |
| }); | |
| selectedTaskId = null; | |
| } | |
| function createTaskCard(task) { | |
| const card = document.createElement("div"); | |
| card.className = "task-card"; | |
| card.draggable = true; | |
| card.tabIndex = 0; | |
| card.role = "listitem"; | |
| card.dataset.taskId = task.id; | |
| card.setAttribute( | |
| "aria-label", | |
| `${task.workflow} for ${task.person}, in queue ${task.queueTime}, assigned to ${getWorkerNameForTask(task)}`, | |
| ); | |
| card.innerHTML = ` | |
| <div class="task-workflow">${task.workflow}</div> | |
| <div class="task-person">${task.person}</div> | |
| <div class="task-time">In queue: ${task.queueTime}</div> | |
| <span class="sr-only">Currently assigned to: ${getWorkerNameForTask(task)}. Press Enter or Space to reassign.</span> | |
| `; | |
| card.addEventListener("dragstart", (e) => { | |
| draggedTaskId = task.id; | |
| card.classList.add("dragging"); | |
| e.dataTransfer.effectAllowed = "move"; | |
| }); | |
| card.addEventListener("dragend", () => { | |
| card.classList.remove("dragging"); | |
| }); | |
| card.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| openMenu(task, card); | |
| } else if (e.key === "Escape") { | |
| closeMenu(); | |
| } | |
| }); | |
| card.addEventListener("click", (e) => { | |
| if (!e.target.closest(".assignment-menu")) { | |
| if (activeMenu && activeMenu.parentElement === card) { | |
| closeMenu(); | |
| } else { | |
| openMenu(task, card); | |
| } | |
| } | |
| }); | |
| return card; | |
| } | |
| function renderUnassigned() { | |
| const container = document.createElement("div"); | |
| container.className = "unassigned-section"; | |
| container.innerHTML = ` | |
| <div class="section-title">Unassigned Tasks</div> | |
| <div class="task-list" data-worker-name="" role="list" aria-label="Unassigned tasks"></div> | |
| `; | |
| const taskList = container.querySelector(".task-list"); | |
| if (appState.unassigned.length === 0) { | |
| taskList.innerHTML = | |
| '<div class="empty-state" role="status">No unassigned tasks</div>'; | |
| } else { | |
| appState.unassigned.forEach((task) => { | |
| taskList.appendChild(createTaskCard(task)); | |
| }); | |
| } | |
| return container; | |
| } | |
| function renderWorkers() { | |
| const container = document.createElement("div"); | |
| container.className = "workers-section"; | |
| const workers = getWorkers(appState); | |
| workers.forEach((workerName) => { | |
| const column = document.createElement("div"); | |
| column.className = "worker-column"; | |
| const workerTasks = getTasksForWorker(appState, workerName); | |
| column.innerHTML = ` | |
| <div class="worker-name">${workerName}</div> | |
| <div class="worker-count">${workerTasks.length} task${workerTasks.length !== 1 ? "s" : ""}</div> | |
| <div class="task-list" data-worker-name="${workerName}" role="list" aria-label="Tasks for ${workerName}"></div> | |
| `; | |
| const taskList = column.querySelector(".task-list"); | |
| if (workerTasks.length === 0) { | |
| taskList.innerHTML = | |
| '<div class="empty-state" role="status">No tasks</div>'; | |
| } else { | |
| workerTasks.forEach((task) => { | |
| taskList.appendChild(createTaskCard(task)); | |
| }); | |
| } | |
| container.appendChild(column); | |
| }); | |
| return container; | |
| } | |
| function setupDropZones() { | |
| const dropZones = document.querySelectorAll(".task-list"); | |
| dropZones.forEach((zone) => { | |
| zone.addEventListener("dragover", (e) => { | |
| e.preventDefault(); | |
| e.dataTransfer.dropEffect = "move"; | |
| zone | |
| .closest(".worker-column, .unassigned-section") | |
| .classList.add("drag-over"); | |
| }); | |
| zone.addEventListener("dragleave", (e) => { | |
| if (!zone.contains(e.relatedTarget)) { | |
| zone | |
| .closest(".worker-column, .unassigned-section") | |
| .classList.remove("drag-over"); | |
| } | |
| }); | |
| zone.addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| zone | |
| .closest(".worker-column, .unassigned-section") | |
| .classList.remove("drag-over"); | |
| if (!draggedTaskId) return; | |
| const workerName = zone.dataset.workerName; | |
| handleAssignment(draggedTaskId, workerName); | |
| }); | |
| }); | |
| } | |
| function render() { | |
| const appContainer = document.getElementById("app"); | |
| if (!appState) { | |
| appContainer.innerHTML = | |
| '<div class="loading">Loading tasks...</div>'; | |
| return; | |
| } | |
| appContainer.innerHTML = ""; | |
| const workspace = document.createElement("div"); | |
| workspace.className = "workspace"; | |
| workspace.appendChild(renderUnassigned()); | |
| workspace.appendChild(renderWorkers()); | |
| appContainer.appendChild(workspace); | |
| setupDropZones(); | |
| } | |
| // ==================== EVENT HANDLERS ==================== | |
| async function handleAssignment(taskId, newWorkerName) { | |
| // Optimistic update | |
| appState = assignTaskInState(appState, taskId, newWorkerName); | |
| render(); | |
| try { | |
| await updateTaskAssignment(taskId, newWorkerName); | |
| } catch (error) { | |
| showError("Failed to update assignment. Refreshing..."); | |
| // On error, refetch to get correct state | |
| await initialize(); | |
| } | |
| } | |
| // ==================== INITIALIZATION ==================== | |
| async function initialize() { | |
| try { | |
| const tasks = await fetchTasks(); | |
| appState = deriveWorkflowState(tasks); | |
| render(); | |
| } catch (error) { | |
| showError("Failed to load tasks. Please refresh the page."); | |
| console.error("Failed to load tasks:", error); | |
| } | |
| } | |
| // Close menu when clicking outside | |
| document.addEventListener("click", (e) => { | |
| if (activeMenu && !e.target.closest(".task-card")) { | |
| closeMenu(); | |
| } | |
| }); | |
| // Start the app | |
| initialize(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment