feat: add procedural flows with intake forms, navigation, and seed templates

Adds a new "procedural" tree type for linear step-by-step project workflows
(domain controller setup, M365 onboarding, VPN config, etc). Includes intake
form builder, two-panel step navigation, variable resolution, procedural
exports, 3 seed templates, and UI rename from "Trees" to "Flows".

Also archives 19 implemented plan docs and creates deferred features backlog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-14 04:13:52 -05:00
parent 303570ca2c
commit 350c977eda
58 changed files with 11686 additions and 167 deletions

View File

@@ -1,17 +1,25 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { BrandLogo } from '@/components/common/BrandLogo'
import { Menu, X, LogOut, User, Shield } from 'lucide-react'
import { Menu, X, LogOut, User, Shield, ChevronDown, FolderTree, ListOrdered, Layers } from 'lucide-react'
import { cn } from '@/lib/utils'
interface NavItem {
path: string
label: string
children?: { path: string; label: string; icon: React.ReactNode }[]
}
export function AppLayout() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const { effectiveRole, isSuperAdmin } = usePermissions()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [flowsDropdownOpen, setFlowsDropdownOpen] = useState(false)
const flowsDropdownRef = useRef<HTMLDivElement>(null)
const handleLogout = async () => {
setMobileMenuOpen(false)
@@ -24,13 +32,30 @@ export function AppLayout() {
if (prevPath !== location.pathname) {
setPrevPath(location.pathname)
if (mobileMenuOpen) setMobileMenuOpen(false)
setFlowsDropdownOpen(false)
}
// Close on Escape
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') setMobileMenuOpen(false)
if (e.key === 'Escape') {
setMobileMenuOpen(false)
setFlowsDropdownOpen(false)
}
}, [])
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (flowsDropdownRef.current && !flowsDropdownRef.current.contains(e.target as Node)) {
setFlowsDropdownOpen(false)
}
}
if (flowsDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [flowsDropdownOpen])
useEffect(() => {
if (mobileMenuOpen) {
document.addEventListener('keydown', handleKeyDown)
@@ -44,10 +69,20 @@ export function AppLayout() {
}
}, [mobileMenuOpen, handleKeyDown])
const navItems = [
const isFlowsActive = location.pathname.startsWith('/trees') || location.pathname.startsWith('/flows')
const navItems: NavItem[] = [
{ path: '/', label: 'Home' },
{ path: '/trees', label: 'Trees' },
{ path: '/my-trees', label: 'My Trees' },
{
path: '/trees',
label: 'Flows',
children: [
{ path: '/trees', label: 'All Flows', icon: <Layers className="h-4 w-4 text-white/50" /> },
{ path: '/trees?type=troubleshooting', label: 'Troubleshooting', icon: <FolderTree className="h-4 w-4 text-white/50" /> },
{ path: '/trees?type=procedural', label: 'Procedures', icon: <ListOrdered className="h-4 w-4 text-white/50" /> },
],
},
{ path: '/my-trees', label: 'My Flows' },
{ path: '/sessions', label: 'Sessions' },
{ path: '/account', label: 'Account' },
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
@@ -84,6 +119,40 @@ export function AppLayout() {
{/* Desktop Navigation */}
<nav className="hidden items-center gap-1 sm:flex">
{navItems.map((item) => {
if (item.children) {
return (
<div key={item.path} className="relative" ref={flowsDropdownRef}>
<button
onClick={() => setFlowsDropdownOpen(!flowsDropdownOpen)}
className={cn(
'flex items-center gap-1 rounded-xl px-4 py-2 text-sm font-medium transition-all',
isFlowsActive
? 'bg-white/10 text-white border border-white/20'
: 'text-white/50 hover:text-white hover:bg-white/[0.06]'
)}
>
{item.label}
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', flowsDropdownOpen && 'rotate-180')} />
</button>
{flowsDropdownOpen && (
<div className="absolute left-0 z-50 mt-1 w-52 rounded-lg border border-white/10 bg-black/95 p-1 shadow-xl backdrop-blur-sm">
{item.children.map((child) => (
<Link
key={child.path}
to={child.path}
onClick={() => setFlowsDropdownOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-white/70 hover:bg-white/10 hover:text-white"
>
{child.icon}
{child.label}
</Link>
))}
</div>
)}
</div>
)
}
const isActive = item.path === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.path)
@@ -200,6 +269,34 @@ export function AppLayout() {
{/* Nav items */}
<div className="space-y-1">
{navItems.map((item) => {
if (item.children) {
return (
<div key={item.path}>
<div className={cn(
'px-4 py-2 text-xs font-semibold uppercase tracking-wider',
isFlowsActive ? 'text-white/60' : 'text-white/30'
)}>
{item.label}
</div>
<div className="space-y-0.5">
{item.children.map((child) => (
<Link
key={child.path}
to={child.path}
className={cn(
'flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium transition-all ml-1',
'text-white/50 hover:text-white hover:bg-white/[0.06]'
)}
>
{child.icon}
{child.label}
</Link>
))}
</div>
</div>
)
}
const isActive = item.path === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.path)