diff --git a/src/App.jsx b/src/App.jsx index 7ccb32c..5d4969d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -58,12 +58,9 @@ const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders')) const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices')); const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail')); const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate')); -const Projects = lazy(() => import('./pages/Projects')); const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage')); -const DashboardPage = lazy(() => import('./pages/DashboardPage')); const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard')); const RequestsPage = lazy(() => import('./pages/RequestsPage')); -const TeamTasksPage = lazy(() => import('./pages/team/TeamTasksPage')); const MyInvoices = lazy(() => import('./pages/client/MyInvoices')); const NewRequest = lazy(() => import('./pages/client/NewRequest')); const NewProject = lazy(() => import('./pages/client/NewProject')); @@ -95,7 +92,6 @@ export default function App() { } /> } /> - } /> } /> } /> } /> @@ -103,9 +99,10 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -119,9 +116,9 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> @@ -132,9 +129,9 @@ export default function App() { } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 17322c2..f3884ad 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -27,8 +27,7 @@ function TeamNav({ onNav }) { const primaryLinks = [ { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, - { to: '/team/tasks', label: 'Tasks', icon: ICONS.requests }, - { to: '/projects', label: 'Projects', icon: ICONS.projects }, + { to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/invoices', label: 'Finances', icon: ICONS.invoices }, { to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing }, ]; @@ -50,10 +49,8 @@ function TeamNav({ onNav }) { {label} ))} -
-
- Tools -
+
+
Tools
{utilityLinks.map(({ to, label, icon }) => ( `sidebar-link${isActive ? ' active' : ''}`}> {label} @@ -72,8 +69,7 @@ function TeamNav({ onNav }) { function ClientNav({ onNav }) { const links = [ { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, - { to: '/requests', label: 'Requests', icon: ICONS.requests }, - { to: '/projects', label: 'Projects', icon: ICONS.projects }, + { to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing }, { to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices }, ]; @@ -92,8 +88,7 @@ function ClientNav({ onNav }) { function ExternalNav({ onNav }) { const links = [ { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, - { to: '/requests', label: 'Requests', icon: ICONS.requests }, - { to: '/projects', label: 'Projects', icon: ICONS.projects }, + { to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices }, { to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing }, { to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey }, @@ -107,10 +102,8 @@ function ExternalNav({ onNav }) {
{index === 4 && ( <> -
-
- Tools -
+
+
Tools
)} `sidebar-link${isActive ? ' active' : ''}`}> @@ -122,16 +115,22 @@ function ExternalNav({ onNav }) { ); } +function getInitialTheme() { + if (typeof document !== 'undefined') { + return document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'dark'; + } + return 'dark'; +} + export default function Layout({ children }) { const { currentUser, logout } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark'); + const [theme, setTheme] = useState(getInitialTheme); const [menuOpen, setMenuOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState({ projects: [], tasks: [] }); + const [searchResults, setSearchResults] = useState({ projects: [], tasks: [], companies: [], people: [] }); const [searchOpen, setSearchOpen] = useState(false); - const [searchExpanded, setSearchExpanded] = useState(false); const searchInputRef = useRef(null); const [avatarOpen, setAvatarOpen] = useState(false); const avatarRef = useRef(null); @@ -141,33 +140,33 @@ export default function Layout({ children }) { const firstName = currentUser?.name?.split(' ')[0] || ''; const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/'); const isFileSharingRoute = location.pathname === '/file-sharing'; - const isRequestsRoute = location.pathname === '/requests'; - const isTasksRoute = location.pathname === '/team/tasks'; - const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isTasksRoute ? 'Tasks & Projects' : isRequestsRoute ? 'Requests' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`; + const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks'; + const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isRequestsRoute ? 'Tasks & Projects' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`; const headerSubtitle = isProfileRoute ? 'Account details and security settings.' : isFileSharingRoute ? 'Browse, share and manage files.' - : isTasksRoute + : isRequestsRoute ? 'Browse and manage all tasks and projects.' - : isRequestsRoute - ? 'Browse and manage all your requests.' : "Here's what's happening today."; useEffect(() => { document.documentElement.setAttribute('data-theme', theme); + document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark'; localStorage.setItem('theme', theme); }, [theme]); useEffect(() => { - if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [] }); return; } + if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [], companies: [], people: [] }); return; } const t = setTimeout(async () => { const { supabase } = await import('../lib/supabase'); - const [{ data: projects }, { data: tasks }] = await Promise.all([ + const [{ data: projects }, { data: tasks }, { data: companies }, { data: people }] = await Promise.all([ supabase.from('projects').select('id, name').ilike('name', `%${searchQuery}%`).limit(6), supabase.from('tasks').select('id, title').ilike('title', `%${searchQuery}%`).limit(6), + supabase.from('companies').select('id, name').ilike('name', `%${searchQuery}%`).limit(6), + supabase.from('profiles').select('id, name').ilike('name', `%${searchQuery}%`).limit(6), ]); - setSearchResults({ projects: projects || [], tasks: tasks || [] }); + setSearchResults({ projects: projects || [], tasks: tasks || [], companies: companies || [], people: people || [] }); }, 280); return () => clearTimeout(t); }, [searchQuery]); @@ -201,7 +200,11 @@ export default function Layout({ children }) { const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark'); const handleLogout = async () => { await logout(); navigate('/'); }; - const hasResults = searchResults.projects.length > 0 || searchResults.tasks.length > 0; + const hasResults = + searchResults.projects.length > 0 || + searchResults.tasks.length > 0 || + searchResults.companies.length > 0 || + searchResults.people.length > 0; return (
@@ -238,29 +241,20 @@ export default function Layout({ children }) {
- {!searchExpanded ? ( - - ) : ( - <> - setSearchQuery(e.target.value)} - onFocus={() => setSearchOpen(true)} - onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); }, 150)} - onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); } }} - /> - - )} + + setSearchQuery(e.target.value)} + onFocus={() => setSearchOpen(true)} + onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchQuery(''); }, 150)} + onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchQuery(''); } }} + /> {searchOpen && searchQuery.trim() && (
{!hasResults &&
No results for "{searchQuery}"
} @@ -268,7 +262,7 @@ export default function Layout({ children }) { <>
Projects
{searchResults.projects.map(p => ( -
{ navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}> +
{ navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}> {ICONS.projects} {p.name}
@@ -279,13 +273,35 @@ export default function Layout({ children }) { <>
Tasks
{searchResults.tasks.map(r => ( -
{ navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}> +
{ navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}> {ICONS.requests} {r.title}
))} )} + {searchResults.companies.length > 0 && ( + <> +
Companies
+ {searchResults.companies.map(c => ( +
{ navigate(`/company/${c.id}`); setSearchQuery(''); setSearchOpen(false); }}> + {ICONS.companies} + {c.name} +
+ ))} + + )} + {searchResults.people.length > 0 && ( + <> +
People
+ {searchResults.people.map(p => ( +
{ navigate(`/profile/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}> + {ICONS.users} + {p.name || 'Unknown User'} +
+ ))} + + )}
)}
diff --git a/src/index.css b/src/index.css index 6659ab4..0b3d546 100644 --- a/src/index.css +++ b/src/index.css @@ -24,6 +24,7 @@ linear-gradient(180deg, #111111 0%, #0d0d0d 42%, #0a0a0a 100%); --card-bg: rgba(255, 255, 255, 0.02); --card-bg-2: rgba(255, 255, 255, 0.08); + --popup-bg: rgba(13,13,13,0.88); --text-primary: #ffffff; --text-secondary: #a8a8a8; --text-muted: #666666; @@ -31,6 +32,9 @@ --interactive-hover-border: rgba(245, 165, 35, 0.3); --interactive-row-hover: rgba(255,255,255,0.03); --file-row-alt-bg: rgba(255,255,255,0.02); + --overlay-scrim: rgba(0,0,0,0.58); + --popup-shadow: 0 24px 64px rgba(0,0,0,0.5); + --avatar-inner-ring: #111111; --danger: #ef4444; --success: #22c55e; } @@ -51,6 +55,7 @@ linear-gradient(180deg, #ffffff 0%, #ffffff 42%, #ffffff 100%); --card-bg: rgba(0,0,0,0.02); --card-bg-2: rgba(0,0,0,0.08); + --popup-bg: rgba(255,255,255,0.92); --text-primary: #0d0d0d; --text-secondary: rgba(0,0,0,0.6); --text-muted: rgba(0,0,0,0.38); @@ -58,6 +63,9 @@ --interactive-hover-border: rgba(0,0,0,0.2); --interactive-row-hover: rgba(0,0,0,0.025); --file-row-alt-bg: rgba(0,0,0,0.02); + --overlay-scrim: rgba(255,255,255,0.72); + --popup-shadow: 0 16px 42px rgba(0,0,0,0.18); + --avatar-inner-ring: #ffffff; } [data-theme="light"] input[type="text"], [data-theme="light"] input[type="email"], @@ -169,7 +177,7 @@ body { .sidebar-link { display: flex; align-items: center; gap: 10px; justify-content: center; - padding: 10px 8px; border-radius: 4px; + padding: 10px 8px; border-radius: 8px; color: var(--sidebar-text); text-decoration: none; font-size: 13px; font-weight: 500; transition: background 0.15s, color 0.15s; @@ -190,7 +198,7 @@ body { transform: translateY(-50%); background: var(--sidebar-bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: 8px; padding: 4px 10px; font-size: 12px; font-weight: 500; @@ -204,7 +212,20 @@ body { .sidebar-link:hover .nav-label { opacity: 1; } [data-theme="light"] .nav-label { color: #0d0d0d; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .sidebar-tools-divider, -.sidebar-tools-label { display: none; } +.sidebar-tools-label { display: block; } +.sidebar-tools-divider { + height: 1px; + margin: 10px 12px; + background: var(--border); +} +.sidebar-tools-label { + padding: 0 12px 8px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--text-muted); +} .grid-card { background: var(--card-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); cursor: pointer; transition: background 0.15s; } .grid-card:hover { background: var(--card-bg-2); } [data-theme="light"] .grid-card:hover { background: #fafafa; } @@ -251,7 +272,8 @@ body { flex: 1; padding: 24px; overflow-y: scroll; - display: block; + display: flex; + flex-direction: column; min-height: 0; scrollbar-gutter: stable; } @@ -261,28 +283,55 @@ body { .hot-items-row { cursor: pointer; } .hot-items-row:hover td { background: rgba(255,255,255,0.04); } .site-header-right { display: flex; align-items: center; gap: 0; } -.site-header-search-wrap { position: relative; margin-right: 7px; } -.site-header-search-btn { background: none; border: 1px solid transparent; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 4px; } +.site-header-search-wrap { position: relative; margin-right: 7px; width: 300px; flex: 0 0 300px; } +.site-header-search-btn { background: none; border: 1px solid transparent; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; } .site-header-search-btn:hover { color: var(--accent); } .site-header-search-btn svg { width: 16px; height: 16px; } -.site-header-search { width: 240px; height: 34px; background: var(--card-bg-2); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font-size: 13px; padding: 0 12px 0 34px; outline: none; font-family: inherit; transition: border-color 0.15s, width 0.2s; } -.site-header-search:focus { border-color: var(--accent); width: 300px; } -.site-header-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; display: flex; } +.site-header-search, +input.site-header-search[type="text"] { + width: 300px; + height: var(--h-control); + background: var(--card-bg-2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + line-height: 1; + padding: 3px 12px 0 38px; + outline: none; + font-family: inherit; + transition: border-color 0.15s; + box-sizing: border-box; + display: block; + margin: 0; + vertical-align: middle; + -webkit-appearance: none; + appearance: none; +} +.site-header-search::placeholder, +input.site-header-search[type="text"]::placeholder { + text-transform: uppercase; + letter-spacing: 0.6px; + line-height: 1; +} +.site-header-search:focus, +input.site-header-search[type="text"]:focus { border-color: var(--accent); } +.site-header-search-icon { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; display: flex; } .site-header-search-icon svg { width: 14px; height: 14px; } -.site-header-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 340px; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; overflow: hidden; } +.site-header-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 340px; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; overflow: hidden; } .site-header-dropdown { animation: ui-fade-up var(--motion-fast) var(--motion-ease); } .site-header-dropdown-group { padding: 8px 12px 4px; font-size: 10px; font-weight: 500; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); } .site-header-dropdown-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary); border: 1px solid transparent; } .site-header-dropdown-item:hover { color: #fff; } .site-header-dropdown-empty { padding: 12px; font-size: 13px; color: var(--text-muted); text-align: center; } .site-header-avatar-wrap { position: relative; margin-left: 14px; } -.site-header-theme-toggle { background: none; border: 1px solid transparent; border-radius: 4px; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; flex-shrink: 0; } +.site-header-theme-toggle { background: none; border: 1px solid transparent; border-radius: 8px; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; flex-shrink: 0; } .site-header-theme-toggle:hover { color: var(--accent); } .site-header-theme-toggle svg { width: 16px; height: 16px; display: block; flex-shrink: 0; } .site-header-avatar-btn { width: 49px; height: 49px; border-radius: 50%; background: var(--card-bg-2); border: 2px solid #111; outline: 2px solid var(--accent); outline-offset: 0; cursor: pointer; color: var(--text-muted); display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: opacity 0.15s; padding: 0; } .site-header-avatar-btn:hover { opacity: 0.85; } [data-theme="light"] .site-header-avatar-btn { border-color: #fff; } -.site-header-avatar-menu { position: absolute; top: calc(100% + 8px); right: 0; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; min-width: 160px; overflow: hidden; } +.site-header-avatar-menu { position: absolute; top: calc(100% + 8px); right: 0; background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 300; min-width: 160px; overflow: hidden; } .site-header-avatar-menu { animation: ui-fade-up var(--motion-fast) var(--motion-ease); } .site-header-avatar-item { padding: 10px 16px; cursor: pointer; font-size: 13px; color: var(--text-primary); display: block; width: 100%; text-align: left; background: none; border: 1px solid transparent; font-family: inherit; } .site-header-avatar-item:hover { color: #fff; } @@ -292,6 +341,39 @@ body { .main-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } .main-content::-webkit-scrollbar-thumb:hover { background: var(--interactive-hover-border); } +.scrollbar-thin-theme { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} +.scrollbar-thin-theme::-webkit-scrollbar { + width: 8px; + height: 8px; +} +.scrollbar-thin-theme::-webkit-scrollbar-track { + background: transparent; +} +.scrollbar-thin-theme::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; + transition: background 0.2s ease; +} +.scrollbar-thin-theme:hover { + scrollbar-color: var(--interactive-hover-border) transparent; +} +.scrollbar-thin-theme:focus-within { + scrollbar-color: var(--interactive-hover-border) transparent; +} +.scrollbar-thin-theme:hover::-webkit-scrollbar-thumb, +.scrollbar-thin-theme:focus-within::-webkit-scrollbar-thumb, +.scrollbar-thin-theme::-webkit-scrollbar-thumb:active { + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 62%, var(--border) 38%), color-mix(in srgb, var(--accent) 42%, var(--border) 58%)); +} +.scrollbar-thin-theme::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 72%, var(--border) 28%), color-mix(in srgb, var(--accent) 52%, var(--border) 48%)); +} + /* Page header */ .page-header { margin-bottom: 24px; display: flex; @@ -987,6 +1069,14 @@ textarea { /* Badges */ .badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 400; white-space: nowrap; letter-spacing: 0.3px; } +.badge-status { + min-width: 78px; + height: 20px; + justify-content: center; + padding: 0 8px; + font-size: 10px; + line-height: 1; +} .badge-not_started { background: #222; color: #888; border: 1px solid #333; } .badge-in_progress { background: rgba(37,99,235,0.15); color: #60a5fa; border: 1px solid rgba(37,99,235,0.3); } .badge-on_hold { background: rgba(217,119,6,0.15); color: #fbbf24; border: 1px solid rgba(217,119,6,0.3); } @@ -1015,8 +1105,28 @@ th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 400; te td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); } tr:hover td { background: rgba(255,255,255,0.02); } -.table-link { color: var(--accent); text-decoration: none; font-weight: 400; } -.table-link:hover { text-decoration: underline; } +.table-link { + color: inherit; + text-decoration: none; + font-weight: 400; + transition: color 0.15s; +} +.table-link:hover, +.table-link:focus-visible { + color: var(--accent) !important; + text-decoration: none; + outline: none; +} + +.table-sticky-head thead th { + position: sticky; + top: 0; + z-index: 3; + background: transparent !important; +} +.table-scroll-fade { + position: static; +} .dashboard-inline-link { background: transparent; border: 0; diff --git a/src/pages/RequestsPage.jsx b/src/pages/RequestsPage.jsx index d6eeb2d..c2e0046 100644 --- a/src/pages/RequestsPage.jsx +++ b/src/pages/RequestsPage.jsx @@ -15,9 +15,212 @@ import { uploadFilesToRequestInfo, createTaskFolder } from '../lib/filebrowserFo import SortTh from '../components/SortTh'; import { useSortable } from '../hooks/useSortable'; import FilterDropdown from '../components/FilterDropdown'; +import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; -const ListViewIcon = () => ; -const GridViewIcon = () => ; + + +const TASK_STAT_ICONS = { + total: '', + todo: '', + progress: '', + hold: '', + review: '', + done: '', +}; + +const TASK_TABLE_TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }; +const TASK_TABLE_TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; +const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }; +const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 }; +const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 }; +const TASK_MODAL_BUTTON_STYLE = { + lineHeight: 1, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', +}; +const TASK_MODAL_SHELL_STYLE = { + ...popupSurfaceStyle, + background: 'var(--card-bg)', + width: 'min(434px, 100%)', + maxHeight: 'calc(100vh - 48px)', + overflowY: 'auto', +}; +const TASK_STATUS_SORT_RANK = { + not_started: 0, + in_progress: 1, + on_hold: 2, + client_review: 3, + client_approved: 4, + invoiced: 5, + paid: 6, +}; + +function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) { + return ( +
+
+
{label}
+
+ +
+
+
+
{value}
+
+
+ ); +} + +function TasksStatsRow({ tasks = [] }) { + const total = tasks.length; + const toDo = tasks.filter((task) => task?.status === 'not_started').length; + const inProgress = tasks.filter((task) => task?.status === 'in_progress').length; + const onHold = tasks.filter((task) => task?.status === 'on_hold').length; + const inReview = tasks.filter((task) => task?.status === 'client_review').length; + const completed = tasks.filter((task) => ['client_approved', 'invoiced', 'paid'].includes(task?.status)).length; + return ( +
+
+ + + + + + +
+
+ ); +} + +function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) { + const [projSortKey, setProjSortKey] = useState('status'); + const [projSortDir, setProjSortDir] = useState('asc'); + const [projTab, setProjTab] = useState('all'); + const toggleProjSort = (col) => { + if (projSortKey === col) setProjSortDir(d => d === 'asc' ? 'desc' : 'asc'); + else { setProjSortKey(col); setProjSortDir('asc'); } + }; + const thStyle = TASK_TABLE_TH_STYLE; + const completedProjectStatuses = new Set(['completed', 'cancelled', 'archived', 'done']); + const projectTabs = [ + { id: 'all', label: 'All Projects' }, + { id: 'active', label: 'Active' }, + { id: 'completed', label: 'Completed' }, + ]; + const visibleProjects = projects.filter((p) => { + if (projTab === 'all') return true; + const isCompleted = completedProjectStatuses.has((p?.status || '').toLowerCase()); + if (projTab === 'completed') return isCompleted; + return !isCompleted; + }); + const projectStatusRank = (status) => { + const s = (status || '').toLowerCase(); + if (!completedProjectStatuses.has(s)) return 0; // Active-like statuses first + return 1; // Completed/cancelled/archived/done after + }; + const sortedProjects = [...visibleProjects].sort((a, b) => { + if (projSortKey === 'status') { + const av = projectStatusRank(a.status); + const bv = projectStatusRank(b.status); + if (av !== bv) return projSortDir === 'asc' ? av - bv : bv - av; + const an = (a.name || '').toLowerCase(); + const bn = (b.name || '').toLowerCase(); + return an.localeCompare(bn); + } + const av = (a.name || '').toLowerCase(); + const bv = (b.name || '').toLowerCase(); + return projSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); + }); + const projectCompletionPct = (project) => { + const projectTasks = statsTasks.filter((task) => task?.project_id === project?.id); + if (projectTasks.length > 0) { + const doneCount = projectTasks.filter((task) => ['client_approved', 'invoiced', 'paid'].includes((task?.status || '').toLowerCase())).length; + return Math.round((doneCount / projectTasks.length) * 100); + } + const status = (project?.status || '').toLowerCase(); + if (status === 'completed' || status === 'done') return 100; + if (status === 'cancelled' || status === 'archived') return 0; + return 35; + }; + return ( +
+ +
+
+ {children} +
+
+
+ {projectTabs.map((t) => ( + + ))} + {onAddProject && ( +
+ +
+ )} +
+
+
+ + + + + + + + Name + Status + + + + {sortedProjects.length === 0 ? ( + + ) : sortedProjects.map(p => ( + + + + + ))} + +
No projects.
+ {p.name} + + + + + + {projectCompletionPct(p)}% + +
+
+
+
+
+
+ ); +} export default function RequestsPage() { const { currentUser } = useAuth(); @@ -38,8 +241,8 @@ export default function RequestsPage() { const [error, setError] = useState(''); const [loadError, setLoadError] = useState(false); const [activeTab, setActiveTab] = useState('all'); - const [viewMode, setViewMode] = useState(() => localStorage.getItem('requestsViewMode') || 'list'); - const toggleView = () => setViewMode(v => { const n = v === 'list' ? 'grid' : 'list'; localStorage.setItem('requestsViewMode', n); return n; }); + + // ── Team-only state ──────────────────────────────────────────────────── const teamCached = isTeam ? readPageCache('team_requests') : null; @@ -48,14 +251,18 @@ export default function RequestsPage() { const [invoiceItems, setInvoiceItems] = useState(() => teamCached?.invoiceItems || []); const [filterCompany, setFilterCompany] = useState(''); const [filterUser, setFilterUser] = useState(''); - const { sortKey: reqSortKey, sortDir: reqSortDir, toggle: reqToggle, sort: reqSort } = useSortable('submitted_at', 'desc'); - const { sortKey: extSortKey, sortDir: extSortDir, toggle: extToggle, sort: extSort } = useSortable('submitted_at', 'desc'); - const { sortKey: clientSortKey, sortDir: clientSortDir, toggle: clientToggle, sort: clientSort } = useSortable('submitted_at', 'desc'); + const { sortKey: reqSortKey, sortDir: reqSortDir, toggle: reqToggle, sort: reqSort } = useSortable('status', 'asc'); + const { sortKey: extSortKey, sortDir: extSortDir, toggle: extToggle, sort: extSort } = useSortable('status', 'asc'); + const { sortKey: clientSortKey, sortDir: clientSortDir, toggle: clientToggle, sort: clientSort } = useSortable('status', 'asc'); const [showAddForm, setShowAddForm] = useState(false); const [addFormKey, setAddFormKey] = useState(0); const [addSaving, setAddSaving] = useState(false); const [addError, setAddError] = useState(''); const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID()); + const [showAddProjectForm, setShowAddProjectForm] = useState(false); + const [addProjectSaving, setAddProjectSaving] = useState(false); + const [addProjectError, setAddProjectError] = useState(''); + const [addProjectForm, setAddProjectForm] = useState({ name: '', companyId: '' }); // ── External-only state ──────────────────────────────────────────────── const extCacheKey = `ext-requests:${currentUser?.id}`; @@ -69,111 +276,118 @@ export default function RequestsPage() { const [clientInvoiceItems, setClientInvoiceItems] = useState([]); useEffect(() => { - if (isTeam) { - async function loadTeam() { - try { - const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([ - supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }), - supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at'), - supabase.from('projects').select('id, name, status, company_id'), - supabase.from('companies').select('id, name'), - supabase.from('invoices').select('id, status'), - supabase.from('invoice_items').select('task_id, invoice_id'), - ]), 12000, 'Requests load'); - setSubmissions(subs || []); - setTasks(t || []); - setProjects(p || []); - setCompanies(co || []); - setInvoices(inv || []); - setInvoiceItems(itemRows || []); - writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [], invoices: inv || [], invoiceItems: itemRows || [] }); - } catch (err) { - console.error('Requests load failed:', err); - setLoadError(true); - } finally { - setLoading(false); - } - } - if (teamCached) { - setSubmissions(teamCached.submissions || []); - setTasks(teamCached.tasks || []); - setProjects(teamCached.projects || []); - setCompanies(teamCached.companies || []); - setInvoices(teamCached.invoices || []); - setInvoiceItems(teamCached.invoiceItems || []); - setLoading(false); - } - loadTeam(); - } else if (isExternal) { - async function loadExternal() { - if (!currentUser?.id) { setLoading(false); return; } - try { - const [{ data: projectData }, { data: taskData }, { data: subData }, { data: paidItems }] = await withTimeout( - Promise.all([ - supabase.from('projects').select('id, name, company_id, company:companies(id, name)').order('created_at', { ascending: false }), - supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced, completed_at').order('submitted_at', { ascending: false }), - supabase.from('submissions').select('task_id, submitted_by_name, version_number, deadline, is_hot, service_type, submitted_at').order('submitted_at', { ascending: false }), - supabase.from('subcontractor_invoice_items').select('task_id, invoice:subcontractor_invoices!inner(status)').eq('subcontractor_invoices.status', 'paid'), - ]), - 15000, 'External requests load' + let cancelled = false; + async function loadTasksPage() { + if (!currentUser?.id) { setLoading(false); return; } + try { + setError(''); + setLoadError(false); + + let scopedTaskIds = null; + let scopedProjectIds = null; + + if (isClient) { + const { data: myInitialSubs } = await withTimeout( + supabase.from('submissions').select('task_id').eq('submitted_by', currentUser.id).eq('type', 'initial'), + 10000, 'Client task scope' ); - const paid = new Set((paidItems || []).filter(item => item.invoice?.status === 'paid' && item.task_id).map(item => item.task_id)); - setProjects(projectData || []); - setTasks(taskData || []); - setSubmissions(subData || []); - setPaidTaskIds(paid); - writePageCache(extCacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] }); - setError(''); - } catch (err) { - console.error('External requests load failed:', err); - setError(err.message || 'Failed to load requests.'); - } finally { - setLoading(false); + scopedTaskIds = (myInitialSubs || []).map((row) => row.task_id).filter(Boolean); } - } - if (extCached) { - setProjects(extCached.projects || []); - setTasks(extCached.tasks || []); - setSubmissions(extCached.submissions || []); - setLoading(false); - } else { - loadExternal(); - } - } else if (isClient) { - async function loadClient() { - try { - const { data: mySubs } = await withTimeout( - supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'), - 10000, 'My submissions' + + if (isExternal) { + const { data: memberData } = await withTimeout( + supabase.from('project_members').select('project_id').eq('profile_id', currentUser.id), + 10000, 'External project scope' ); - if (!mySubs || mySubs.length === 0) { setLoading(false); return; } - const myTaskIds = mySubs.map(s => s.task_id); - const [{ data: t }, { data: allSubs }, { data: inv }, { data: itemRows }] = await withTimeout( - Promise.all([ - supabase.from('tasks').select('*, project:projects(id, name, created_at, status, company_id)').in('id', myTaskIds), - supabase.from('submissions').select('task_id, submitted_by_name, version_number, type, service_type, deadline').in('task_id', myTaskIds).order('version_number'), - supabase.from('invoices').select('id, status'), - supabase.from('invoice_items').select('task_id, invoice_id').in('task_id', myTaskIds), - ]), - 12000, 'My requests data' - ); - const clientTasks = t || []; - setTasks(clientTasks); - setSubmissions(allSubs || []); - setClientInvoices(inv || []); - setClientInvoiceItems(itemRows || []); - const projectMap = {}; - clientTasks.forEach(task => { const p = task.project; if (p && !projectMap[p.id]) projectMap[p.id] = { ...p }; }); - setProjects(Object.values(projectMap)); - } catch (err) { - console.error('MyRequests load failed:', err); - setLoadError(true); - } finally { - setLoading(false); + scopedProjectIds = (memberData || []).map((row) => row.project_id).filter(Boolean); } + + const tasksQuery = supabase + .from('tasks') + .select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at') + .order('submitted_at', { ascending: false }); + if (isClient) { + if ((scopedTaskIds || []).length > 0) tasksQuery.in('id', scopedTaskIds); + else tasksQuery.eq('id', '__none__'); + } + if (isExternal) tasksQuery.eq('assigned_to', currentUser.id); + + const projectsQuery = supabase + .from('projects') + .select('id, name, status, company_id, company:companies(id, name)'); + if (isExternal) { + if ((scopedProjectIds || []).length > 0) projectsQuery.in('id', scopedProjectIds); + else projectsQuery.eq('id', '__none__'); + } + + const submissionsQuery = supabase + .from('submissions') + .select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type') + .order('submitted_at', { ascending: false }); + if (isClient) { + if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds); + else submissionsQuery.eq('id', '__none__'); + } + if (isExternal) submissionsQuery.eq('submitted_by', currentUser.id); + + const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }, { data: paidItems }] = await withTimeout(Promise.all([ + submissionsQuery, + tasksQuery, + projectsQuery, + supabase.from('companies').select('id, name'), + supabase.from('invoices').select('id, status'), + supabase.from('invoice_items').select('task_id, invoice_id'), + supabase.from('subcontractor_invoice_items').select('task_id, invoice:subcontractor_invoices!inner(status)').eq('subcontractor_invoices.status', 'paid'), + ]), 15000, 'Tasks page load'); + + if (cancelled) return; + + const paid = new Set((paidItems || []).filter(item => item.invoice?.status === 'paid' && item.task_id).map(item => item.task_id)); + const allProjects = p || []; + const allTasks = t || []; + const allSubs = subs || []; + const allCompanies = co || []; + + setProjects(allProjects); + setTasks(allTasks); + setSubmissions(allSubs); + setCompanies(allCompanies); + setInvoices(inv || []); + setInvoiceItems(itemRows || []); + setPaidTaskIds(paid); + + if (isTeam) { + writePageCache('team_requests', { submissions: allSubs, tasks: allTasks, projects: allProjects, companies: allCompanies, invoices: inv || [], invoiceItems: itemRows || [] }); + } else if (isExternal) { + writePageCache(extCacheKey, { projects: allProjects, tasks: allTasks, submissions: allSubs, paidTaskIds: [...paid] }); + } + } catch (err) { + console.error('Tasks page load failed:', err); + if (isExternal) setError(err.message || 'Failed to load tasks.'); + setLoadError(true); + } finally { + if (!cancelled) setLoading(false); } - loadClient(); } + + if (isTeam && teamCached) { + setSubmissions(teamCached.submissions || []); + setTasks(teamCached.tasks || []); + setProjects(teamCached.projects || []); + setCompanies(teamCached.companies || []); + setInvoices(teamCached.invoices || []); + setInvoiceItems(teamCached.invoiceItems || []); + setLoading(false); + } + if (isExternal && extCached) { + setProjects(extCached.projects || []); + setTasks(extCached.tasks || []); + setSubmissions(extCached.submissions || []); + setLoading(false); + } + + loadTasksPage(); + return () => { cancelled = true; }; }, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps const handleAddRequest = async (formData, _files, existingProjects) => { @@ -253,6 +467,31 @@ export default function RequestsPage() { } }; + const handleAddProject = async (e) => { + e.preventDefault(); + if (addProjectSaving) return; + setAddProjectSaving(true); + setAddProjectError(''); + try { + const name = addProjectForm.name.trim(); + if (!name) throw new Error('Project name required.'); + if (!addProjectForm.companyId) throw new Error('Company required.'); + const { data: newProject, error: insertError } = await supabase + .from('projects') + .insert({ name, company_id: addProjectForm.companyId, status: 'active' }) + .select('id, name, status, company_id, company:companies(id, name)') + .single(); + if (insertError) throw insertError; + setProjects((prev) => [...prev, newProject]); + setShowAddProjectForm(false); + setAddProjectForm({ name: '', companyId: '' }); + } catch (err) { + setAddProjectError(err.message || 'Failed to create project.'); + } finally { + setAddProjectSaving(false); + } + }; + if (loading) return

Loading...

; if (loadError) return

Failed to load. Check your connection and try again.

; @@ -273,41 +512,39 @@ export default function RequestsPage() { if (filterProject && task?.project_id !== filterProject) return false; return true; }).sort((a, b) => Math.max(...b.group.map(s => new Date(s.submitted_at).getTime())) - Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()))); + const filteredTasksForStats = filteredGroups.map(({ task }) => task).filter(Boolean); const byStatus = (s) => filteredGroups.filter(({ task }) => task?.status === s); + const thStyle = TASK_TABLE_TH_STYLE; + const tdBase = TASK_TABLE_TD_BASE; const renderRow = ({ task, primary }) => { const project = projects.find(p => p.id === task?.project_id); - const company = companies.find(co => co.id === project?.company_id); const serviceType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary.service_type || '—'; return ( - task && navigate(`/requests/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}> - + +
- {task?.title || '—'} + {task?.title || '—'} {`R${String(primary.version_number ?? 0).padStart(2, '0')}`} {primary.is_hot ? HOT : null}
-
{project?.name || '—'}
- {company ? e.stopPropagation()}>{company.name} : 'No client'} - {serviceType} - {fmtShortDate(primary.deadline, 'Not specified')} - {fmtShortDate(task?.completed_at)} - + {project ? {project.name} : '—'} + {serviceType} + {fmtShortDate(primary.deadline, 'Not specified')} + ); }; + const completedStatuses = new Set(['client_approved', 'invoiced', 'paid']); const teamTabs = [ - { id: 'all', label: 'All', groups: filteredGroups }, - { id: 'not_started', label: 'Not Started', groups: byStatus('not_started') }, + { id: 'all', label: 'All Tasks', groups: filteredGroups }, + { id: 'not_started', label: 'To Do', groups: byStatus('not_started') }, { id: 'in_progress', label: 'In Progress', groups: byStatus('in_progress') }, - { id: 'on_hold', label: 'On Hold', groups: byStatus('on_hold') }, { id: 'client_review', label: 'In Review', groups: byStatus('client_review') }, - { id: 'client_approved', label: 'Approved', groups: byStatus('client_approved') }, - { id: 'invoiced', label: 'Invoiced', groups: byStatus('invoiced') }, - { id: 'paid', label: 'Paid', groups: byStatus('paid') }, + { id: 'completed', label: 'Completed', groups: filteredGroups.filter(({ task }) => completedStatuses.has(task?.status)) }, ]; const rawGroups = teamTabs.find(t => t.id === activeTab)?.groups || []; const currentGroups = reqSort(rawGroups, ({ task, primary }, key) => { @@ -317,10 +554,9 @@ export default function RequestsPage() { if (key === 'title') return task?.title || ''; if (key === 'serviceType') return primary?.service_type || ''; if (key === 'revision') return primary?.version_number ?? 0; - if (key === 'client') return company?.name || ''; + if (key === 'project') return project?.name || ''; if (key === 'deadline') return primary?.deadline || ''; - if (key === 'completed_at') return task?.completed_at || ''; - if (key === 'status') return task?.status || ''; + if (key === 'status') return TASK_STATUS_SORT_RANK[task?.status] ?? 99; if (key === 'submitted_at') return primary?.submitted_at || ''; return ''; }); @@ -331,10 +567,44 @@ export default function RequestsPage() { return ( + { setShowAddProjectForm(true); setAddProjectError(''); }} + > + {showAddProjectForm && ( +
{ setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}> +
e.stopPropagation()}> +
+
Add Project
+
+
+
+ + +
+
+ + setAddProjectForm((f) => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} /> +
+ {addProjectError &&
{addProjectError}
} +
+ + +
+
+
+
+ )} {showAddForm && ( -
{ setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}> -
e.stopPropagation()}> -
Add Request
+
{ setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}> +
e.stopPropagation()}> +
+
Add Task
+
{ setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} saving={addSaving} error={addError} - submitLabel="Add Request" + submitLabel="Save" />
)} -
- {companies.length > 0 && ( - - )} - {filterCompany && (() => { const co_projects = projects.filter(p => p.company_id === filterCompany); return co_projects.length > 0 ? ( - - ) : null; })()} -
- - - + ))} +
+
+
{submissions.length === 0 ? ( -
No requests yet.
- ) : filteredGroups.length === 0 ? ( -
No matching requests.
+
No tasks yet.
) : currentGroups.length === 0 ? ( -
No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
- ) : viewMode === 'grid' ? ( -
-
- {currentGroups.map(({ task, primary }) => { - const project = projects.find(p => p.id === task?.project_id); - const company = companies.find(co => co.id === project?.company_id); - const serviceType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || primary?.service_type || '—'; - return ( -
navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}> -
-
-
{task?.title || '—'}
-
{project?.name || '—'}
-
- -
- {company &&
{company.name}
} -
- {serviceType} - {fmtShortDate(primary?.deadline)} -
-
- ); - })} -
-
+
No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} tasks.
) : ( -
- +
+
- - + - Name - Client - Request Type - Deadline - Approved - Status + Name + Project + Task Type + Deadline + Status{currentGroups.map(group => renderRow(group))}
)} +
+ ); } @@ -452,16 +700,15 @@ export default function RequestsPage() { if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) return false; return true; }).sort((a, b) => Math.max(...b.group.map(s => new Date(s.submitted_at).getTime())) - Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()))); + const filteredTasksExtForStats = filteredGroupsExt.map(({ task }) => task).filter(Boolean); const byStatusExt = (s) => filteredGroupsExt.filter(({ task }) => task?.status === s); const extTabs = [ - { id: 'all', label: 'All', groups: filteredGroupsExt }, - { id: 'not_started', label: 'Not Started', groups: byStatusExt('not_started') }, + { id: 'all', label: 'All Tasks', groups: filteredGroupsExt }, + { id: 'not_started', label: 'To Do', groups: byStatusExt('not_started') }, { id: 'in_progress', label: 'In Progress', groups: byStatusExt('in_progress') }, { id: 'on_hold', label: 'On Hold', groups: byStatusExt('on_hold') }, { id: 'client_review', label: 'In Review', groups: byStatusExt('client_review') }, - { id: 'client_approved', label: 'Approved', groups: byStatusExt('client_approved') }, - { id: 'invoiced', label: 'Invoiced', groups: byStatusExt('invoiced') }, - { id: 'paid', label: 'Paid', groups: byStatusExt('paid') }, + { id: 'completed', label: 'Completed', groups: filteredGroupsExt.filter(({ task }) => ['client_approved', 'invoiced', 'paid'].includes(task?.status)) }, ]; const rawExtGroups = extTabs.find(t => t.id === activeTab)?.groups || []; const currentExtGroups = extSort(rawExtGroups, ({ task, primary }, key) => { @@ -469,10 +716,9 @@ export default function RequestsPage() { if (key === 'title') return task?.title || ''; if (key === 'serviceType') return submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || ''; if (key === 'revision') return primary?.version_number ?? 0; - if (key === 'client') return project?.company?.name || ''; + if (key === 'project') return project?.name || ''; if (key === 'deadline') return primary?.deadline || ''; - if (key === 'completed_at') return task?.completed_at || ''; - if (key === 'status') return task?.status || ''; + if (key === 'status') return TASK_STATUS_SORT_RANK[task?.status] ?? 99; if (key === 'submitted_at') return primary?.submitted_at || ''; return ''; }); @@ -480,20 +726,18 @@ export default function RequestsPage() { const project = projects.find(p => p.id === task?.project_id); const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary.service_type || '—'; return ( - navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}> - + +
- {task?.title || '—'} + {task?.title || '—'} {`R${String(primary.version_number ?? 0).padStart(2, '0')}`} {primary.is_hot ? HOT : null}
-
{project?.name || '—'}
- {project?.company?.name || '—'} - {extServiceType} - {fmtShortDate(primary.deadline, 'Not specified')} - {fmtShortDate(task?.completed_at)} - + {project ? {project.name} : '—'} + {extServiceType} + {fmtShortDate(primary.deadline, 'Not specified')} + ); }; @@ -504,73 +748,64 @@ export default function RequestsPage() { return ( -
+ +
{projectNames.length > 0 && ( )} -
- - +
+ {extTabs.map(t => ( + + ))}
{submissions.length === 0 ? ( -
No requests yet.
+
No tasks yet.
) : filteredGroupsExt.length === 0 ? ( -
No matching requests.
+
No matching tasks.
) : currentExtGroups.length === 0 ? ( -
No {extTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
- ) : viewMode === 'grid' ? ( -
-
- {currentExtGroups.map(({ task, primary }) => { - const project = projects.find(p => p.id === task?.project_id); - const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || '—'; - return ( -
navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}> -
-
-
{task?.title || '—'}
-
{project?.name || '—'}
-
- -
- {project?.company?.name &&
{project.company.name}
} -
- {extServiceType} - {fmtShortDate(primary?.deadline)} -
-
- ); - })} -
-
+
No {extTabs.find(t => t.id === activeTab)?.label.toLowerCase()} tasks.
) : ( -
- +
+
- - + - Name - Client - Request Type - Deadline - Approved - Status + Name + Project + Task Type + Deadline + Status{currentExtGroups.map(group => renderExtRow(group))} @@ -578,6 +813,7 @@ export default function RequestsPage() { )} {error &&
{error}
} + ); } @@ -594,34 +830,69 @@ export default function RequestsPage() { }); const byStatusClientFiltered = (s) => clientFilteredTasks.filter(t => t.status === s); const clientTabs = [ - { id: 'all', label: 'All', tasks: clientFilteredTasks }, - { id: 'not_started', label: 'Not Started', tasks: byStatusClientFiltered('not_started') }, + { id: 'all', label: 'All Tasks', tasks: clientFilteredTasks }, + { id: 'not_started', label: 'To Do', tasks: byStatusClientFiltered('not_started') }, { id: 'in_progress', label: 'In Progress', tasks: byStatusClientFiltered('in_progress') }, { id: 'on_hold', label: 'On Hold', tasks: byStatusClientFiltered('on_hold') }, { id: 'client_review', label: 'In Review', tasks: byStatusClientFiltered('client_review') }, - { id: 'client_approved', label: 'Approved', tasks: byStatusClientFiltered('client_approved') }, - { id: 'invoiced', label: 'Invoiced', tasks: byStatusClientFiltered('invoiced') }, - { id: 'paid', label: 'Paid', tasks: byStatusClientFiltered('paid') }, + { id: 'completed', label: 'Completed', tasks: clientFilteredTasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t.status)) }, ]; const rawClientTasks = clientTabs.find(t => t.id === activeTab)?.tasks || []; const currentClientTasks = clientSort(rawClientTasks, (task, key) => { if (key === 'title') return task?.title || ''; if (key === 'serviceType') return submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || ''; if (key === 'revision') return task?.current_version ?? 0; - if (key === 'client') return (clientCompanies.find(c => c.id === task.project?.company_id))?.name || ''; + if (key === 'project') return task.project?.name || ''; if (key === 'deadline') return submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.deadline || ''; - if (key === 'completed_at') return task?.completed_at || ''; - if (key === 'status') return task?.status || ''; + if (key === 'status') return TASK_STATUS_SORT_RANK[task?.status] ?? 99; if (key === 'submitted_at') return task?.submitted_at || ''; return ''; }); return ( + { + setAddProjectForm((f) => ({ ...f, companyId: clientCompanies.length === 1 ? clientCompanies[0].id : f.companyId })); + setShowAddProjectForm(true); + setAddProjectError(''); + }} + > + {showAddProjectForm && ( +
{ setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}> +
e.stopPropagation()}> +
+
Add Project
+
+
+
+ + +
+
+ + setAddProjectForm((f) => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} /> +
+ {addProjectError &&
{addProjectError}
} +
+ + +
+ +
+
+ )} {showAddForm && ( -
{ setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}> -
e.stopPropagation()}> -
New Request
+
{ setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}> +
e.stopPropagation()}> +
+
New Task
+
{ setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} saving={addSaving} error={addError} - submitLabel="Submit Request" + submitLabel="Save" />
)} -
+
{clientCompanies.length > 1 && ( ) : null; })()} -
- - - +
+ {clientTabs.map(t => ( + + ))} +
{tasks.length === 0 ? ( -
No requests yet.
+
No tasks yet.
) : currentClientTasks.length === 0 ? ( -
No {clientTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
- ) : viewMode === 'grid' ? ( -
-
- {currentClientTasks.map(task => { - const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial'); - const clientCo = clientCompanies.find(c => c.id === task.project?.company_id); - return ( -
navigate(`/requests/${task.id}`)} style={{ padding: '14px 16px', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}> -
-
-
{task.title}
-
{task.project?.name || '—'}
-
- -
- {clientCo &&
{clientCo.name}
} -
- {clientSub?.service_type || '—'} - {fmtShortDate(clientSub?.deadline)} -
-
- ); - })} -
-
+
No {clientTabs.find(t => t.id === activeTab)?.label.toLowerCase()} tasks.
) : ( -
-
+
+
- - + - Name - Client - Request Type - Deadline - Approved - Status + Name + Project + Task Type + Deadline + Status {currentClientTasks.map(task => { const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial'); - const clientCo = clientCompanies.find(c => c.id === task.project?.company_id); return ( - navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}> - + - - - - - + + + + ); })} @@ -736,6 +994,7 @@ export default function RequestsPage() {
+
- {task.title} + {task.title} {`R${String(task.current_version || 0).padStart(2, '0')}`}
-
{task.project?.name || '—'}
{clientCo?.name || '—'}{clientSub?.service_type || '—'}{fmtShortDate(clientSub?.deadline)}{fmtShortDate(task.completed_at)}{task.project ? {task.project.name} : '—'}{clientSub?.service_type || '—'}{fmtShortDate(clientSub?.deadline)}
)} + ); } diff --git a/src/pages/team/CreateInvoice.jsx b/src/pages/team/CreateInvoice.jsx index abb739b..90fad4b 100644 --- a/src/pages/team/CreateInvoice.jsx +++ b/src/pages/team/CreateInvoice.jsx @@ -117,7 +117,13 @@ export default function CreateInvoice() { return { ...t, service_type: initial?.service_type || t.title }; }); setUninvoicedTasks(tasksWithService); - setUninvoicedRevisions(revisions || []); + // Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges + const revMap = new Map(); + for (const rev of (revisions || [])) { + const key = `${rev.task_id}:${rev.version_number}`; + if (!revMap.has(key)) revMap.set(key, rev); + } + setUninvoicedRevisions([...revMap.values()]); } else { setUninvoicedTasks([]); setUninvoicedRevisions([]);