Refactor: clients → companies schema v2
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
||||
/* unused - styles are in index.css */
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
import Login from './pages/Login';
|
||||
import Signup from './pages/Signup';
|
||||
import SignupConfirmation from './pages/SignupConfirmation';
|
||||
|
||||
import Dashboard from './pages/team/Dashboard';
|
||||
import Companies from './pages/team/Companies';
|
||||
import CompanyDetail from './pages/team/CompanyDetail';
|
||||
import ProjectDetail from './pages/team/ProjectDetail';
|
||||
import TaskDetail from './pages/team/TaskDetail';
|
||||
import Requests from './pages/team/Requests';
|
||||
import Invoices from './pages/team/Invoices';
|
||||
import CreateInvoice from './pages/team/CreateInvoice';
|
||||
import InvoiceDetail from './pages/team/InvoiceDetail';
|
||||
|
||||
import Settings from './pages/Settings';
|
||||
import MyRequests from './pages/client/MyRequests';
|
||||
import MyProjects from './pages/client/MyProjects';
|
||||
import MyProjectDetail from './pages/client/MyProjectDetail';
|
||||
import MyInvoices from './pages/client/MyInvoices';
|
||||
import RequestDetail from './pages/client/RequestDetail';
|
||||
import NewRequest from './pages/client/NewRequest';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/signup-confirmation" element={<SignupConfirmation />} />
|
||||
|
||||
<Route path="/dashboard" element={<ProtectedRoute role="team"><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/companies" element={<ProtectedRoute role="team"><Companies /></ProtectedRoute>} />
|
||||
<Route path="/companies/:id" element={<ProtectedRoute role="team"><CompanyDetail /></ProtectedRoute>} />
|
||||
<Route path="/projects/:id" element={<ProtectedRoute role="team"><ProjectDetail /></ProtectedRoute>} />
|
||||
<Route path="/tasks/:id" element={<ProtectedRoute role="team"><TaskDetail /></ProtectedRoute>} />
|
||||
<Route path="/requests" element={<ProtectedRoute role="team"><Requests /></ProtectedRoute>} />
|
||||
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
|
||||
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
|
||||
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><InvoiceDetail /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/my-requests" element={<ProtectedRoute role="client"><MyRequests /></ProtectedRoute>} />
|
||||
<Route path="/my-requests/:id" element={<ProtectedRoute role="client"><RequestDetail /></ProtectedRoute>} />
|
||||
<Route path="/my-projects" element={<ProtectedRoute role="client"><MyProjects /></ProtectedRoute>} />
|
||||
<Route path="/my-projects/:id" element={<ProtectedRoute role="client"><MyProjectDetail /></ProtectedRoute>} />
|
||||
<Route path="/my-invoices" element={<ProtectedRoute role="client"><MyInvoices /></ProtectedRoute>} />
|
||||
<Route path="/new-request" element={<ProtectedRoute role="client"><NewRequest /></ProtectedRoute>} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Executable
+1
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
Executable
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
Executable
+92
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const MAX_FILES = 20;
|
||||
const MAX_SIZE_MB = 10;
|
||||
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
export default function FileAttachment({ files, onChange }) {
|
||||
const [errors, setErrors] = useState([]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const incoming = Array.from(e.target.files);
|
||||
const combined = [...files, ...incoming];
|
||||
const errs = [];
|
||||
|
||||
if (combined.length > MAX_FILES) {
|
||||
errs.push(`Maximum ${MAX_FILES} files allowed.`);
|
||||
}
|
||||
incoming.filter(f => f.size > MAX_SIZE_BYTES)
|
||||
.forEach(f => errs.push(`"${f.name}" exceeds ${MAX_SIZE_MB} MB limit.`));
|
||||
|
||||
if (errs.length > 0) { setErrors(errs); return; }
|
||||
setErrors([]);
|
||||
onChange(combined);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const remove = (index) => {
|
||||
setErrors([]);
|
||||
onChange(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Attach Files
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>
|
||||
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each · Any file type
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{
|
||||
border: `2px dashed ${files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8, padding: '18px 16px', textAlign: 'center',
|
||||
background: files.length > 0 ? '#fffbeb' : '#fafafa', transition: 'all 0.15s',
|
||||
}}>
|
||||
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" />
|
||||
<label htmlFor="req-file-upload" style={{ cursor: 'pointer' }}>
|
||||
<div style={{ fontSize: 22, marginBottom: 4 }}>📎</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{files.length > 0 ? `${files.length} file${files.length !== 1 ? 's' : ''} attached — click to add more` : 'Click to attach files'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && errors.map((err, i) => (
|
||||
<div key={i} style={{ fontSize: 12, color: 'var(--danger)', marginTop: 6 }}>⚠ {err}</div>
|
||||
))}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{files.map((file, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '7px 12px', background: 'white', borderRadius: 8, border: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📄</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => remove(i)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: '0 4px' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textAlign: 'right' }}>
|
||||
{files.length}/{MAX_FILES} files
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
function TeamNav({ onNav }) {
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-label">Main</div>
|
||||
{[
|
||||
{ to: '/dashboard', label: 'Dashboard' },
|
||||
{ to: '/companies', label: 'Companies' },
|
||||
{ to: '/requests', label: 'Requests' },
|
||||
{ to: '/invoices', label: 'Invoices' },
|
||||
].map(({ to, label }) => (
|
||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientNav({ onNav }) {
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-label">My Work</div>
|
||||
{[
|
||||
{ to: '/my-projects', label: 'Projects' },
|
||||
{ to: '/my-invoices', label: 'Invoices' },
|
||||
].map(({ to, label }) => (
|
||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// Close menu on route change
|
||||
useEffect(() => { setMenuOpen(false); }, [location.pathname]);
|
||||
|
||||
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
|
||||
const handleLogout = () => { logout(); navigate('/'); };
|
||||
|
||||
const initials = currentUser?.name
|
||||
?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{/* Overlay */}
|
||||
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
|
||||
|
||||
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 140, display: 'block' }} />
|
||||
</div>
|
||||
|
||||
{currentUser?.role === 'team'
|
||||
? <TeamNav onNav={() => setMenuOpen(false)} />
|
||||
: <ClientNav onNav={() => setMenuOpen(false)} />}
|
||||
|
||||
<div className="sidebar-bottom">
|
||||
<NavLink to="/settings" onClick={() => setMenuOpen(false)} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
<div className="sidebar-avatar" style={{ width: 28, height: 28, fontSize: 11, flexShrink: 0 }}>{initials}</div>
|
||||
<div className="sidebar-user-info">
|
||||
<div className="sidebar-user-name">{currentUser?.name || 'Set your name'}</div>
|
||||
<div className="sidebar-user-role">{currentUser?.role}</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '0 12px', gap: 8 }}>
|
||||
<button className="sidebar-link" style={{ flex: 1 }} onClick={handleLogout}>Sign Out</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #333', borderRadius: '6px',
|
||||
padding: '7px 10px', cursor: 'pointer', color: '#888',
|
||||
fontSize: 13, lineHeight: 1, transition: 'all 0.15s', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{theme === 'dark' ? '☀' : '☾'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="main-wrapper">
|
||||
{/* Mobile top bar inside main wrapper so it sits at the top */}
|
||||
<div className="mobile-topbar">
|
||||
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
|
||||
<span /><span /><span />
|
||||
</button>
|
||||
</div>
|
||||
<main className="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function ProtectedRoute({ children, role }) {
|
||||
const { currentUser } = useAuth();
|
||||
if (!currentUser) return <Navigate to="/" replace />;
|
||||
if (role && currentUser.role !== role) {
|
||||
return <Navigate to={currentUser.role === 'team' ? '/dashboard' : '/my-requests'} replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
const labels = {
|
||||
not_started: 'Not Started',
|
||||
in_progress: 'In Progress',
|
||||
on_hold: 'On Hold',
|
||||
client_review: 'Client Review',
|
||||
client_approved: 'Client Approved',
|
||||
active: 'Active',
|
||||
completed: 'Completed',
|
||||
initial: 'Initial',
|
||||
revision: 'Revision',
|
||||
team: 'Team',
|
||||
client: 'Client',
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
return (
|
||||
<span className={`badge badge-${status}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchProfile = async (authUser) => {
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('*, company:companies(id, name, email)')
|
||||
.eq('id', authUser.id)
|
||||
.single();
|
||||
if (data) setCurrentUser({ ...data, email: authUser.email });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setLoading(false), 5000);
|
||||
|
||||
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
||||
if (session?.user) await fetchProfile(session.user);
|
||||
clearTimeout(timeout);
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
clearTimeout(timeout);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (session?.user) {
|
||||
await fetchProfile(session.user);
|
||||
} else {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
};
|
||||
|
||||
const signup = async (email, password, name) => {
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { data: { name, role: 'client' } },
|
||||
});
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
setCurrentUser(null);
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div style={{
|
||||
minHeight: '100vh', background: '#111111',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 160, opacity: 0.6 }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ currentUser, login, signup, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
Executable
+270
@@ -0,0 +1,270 @@
|
||||
export const mockUsers = [
|
||||
{ id: '1', name: 'Jordan Lee', email: 'jordan@fourgebranding.com', role: 'team', company: 'Fourge Branding' },
|
||||
{ id: '2', name: 'Alex Rivera', email: 'alex@fourgebranding.com', role: 'team', company: 'Fourge Branding' },
|
||||
{ id: '3', name: 'Morgan Chen', email: 'morgan@fourgebranding.com', role: 'team', company: 'Fourge Branding' },
|
||||
{ id: '4', name: 'Taylor Brooks', email: 'taylor@fourgebranding.com', role: 'team', company: 'Fourge Branding' },
|
||||
{ id: '5', name: 'John Smith', email: 'john@acmecorp.com', role: 'client', company: 'Acme Corp' },
|
||||
{ id: '6', name: 'Sarah Johnson', email: 'sarah@techstartup.com', role: 'client', company: 'Tech Startup Inc' },
|
||||
];
|
||||
|
||||
export const serviceTypes = [
|
||||
'Logo Design',
|
||||
'Brand Identity',
|
||||
'Brand Guidelines',
|
||||
'Brand Book',
|
||||
'Social Media Graphics',
|
||||
'Print Design',
|
||||
'Business Cards',
|
||||
'Packaging Design',
|
||||
'Web Design',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const teamMembers = mockUsers.filter(u => u.role === 'team');
|
||||
|
||||
export const mockClients = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'John Smith',
|
||||
email: 'john@acmecorp.com',
|
||||
company: 'Acme Corp',
|
||||
type: 'registered',
|
||||
userId: '5',
|
||||
createdAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah@techstartup.com',
|
||||
company: 'Tech Startup Inc',
|
||||
type: 'registered',
|
||||
userId: '6',
|
||||
createdAt: '2025-01-18',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Mike Davis',
|
||||
email: 'mike@davisco.com',
|
||||
company: 'Davis & Co',
|
||||
type: 'guest',
|
||||
userId: null,
|
||||
createdAt: '2025-01-22',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Emma Bloom',
|
||||
email: 'emma@bloombakery.com',
|
||||
company: 'Bloom Bakery',
|
||||
type: 'guest',
|
||||
userId: null,
|
||||
createdAt: '2024-12-10',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockProjects = [
|
||||
{
|
||||
id: '1',
|
||||
clientId: '1',
|
||||
name: '2025 Brand Refresh',
|
||||
description: 'Full brand identity overhaul including logo, guidelines, and print materials.',
|
||||
status: 'active',
|
||||
createdAt: '2025-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
clientId: '1',
|
||||
name: 'Social Media Kit',
|
||||
description: 'Social media templates and graphics package.',
|
||||
status: 'active',
|
||||
createdAt: '2025-02-01',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
clientId: '2',
|
||||
name: 'Launch Package',
|
||||
description: 'Everything needed for the startup launch — logo, brand, web.',
|
||||
status: 'active',
|
||||
createdAt: '2025-01-20',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
clientId: '3',
|
||||
name: 'Business Card Design',
|
||||
description: 'Single business card design for consulting firm.',
|
||||
status: 'active',
|
||||
createdAt: '2025-01-22',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
clientId: '4',
|
||||
name: 'Packaging & Brand',
|
||||
description: 'Packaging design and brand identity for bakery.',
|
||||
status: 'completed',
|
||||
createdAt: '2024-12-10',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockTasks = [
|
||||
{
|
||||
id: '1',
|
||||
projectId: '1',
|
||||
title: 'Logo Design',
|
||||
assignedTo: '1',
|
||||
assignedName: 'Jordan Lee',
|
||||
status: 'client_review',
|
||||
currentVersion: 1,
|
||||
submittedAt: '2025-01-15',
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
projectId: '1',
|
||||
title: 'Brand Book',
|
||||
assignedTo: '2',
|
||||
assignedName: 'Alex Rivera',
|
||||
status: 'in_progress',
|
||||
currentVersion: 0,
|
||||
submittedAt: '2025-01-15',
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
projectId: '1',
|
||||
title: 'Business Cards',
|
||||
assignedTo: null,
|
||||
assignedName: null,
|
||||
status: 'not_started',
|
||||
currentVersion: 0,
|
||||
submittedAt: '2025-01-15',
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
projectId: '2',
|
||||
title: 'Instagram Templates',
|
||||
assignedTo: '3',
|
||||
assignedName: 'Morgan Chen',
|
||||
status: 'not_started',
|
||||
currentVersion: 0,
|
||||
submittedAt: '2025-02-01',
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
projectId: '3',
|
||||
title: 'Logo Design',
|
||||
assignedTo: '1',
|
||||
assignedName: 'Jordan Lee',
|
||||
status: 'not_started',
|
||||
currentVersion: 1,
|
||||
submittedAt: '2025-01-20',
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
projectId: '4',
|
||||
title: 'Business Card Design',
|
||||
assignedTo: null,
|
||||
assignedName: null,
|
||||
status: 'not_started',
|
||||
currentVersion: 0,
|
||||
submittedAt: '2025-01-22',
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
projectId: '5',
|
||||
title: 'Packaging Design',
|
||||
assignedTo: '3',
|
||||
assignedName: 'Morgan Chen',
|
||||
status: 'client_approved',
|
||||
currentVersion: 2,
|
||||
submittedAt: '2024-12-10',
|
||||
completedAt: '2025-01-05',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSubmissions = [
|
||||
{
|
||||
id: '1',
|
||||
taskId: '1',
|
||||
versionNumber: 1,
|
||||
type: 'initial',
|
||||
serviceType: 'Logo Design',
|
||||
deadline: '2025-02-15',
|
||||
description: 'We need a modern, minimalist logo for our company. Colors should be blue and white. We want something that conveys trust and professionalism.',
|
||||
submittedBy: '5',
|
||||
submittedByName: 'John Smith',
|
||||
submittedAt: '2025-01-15',
|
||||
delivery: {
|
||||
files: [{ name: 'AcmeCorp_Logo_v00.pdf' }, { name: 'AcmeCorp_Logo_v00_dark.pdf' }],
|
||||
sentAt: '2025-01-22',
|
||||
sentBy: 'Jordan Lee',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
taskId: '1',
|
||||
versionNumber: 2,
|
||||
type: 'revision',
|
||||
serviceType: 'Logo Design',
|
||||
deadline: '2025-02-20',
|
||||
description: 'We like the direction but would like the font to be bolder and the icon slightly larger. Also please try a version with a dark background.',
|
||||
submittedBy: '5',
|
||||
submittedByName: 'John Smith',
|
||||
submittedAt: '2025-01-25',
|
||||
delivery: {
|
||||
files: [{ name: 'AcmeCorp_Logo_v01.pdf' }],
|
||||
sentAt: '2025-01-30',
|
||||
sentBy: 'Jordan Lee',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
taskId: '2',
|
||||
versionNumber: 1,
|
||||
type: 'initial',
|
||||
serviceType: 'Brand Book',
|
||||
deadline: '2025-03-01',
|
||||
description: 'Full brand book including color palette, typography, logo usage rules, and example applications.',
|
||||
submittedBy: '5',
|
||||
submittedByName: 'John Smith',
|
||||
submittedAt: '2025-01-15',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
taskId: '5',
|
||||
versionNumber: 1,
|
||||
type: 'initial',
|
||||
serviceType: 'Logo Design',
|
||||
deadline: '2025-02-28',
|
||||
description: 'Need a fresh, modern logo for our tech startup. We are in the AI space and want something futuristic but approachable.',
|
||||
submittedBy: '6',
|
||||
submittedByName: 'Sarah Johnson',
|
||||
submittedAt: '2025-01-20',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
taskId: '5',
|
||||
versionNumber: 2,
|
||||
type: 'revision',
|
||||
serviceType: 'Logo Design',
|
||||
deadline: '2025-03-05',
|
||||
description: 'The logo looks good but we want to explore a different color direction — green and purple. The icon feels too corporate.',
|
||||
submittedBy: '6',
|
||||
submittedByName: 'Sarah Johnson',
|
||||
submittedAt: '2025-01-28',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
taskId: '6',
|
||||
versionNumber: 1,
|
||||
type: 'initial',
|
||||
serviceType: 'Business Cards',
|
||||
deadline: '2025-02-10',
|
||||
description: 'Standard business card design for our consulting firm. We have a logo already, just need a professional card layout with clean typography.',
|
||||
submittedBy: null,
|
||||
submittedByName: 'Mike Davis (Guest)',
|
||||
submittedAt: '2025-01-22',
|
||||
},
|
||||
];
|
||||
Executable
+380
@@ -0,0 +1,380 @@
|
||||
@font-face {
|
||||
font-family: 'Fourge';
|
||||
src: url('/font.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--sidebar-bg: #0d0d0d;
|
||||
--sidebar-text: #888888;
|
||||
--sidebar-active-text: #ffffff;
|
||||
--sidebar-active-bg: #1a1a1a;
|
||||
--sidebar-hover-bg: #1a1a1a;
|
||||
--accent: #F5A523;
|
||||
--accent-hover: #e09510;
|
||||
--bg: #111111;
|
||||
--card-bg: #1a1a1a;
|
||||
--card-bg-2: #222222;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a8a8a8;
|
||||
--text-muted: #666666;
|
||||
--border: #2a2a2a;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--sidebar-bg: #1a1a1a;
|
||||
--sidebar-text: #888888;
|
||||
--sidebar-active-text: #ffffff;
|
||||
--sidebar-active-bg: #2a2a2a;
|
||||
--sidebar-hover-bg: #2a2a2a;
|
||||
--bg: #f4f4f4;
|
||||
--card-bg: #ffffff;
|
||||
--card-bg-2: #f0f0f0;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #5a5a5a;
|
||||
--text-muted: #999999;
|
||||
--border: #e0e0e0;
|
||||
}
|
||||
[data-theme="light"] input[type="text"],
|
||||
[data-theme="light"] input[type="email"],
|
||||
[data-theme="light"] input[type="date"],
|
||||
[data-theme="light"] input[type="password"],
|
||||
[data-theme="light"] select,
|
||||
[data-theme="light"] textarea {
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
[data-theme="light"] input::placeholder,
|
||||
[data-theme="light"] textarea::placeholder { color: #aaa; }
|
||||
[data-theme="light"] th { background: #fafafa; }
|
||||
[data-theme="light"] tr:hover td { background: #fafafa; }
|
||||
[data-theme="light"] .assign-select { background: #fff; color: #1a1a1a; }
|
||||
[data-theme="light"] .badge-not_started { background: #f1f5f9; color: #64748b; border-color: #e2e8f0; }
|
||||
[data-theme="light"] .badge-in_progress { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
||||
[data-theme="light"] .badge-on_hold { background: #fffbeb; color: #d97706; border-color: #fde68a; }
|
||||
[data-theme="light"] .badge-client_review { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; }
|
||||
[data-theme="light"] .badge-client_approved { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||
[data-theme="light"] .badge-sent_to_client { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; }
|
||||
[data-theme="light"] .badge-revision_requested { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
|
||||
[data-theme="light"] .badge-approved { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||
[data-theme="light"] .badge-active { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
||||
[data-theme="light"] .badge-completed { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||
[data-theme="light"] .badge-initial { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||
[data-theme="light"] .badge-revision { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
|
||||
[data-theme="light"] .badge-team { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; }
|
||||
[data-theme="light"] .badge-client { background: #fffbeb; color: #b45309; border-color: #fde68a; }
|
||||
[data-theme="light"] .notification-success { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||
[data-theme="light"] .notification-info { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
||||
[data-theme="light"] .btn-outline { color: #1a1a1a; border-color: #d0d0d0; }
|
||||
[data-theme="light"] .btn-outline:hover { background: #f0f0f0; }
|
||||
[data-theme="light"] .btn-danger { color: var(--danger); border-color: var(--danger); }
|
||||
[data-theme="light"] .btn-danger:hover { background: var(--danger); color: white; }
|
||||
[data-theme="light"] select option { background: #fff; color: #1a1a1a; }
|
||||
[data-theme="light"] .assign-select option { background: #fff; color: #1a1a1a; }
|
||||
|
||||
body {
|
||||
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#root { all: unset; display: block; }
|
||||
|
||||
/* Layout */
|
||||
.app-layout { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
background: var(--sidebar-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 0;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 0 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sidebar-logo h1 { font-size: 18px; font-weight: 700; color: #fff; letter-spacing: -0.3px; }
|
||||
.sidebar-logo span { font-size: 12px; color: var(--sidebar-text); }
|
||||
|
||||
.sidebar-section { padding: 0 12px; margin-bottom: 8px; }
|
||||
.sidebar-section-label {
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.8px; color: #444; padding: 0 8px; margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 12px; border-radius: 6px;
|
||||
color: var(--sidebar-text); text-decoration: none;
|
||||
font-size: 13px; font-weight: 500;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
cursor: pointer; border: none; background: none;
|
||||
width: 100%; text-align: left;
|
||||
}
|
||||
.sidebar-link:hover { background: var(--sidebar-hover-bg); color: #fff; }
|
||||
.sidebar-link.active { background: var(--sidebar-active-bg); color: #fff; border-left: 2px solid var(--accent); padding-left: 10px; }
|
||||
.sidebar-link .icon { font-size: 15px; width: 18px; text-align: center; opacity: 0.7; }
|
||||
|
||||
.sidebar-bottom {
|
||||
margin-top: auto; padding: 16px 12px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-user { padding: 10px 12px; display: flex; align-items: center; gap: 10px; }
|
||||
.sidebar-avatar {
|
||||
width: 30px; height: 30px; border-radius: 4px;
|
||||
background: var(--accent); display: flex; align-items: center;
|
||||
justify-content: center; color: #1a1a1a; font-size: 12px;
|
||||
font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.sidebar-user-info { overflow: hidden; }
|
||||
.sidebar-user-name { font-size: 13px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sidebar-user-role { font-size: 11px; color: var(--sidebar-text); text-transform: capitalize; }
|
||||
|
||||
.main-wrapper { margin-left: 240px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.main-content { flex: 1; padding: 32px; }
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
margin-bottom: 28px; display: flex;
|
||||
align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||
}
|
||||
.page-title { font-size: 22px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.3px; }
|
||||
.page-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 3px; }
|
||||
|
||||
/* Cards */
|
||||
.card { background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border); padding: 20px; }
|
||||
.card-title { font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; }
|
||||
|
||||
/* Stats */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 28px; }
|
||||
.stat-card { background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border); padding: 20px; }
|
||||
.stat-value { font-size: 32px; font-weight: 700; color: var(--text-primary); letter-spacing: -1px; }
|
||||
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.stat-icon { display: none; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 18px; border-radius: 6px; font-size: 13px;
|
||||
font-weight: 600; cursor: pointer; border: none;
|
||||
transition: all 0.15s; text-decoration: none; white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #111111; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-outline { background: transparent; color: var(--text-primary); border: 1px solid var(--border); }
|
||||
.btn-outline:hover { background: var(--card-bg-2); border-color: #444; }
|
||||
.btn-success { background: #22c55e; color: #111; }
|
||||
.btn-success:hover { background: #16a34a; }
|
||||
.btn-warning { background: var(--accent); color: #111; }
|
||||
.btn-warning:hover { background: var(--accent-hover); }
|
||||
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger); color: white; }
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
.btn-lg { padding: 13px 28px; font-size: 15px; }
|
||||
|
||||
/* Badges */
|
||||
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; white-space: nowrap; letter-spacing: 0.3px; }
|
||||
.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); }
|
||||
.badge-client_review { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); }
|
||||
.badge-client_approved { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
||||
.badge-sent_to_client { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); }
|
||||
.badge-revision_requested { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
|
||||
.badge-approved { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
||||
.badge-active { background: rgba(37,99,235,0.15); color: #60a5fa; border: 1px solid rgba(37,99,235,0.3); }
|
||||
.badge-completed { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
||||
.badge-initial { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
||||
.badge-revision { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
|
||||
.badge-team { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); }
|
||||
.badge-client { background: rgba(245,165,35,0.15); color: var(--accent); border: 1px solid rgba(245,165,35,0.3); }
|
||||
|
||||
/* Table */
|
||||
.table-wrapper { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border); background: var(--card-bg); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: var(--card-bg); border-bottom: 1px solid var(--border); }
|
||||
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
.table-link { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.table-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 18px; }
|
||||
label { display: block; font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
input[type="text"], input[type="email"], input[type="date"], input[type="password"], select, textarea {
|
||||
width: 100%; padding: 10px 14px; border: 1px solid var(--border);
|
||||
border-radius: 6px; font-size: 14px; color: var(--text-primary);
|
||||
background: var(--card-bg-2); outline: none; transition: border 0.15s; font-family: inherit;
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: var(--text-muted); }
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(245,165,35,0.1);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 100px; }
|
||||
select option { background: #222; color: #fff; }
|
||||
|
||||
/* Auth */
|
||||
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0d0d0d; padding: 20px; }
|
||||
.auth-card { background: #1a1a1a; border-radius: 8px; border: 1px solid var(--border); padding: 40px; width: 100%; max-width: 440px; }
|
||||
.auth-logo { text-align: center; margin-bottom: 32px; }
|
||||
.auth-logo h1 { font-size: 22px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px; }
|
||||
.auth-logo p { font-size: 13px; color: #888; margin-top: 6px; }
|
||||
.auth-card label { color: var(--text-secondary); }
|
||||
.auth-card input[type="text"],
|
||||
.auth-card input[type="email"],
|
||||
.auth-card input[type="password"],
|
||||
.auth-card select,
|
||||
.auth-card textarea {
|
||||
background: #222;
|
||||
border-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
.auth-card input::placeholder,
|
||||
.auth-card textarea::placeholder { color: #555; }
|
||||
.auth-card input:focus, .auth-card select:focus, .auth-card textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(245,165,35,0.1); }
|
||||
.auth-card .btn-outline { border-color: #333; color: #888; }
|
||||
.auth-card .btn-outline:hover { background: #222; color: #fff; }
|
||||
.auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: #444; font-size: 12px; }
|
||||
.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.quick-login { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
.quick-login-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #555; margin-bottom: 10px; }
|
||||
.quick-login-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.quick-login-btn { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 6px; border: 1px solid #2a2a2a; background: #222; cursor: pointer; transition: background 0.15s; text-align: left; width: 100%; }
|
||||
.quick-login-btn:hover { background: #2a2a2a; }
|
||||
.quick-login-name { font-size: 13px; font-weight: 600; color: #fff; }
|
||||
.quick-login-email { font-size: 11px; color: #666; }
|
||||
|
||||
/* Misc */
|
||||
.back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); text-decoration: none; font-size: 12px; font-weight: 500; margin-bottom: 20px; cursor: pointer; border: none; background: none; text-transform: uppercase; letter-spacing: 0.5px; font-family: inherit; }
|
||||
.back-link:hover { color: var(--text-primary); }
|
||||
.empty-state { text-align: center; padding: 56px 20px; color: var(--text-secondary); }
|
||||
.empty-state-icon { display: none; }
|
||||
.empty-state h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.version-timeline { display: flex; flex-direction: column; gap: 12px; }
|
||||
.version-item { border: 1px solid var(--border); border-radius: 8px; padding: 16px; background: var(--card-bg); }
|
||||
.version-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.version-number { font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.version-meta { font-size: 12px; color: var(--text-secondary); margin-top: 8px; display: flex; gap: 16px; }
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||
.detail-item label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.detail-item p { font-size: 14px; color: var(--text-primary); font-weight: 500; }
|
||||
.notification { padding: 12px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; margin-bottom: 20px; }
|
||||
.notification-success { background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); }
|
||||
.notification-info { background: rgba(37,99,235,0.1); color: #60a5fa; border: 1px solid rgba(37,99,235,0.2); }
|
||||
.request-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 8px; transition: border-color 0.15s; }
|
||||
.request-card:hover { border-color: #3a3a3a; }
|
||||
.request-card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 10px; }
|
||||
.request-card-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.request-card-meta { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
||||
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.assign-select { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); font-size: 13px; color: var(--text-primary); background: var(--card-bg-2); cursor: pointer; font-family: inherit; }
|
||||
.assign-select option { background: #222; }
|
||||
.flex { display: flex; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mt-6 { margin-top: 24px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
.w-full { width: 100%; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
|
||||
/* Mobile topbar — hidden on desktop */
|
||||
.mobile-topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hamburger button */
|
||||
.hamburger {
|
||||
background: none; border: none; cursor: pointer;
|
||||
display: flex; flex-direction: column; gap: 5px; padding: 4px;
|
||||
}
|
||||
.hamburger span {
|
||||
display: block; width: 22px; height: 2px;
|
||||
background: var(--text-primary); border-radius: 2px; transition: all 0.2s;
|
||||
}
|
||||
|
||||
/* Sidebar overlay (mobile) */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Show mobile topbar */
|
||||
.mobile-topbar {
|
||||
display: flex; align-items: center; justify-content: flex-start;
|
||||
padding: 10px 14px; background: var(--sidebar-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
height: 48px;
|
||||
}
|
||||
.main-content { padding-top: 64px; }
|
||||
|
||||
/* Sidebar slides in from left */
|
||||
.sidebar {
|
||||
position: fixed; left: -240px; top: 0; z-index: 200;
|
||||
transition: left 0.25s ease; height: 100vh;
|
||||
}
|
||||
.sidebar.sidebar-open { left: 0; }
|
||||
|
||||
/* Show overlay when menu open */
|
||||
.sidebar-overlay { display: block; }
|
||||
|
||||
/* Main wrapper full width, no left margin */
|
||||
.main-wrapper { margin-left: 0; }
|
||||
.main-content { padding: 16px; }
|
||||
|
||||
/* Stack grids on mobile */
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
.detail-grid { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
/* Smaller page header */
|
||||
.page-header { flex-direction: column; gap: 12px; }
|
||||
.page-title { font-size: 18px; }
|
||||
|
||||
/* Action buttons wrap */
|
||||
.action-buttons { flex-wrap: wrap; }
|
||||
|
||||
/* Tables scroll horizontally */
|
||||
.table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
/* Cards tighter padding */
|
||||
.card { padding: 14px; }
|
||||
|
||||
/* Request cards */
|
||||
.request-card-header { flex-direction: column; gap: 8px; }
|
||||
|
||||
/* Auth card full width */
|
||||
.auth-card { padding: 24px 20px; }
|
||||
|
||||
/* Version timeline */
|
||||
.version-item { padding: 12px; }
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export async function sendEmail(type, to, data) {
|
||||
const { error } = await supabase.functions.invoke('send-email', {
|
||||
body: { type, to, data },
|
||||
});
|
||||
if (error) console.error('Email error:', error);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateInvoicePDF(invoice, company, items) {
|
||||
const client = company; // alias for PDF layout vars below
|
||||
const doc = new jsPDF();
|
||||
const pageWidth = doc.internal.pageSize.width;
|
||||
|
||||
// Load logo first to know its height
|
||||
const logo = await loadImage('/fourge-logo.png');
|
||||
const logoW = 40;
|
||||
const logoH = logo ? (logoW / (logo.naturalWidth / logo.naturalHeight)) : 8;
|
||||
const headerH = logoH + 12; // tight padding around logo
|
||||
|
||||
// Black header band — sized to logo
|
||||
doc.setFillColor(20, 20, 20);
|
||||
doc.rect(0, 0, pageWidth, headerH, 'F');
|
||||
|
||||
// Logo
|
||||
if (logo) {
|
||||
doc.addImage(logo, 'PNG', 14, 6, logoW, logoH);
|
||||
} else {
|
||||
doc.setFontSize(13);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('FOURGE BRANDING', 14, headerH / 2 + 4);
|
||||
}
|
||||
|
||||
// Contact info in header (right, small)
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.text('1.855.FOURGE4 · 1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', pageWidth - 14, headerH / 2 + 2, { align: 'right' });
|
||||
|
||||
// INVOICE title + number (below header, left-right)
|
||||
const afterHeader = headerH + 10;
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text('INVOICE', 14, afterHeader + 8);
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(100, 100, 100);
|
||||
doc.text(invoice.invoice_number, pageWidth - 14, afterHeader + 4, { align: 'right' });
|
||||
|
||||
// Invoice details (right column)
|
||||
const details = [
|
||||
['Date', new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })],
|
||||
['Due', new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })],
|
||||
['Terms', 'Net 30'],
|
||||
];
|
||||
let dy = afterHeader + 10;
|
||||
details.forEach(([label, val]) => {
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(130, 130, 130);
|
||||
doc.text(label, pageWidth - 60, dy);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(40, 40, 40);
|
||||
doc.text(val, pageWidth - 14, dy, { align: 'right' });
|
||||
dy += 6;
|
||||
});
|
||||
|
||||
// Status
|
||||
const statusColors = { draft: [150, 150, 150], sent: [37, 99, 235], paid: [22, 163, 74] };
|
||||
const sc = statusColors[invoice.status] || [150, 150, 150];
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...sc);
|
||||
doc.text(invoice.status.toUpperCase(), pageWidth - 14, dy, { align: 'right' });
|
||||
|
||||
// Bill To (left column, same zone)
|
||||
const billStartY = afterHeader + 18;
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text('BILL TO', 14, billStartY);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(client.name, 14, billStartY + 6);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(80, 80, 80);
|
||||
let billY = billStartY + 6;
|
||||
if (client.email) { billY += 5; doc.text(client.email, 14, billY); }
|
||||
|
||||
// Divider
|
||||
const tableStart = Math.max(billY, dy) + 8;
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.4);
|
||||
doc.line(14, tableStart, pageWidth - 14, tableStart);
|
||||
|
||||
// Line items table — tight
|
||||
autoTable(doc, {
|
||||
startY: tableStart + 2,
|
||||
head: [['Description', 'Qty', 'Unit Price', 'Total']],
|
||||
body: items.map(item => [
|
||||
item.description,
|
||||
Number(item.quantity).toString(),
|
||||
`$${Number(item.unit_price).toFixed(2)}`,
|
||||
`$${(Number(item.quantity) * Number(item.unit_price)).toFixed(2)}`,
|
||||
]),
|
||||
headStyles: {
|
||||
fillColor: [30, 30, 30],
|
||||
textColor: [255, 255, 255],
|
||||
fontStyle: 'bold',
|
||||
fontSize: 8,
|
||||
cellPadding: 4,
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 9,
|
||||
textColor: [40, 40, 40],
|
||||
cellPadding: 4,
|
||||
fillColor: [255, 255, 255],
|
||||
},
|
||||
alternateRowStyles: { fillColor: [249, 249, 249] },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 'auto' },
|
||||
1: { cellWidth: 14, halign: 'center' },
|
||||
2: { cellWidth: 30, halign: 'right' },
|
||||
3: { cellWidth: 30, halign: 'right', fontStyle: 'bold' },
|
||||
},
|
||||
margin: { left: 14, right: 14 },
|
||||
theme: 'striped',
|
||||
tableLineColor: [225, 225, 225],
|
||||
tableLineWidth: 0.2,
|
||||
});
|
||||
|
||||
const finalY = doc.lastAutoTable.finalY;
|
||||
|
||||
// Total box
|
||||
doc.setFillColor(20, 20, 20);
|
||||
doc.rect(pageWidth - 70, finalY + 4, 56, 11, 'F');
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('TOTAL', pageWidth - 66, finalY + 11);
|
||||
doc.setFontSize(9);
|
||||
doc.text(`$${Number(invoice.total).toFixed(2)}`, pageWidth - 16, finalY + 11, { align: 'right' });
|
||||
|
||||
// Notes
|
||||
if (invoice.notes) {
|
||||
const notesY = finalY + 22;
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text('NOTES', 14, notesY);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const split = doc.splitTextToSize(invoice.notes, pageWidth / 2);
|
||||
doc.text(split, 14, notesY + 5);
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = doc.internal.pageSize.height - 14;
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.4);
|
||||
doc.line(14, footerY - 4, pageWidth - 14, footerY - 4);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.text('Payment due within 30 days of invoice date. Thank you for your business!', pageWidth / 2, footerY, { align: 'center' });
|
||||
|
||||
doc.save(`${invoice.invoice_number}.pdf`);
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||
const key = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!url || !key) throw new Error('Missing Supabase environment variables. Check VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.');
|
||||
|
||||
export const supabase = createClient(url, key);
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const { error: err } = await login(email, password);
|
||||
if (err) {
|
||||
setError('Invalid email or password.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// onAuthStateChange in AuthContext sets currentUser + role → redirect handled below
|
||||
};
|
||||
|
||||
// After login, AuthContext updates currentUser. Use onAuthStateChange to redirect.
|
||||
// We rely on ProtectedRoute to handle post-login navigation.
|
||||
// But we need to redirect on success — watch currentUser via auth state.
|
||||
// Simplest: redirect after successful login based on profile role.
|
||||
const handleSuccess = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const { error: err } = await login(email, password);
|
||||
if (err) {
|
||||
setError('Invalid email or password.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Small delay to let onAuthStateChange set currentUser
|
||||
setTimeout(() => {
|
||||
// Will be redirected by ProtectedRoute if they go to /dashboard or /my-requests
|
||||
navigate('/dashboard');
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-logo">
|
||||
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 200, marginBottom: 8 }} />
|
||||
<p>Client & Project Portal</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSuccess}>
|
||||
<div className="form-group">
|
||||
<label>Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); setError(''); }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); setError(''); }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: '#a8a8a8' }}>
|
||||
New client?{' '}
|
||||
<Link to="/signup" style={{ color: 'var(--accent)' }}>Create an account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react';
|
||||
import Layout from '../components/Layout';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function Settings() {
|
||||
const { currentUser } = useAuth();
|
||||
const [form, setForm] = useState({
|
||||
name: currentUser?.name || '',
|
||||
company: currentUser?.company || '',
|
||||
});
|
||||
const [passwords, setPasswords] = useState({ current: '', next: '', confirm: '' });
|
||||
const [profileSaved, setProfileSaved] = useState(false);
|
||||
const [passwordSaved, setPasswordSaved] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingPw, setSavingPw] = useState(false);
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
const setPw = (field) => (e) => setPasswords(p => ({ ...p, [field]: e.target.value }));
|
||||
|
||||
const handleProfileSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await supabase.from('profiles').update({
|
||||
name: form.name.trim(),
|
||||
company: form.company.trim(),
|
||||
}).eq('id', currentUser.id);
|
||||
setProfileSaved(true);
|
||||
setSaving(false);
|
||||
setTimeout(() => setProfileSaved(false), 3000);
|
||||
};
|
||||
|
||||
const handlePasswordSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setPasswordError('');
|
||||
if (passwords.next !== passwords.confirm) { setPasswordError('New passwords do not match.'); return; }
|
||||
if (passwords.next.length < 6) { setPasswordError('Password must be at least 6 characters.'); return; }
|
||||
setSavingPw(true);
|
||||
const { error } = await supabase.auth.updateUser({ password: passwords.next });
|
||||
if (error) { setPasswordError(error.message); setSavingPw(false); return; }
|
||||
setPasswords({ current: '', next: '', confirm: '' });
|
||||
setPasswordSaved(true);
|
||||
setSavingPw(false);
|
||||
setTimeout(() => setPasswordSaved(false), 3000);
|
||||
};
|
||||
|
||||
const initials = form.name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Profile & Settings</div>
|
||||
<div className="page-subtitle">Update your name, company, and password.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 520, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* Avatar preview */}
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div className="sidebar-avatar" style={{ width: 56, height: 56, fontSize: 20, flexShrink: 0 }}>
|
||||
{initials || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 15 }}>{form.name || 'Your Name'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{currentUser?.email}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, textTransform: 'capitalize' }}>{currentUser?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile form */}
|
||||
<div className="card">
|
||||
<div className="card-title">Profile Info</div>
|
||||
<form onSubmit={handleProfileSave}>
|
||||
<div className="form-group">
|
||||
<label>Full Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="First Last"
|
||||
value={form.name}
|
||||
onChange={set('name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Company / Organization</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your company"
|
||||
value={form.company}
|
||||
onChange={set('company')}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email Address</label>
|
||||
<input type="email" value={currentUser?.email} disabled style={{ opacity: 0.6, cursor: 'not-allowed' }} />
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4 }}>Contact Fourge to change your email.</div>
|
||||
</div>
|
||||
{profileSaved && (
|
||||
<div className="notification notification-success" style={{ marginBottom: 12 }}>✓ Profile updated.</div>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Password form */}
|
||||
<div className="card">
|
||||
<div className="card-title">Change Password</div>
|
||||
<form onSubmit={handlePasswordSave}>
|
||||
<div className="form-group">
|
||||
<label>New Password *</label>
|
||||
<input type="password" placeholder="Min. 6 characters" value={passwords.next} onChange={setPw('next')} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm New Password *</label>
|
||||
<input type="password" placeholder="Repeat new password" value={passwords.confirm} onChange={setPw('confirm')} required />
|
||||
</div>
|
||||
{passwordError && (
|
||||
<div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {passwordError}</div>
|
||||
)}
|
||||
{passwordSaved && (
|
||||
<div className="notification notification-success" style={{ marginBottom: 12 }}>✓ Password updated.</div>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={savingPw}>
|
||||
{savingPw ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function Signup() {
|
||||
const { signup } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', confirm: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (form.password !== form.confirm) { setError('Passwords do not match.'); return; }
|
||||
if (form.password.length < 6) { setError('Password must be at least 6 characters.'); return; }
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const { error: err } = await signup(form.email, form.password, form.name);
|
||||
if (err) { setError(err); setLoading(false); return; }
|
||||
navigate('/signup-confirmation');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-logo">
|
||||
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 180, marginBottom: 8 }} />
|
||||
<p>Create your client account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Full Name *</label>
|
||||
<input type="text" placeholder="Jane Smith" value={form.name} onChange={set('name')} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email Address *</label>
|
||||
<input type="email" placeholder="jane@company.com" value={form.email} onChange={set('email')} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password *</label>
|
||||
<input type="password" placeholder="Min. 6 characters" value={form.password} onChange={set('password')} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm Password *</label>
|
||||
<input type="password" placeholder="Repeat password" value={form.confirm} onChange={set('confirm')} required />
|
||||
</div>
|
||||
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: '#a8a8a8' }}>
|
||||
Already have an account?{' '}
|
||||
<Link to="/" style={{ color: 'var(--accent)' }}>Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function SignupConfirmation() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ textAlign: 'center' }}>
|
||||
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ width: 180, marginBottom: 24 }} />
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>📧</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8, color: '#fff' }}>Check your email</h2>
|
||||
<p style={{ fontSize: 14, color: '#a8a8a8', marginBottom: 24, lineHeight: 1.6 }}>
|
||||
We sent a confirmation link to your email address. Click it to activate your account, then come back to sign in.
|
||||
</p>
|
||||
<Link to="/" className="btn btn-primary w-full" style={{ justifyContent: 'center' }}>
|
||||
Back to Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
export default function MyInvoices() {
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, company:companies(name, email), items:invoice_items(*)')
|
||||
.order('created_at', { ascending: false });
|
||||
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDownload = async (invoice) => {
|
||||
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
|
||||
};
|
||||
|
||||
const outstanding = invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
|
||||
const paid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${outstanding.toFixed(2)}</div>
|
||||
<div className="stat-label">Outstanding</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${paid.toFixed(2)}</div>
|
||||
<div className="stat-label">Paid</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices yet</h3>
|
||||
<p>Your invoices will appear here once they are sent.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{invoices.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<div key={inv.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">{inv.invoice_number}</div>
|
||||
<div className="request-card-meta">
|
||||
Issued {new Date(inv.invoice_date).toLocaleDateString()} · Due{' '}
|
||||
<span style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>
|
||||
{new Date(inv.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
{isOverdue && ' · Overdue'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{inv.items && inv.items.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
{inv.items.map(i => i.description).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleDownload(inv)}>
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
||||
|
||||
export default function MyProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all'); // 'all' | 'mine'
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||
if (!p) { setLoading(false); return; }
|
||||
setProject(p);
|
||||
|
||||
const { data: t } = await supabase
|
||||
.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false });
|
||||
setTasks(t || []);
|
||||
|
||||
if (t && t.length > 0) {
|
||||
const { data: subs } = await supabase
|
||||
.from('submissions')
|
||||
.select('id, task_id, submitted_by, submitted_by_name, version_number, type')
|
||||
.in('task_id', t.map(task => task.id))
|
||||
.order('version_number');
|
||||
setSubmissions(subs || []);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
||||
|
||||
const filteredTasks = filter === 'mine'
|
||||
? tasks.filter(task => {
|
||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
return initial?.submitted_by === currentUser.id;
|
||||
})
|
||||
: tasks;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{project.name}</div>
|
||||
<div className="page-subtitle">
|
||||
{tasks.length} request{tasks.length !== 1 ? 's' : ''} · Started {new Date(project.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">
|
||||
+ Add Request
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<button
|
||||
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All Requests
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter('mine')}
|
||||
>
|
||||
Mine Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>{filter === 'mine' ? "You haven't submitted any requests to this project" : 'No requests yet'}</h3>
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>
|
||||
Add Request
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filteredTasks.map(task => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
||||
const latestSub = taskSubs[taskSubs.length - 1];
|
||||
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
|
||||
const isMine = initialSub?.submitted_by === currentUser.id;
|
||||
|
||||
return (
|
||||
<div key={task.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
{vLabel(task.current_version)}
|
||||
</span>
|
||||
{isMine && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 600 }}>
|
||||
Mine
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="request-card-meta" style={{ marginTop: 4 }}>
|
||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
||||
|
||||
function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const filteredTasks = filter === 'mine'
|
||||
? tasks.filter(task => {
|
||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
return initial?.submitted_by === currentUserId;
|
||||
})
|
||||
: tasks;
|
||||
|
||||
if (filter === 'mine' && filteredTasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', marginBottom: 8 }}>
|
||||
{/* Project header — clickable to collapse */}
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '12px 16px',
|
||||
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
|
||||
borderBottom: open ? '1px solid var(--border)' : 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{project.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
|
||||
{filteredTasks.length} request{filteredTasks.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ background: 'var(--card-bg)' }}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div style={{ padding: '16px', fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
No requests in this project yet.
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.map((task, i) => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
||||
const latestSub = taskSubs[taskSubs.length - 1];
|
||||
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
|
||||
const isMine = initialSub?.submitted_by === currentUserId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{task.title}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{vLabel(task.current_version)}
|
||||
</span>
|
||||
{isMine && (
|
||||
<span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 600 }}>
|
||||
Mine
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div style={{ padding: '10px 16px', borderTop: filteredTasks.length > 0 ? '1px solid var(--border)' : 'none' }}>
|
||||
<Link
|
||||
to={`/new-request?project=${encodeURIComponent(project.name)}`}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
+ Add Request to {project.name}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyProjects() {
|
||||
const { currentUser } = useAuth();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all'); // 'all' | 'mine'
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: p } = await supabase
|
||||
.from('projects').select('*').order('created_at', { ascending: false });
|
||||
setProjects(p || []);
|
||||
|
||||
if (!p || p.length === 0) { setLoading(false); return; }
|
||||
|
||||
const { data: t } = await supabase
|
||||
.from('tasks').select('*').in('project_id', p.map(pr => pr.id))
|
||||
.order('submitted_at', { ascending: false });
|
||||
setTasks(t || []);
|
||||
|
||||
if (t && t.length > 0) {
|
||||
const { data: subs } = await supabase
|
||||
.from('submissions')
|
||||
.select('id, task_id, submitted_by, submitted_by_name, version_number, type')
|
||||
.in('task_id', t.map(task => task.id))
|
||||
.order('version_number');
|
||||
setSubmissions(subs || []);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Projects</div>
|
||||
<div className="page-subtitle">All work for your company.</div>
|
||||
</div>
|
||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<button
|
||||
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All Requests
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter('mine')}
|
||||
>
|
||||
Mine Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Submit a request and a project will be created automatically.</p>
|
||||
<Link to="/new-request" className="btn btn-primary" style={{ marginTop: 16 }}>Submit Request</Link>
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => (
|
||||
<ProjectGroup
|
||||
key={project.id}
|
||||
project={project}
|
||||
tasks={tasks.filter(t => t.project_id === project.id)}
|
||||
submissions={submissions}
|
||||
currentUserId={currentUser.id}
|
||||
filter={filter}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function MyRequests() {
|
||||
const { currentUser } = useAuth();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
// Only fetch tasks where current user was the original submitter
|
||||
const { data: mySubs } = await supabase
|
||||
.from('submissions')
|
||||
.select('task_id, submitted_by_name, version_number, type')
|
||||
.eq('submitted_by', currentUser.id)
|
||||
.eq('type', 'initial');
|
||||
|
||||
if (!mySubs || mySubs.length === 0) { setLoading(false); return; }
|
||||
|
||||
const myTaskIds = mySubs.map(s => s.task_id);
|
||||
|
||||
const { data: t } = await supabase
|
||||
.from('tasks').select('*, project:projects(id, name, created_at, status)')
|
||||
.in('id', myTaskIds);
|
||||
setTasks(t || []);
|
||||
|
||||
// Also fetch all submissions for these tasks (to show revision history)
|
||||
const { data: allSubs } = await supabase
|
||||
.from('submissions')
|
||||
.select('task_id, submitted_by_name, version_number, type')
|
||||
.in('task_id', myTaskIds)
|
||||
.order('version_number');
|
||||
setSubmissions(allSubs || []);
|
||||
|
||||
// Group tasks by project
|
||||
const projectMap = {};
|
||||
(t || []).forEach(task => {
|
||||
const p = task.project;
|
||||
if (!p) return;
|
||||
if (!projectMap[p.id]) projectMap[p.id] = { ...p, id: p.id };
|
||||
});
|
||||
setProjects(Object.values(projectMap));
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [currentUser.id]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">My Requests</div>
|
||||
<div className="page-subtitle">Requests you have submitted.</div>
|
||||
</div>
|
||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>No requests yet</h3>
|
||||
<p>Submit a new request to get started.</p>
|
||||
<Link to="/new-request" className="btn btn-primary" style={{ marginTop: 16 }}>Submit Request</Link>
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => {
|
||||
const projectTasks = tasks.filter(t => t.project?.id === project.id);
|
||||
return (
|
||||
<div key={project.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">{project.name}</div>
|
||||
<div className="request-card-meta">
|
||||
Started {new Date(project.created_at).toLocaleDateString()} · {projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{projectTasks.map(task => {
|
||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
||||
const latestSub = taskSubs[taskSubs.length - 1];
|
||||
const hasRevision = taskSubs.length > 1 && latestSub?.submitted_by_name !== initialSub?.submitted_by_name;
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>
|
||||
{'v' + String(task.current_version).padStart(2, '0')}
|
||||
</span>
|
||||
</span>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
Submitted by {initialSub?.submitted_by_name || 'Unknown'}
|
||||
{hasRevision && ` · Last updated by ${latestSub.submitted_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={task.status} />
|
||||
<Link to={`/my-requests/${task.id}`} className="btn btn-outline btn-sm">Details</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import FileAttachment from '../../components/FileAttachment';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function NewRequest() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedProject = searchParams.get('project') || '';
|
||||
|
||||
const [existingProjects, setExistingProjects] = useState([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ project: preselectedProject, serviceType: '', title: '', deadline: '', description: '' });
|
||||
const [files, setFiles] = useState([]);
|
||||
const [customProjects, setCustomProjects] = useState([]);
|
||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!currentUser.company_id) return;
|
||||
const { data: p } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name')
|
||||
.eq('company_id', currentUser.company_id)
|
||||
.order('created_at', { ascending: false });
|
||||
setExistingProjects((p || []).map(pr => ({ id: pr.id, name: pr.name })));
|
||||
}
|
||||
load();
|
||||
}, [currentUser.company_id]);
|
||||
|
||||
const allProjectNames = [
|
||||
...existingProjects.map(p => p.name),
|
||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
||||
];
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const handleProjectSelect = (e) => {
|
||||
if (e.target.value === '__new__') {
|
||||
setIsTypingProject(true);
|
||||
setForm(f => ({ ...f, project: '' }));
|
||||
} else {
|
||||
setForm(f => ({ ...f, project: e.target.value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!customProjects.includes(name) && !existingProjects.some(p => p.name === name)) {
|
||||
setCustomProjects(prev => [...prev, name]);
|
||||
}
|
||||
setForm(f => ({ ...f, project: name }));
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser.company_id) {
|
||||
alert('Your account is not yet assigned to a company. Please contact support.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
|
||||
// Find existing project by name within this company, or create new one
|
||||
let projectId;
|
||||
const existing = existingProjects.find(p => p.name === form.project);
|
||||
if (existing) {
|
||||
projectId = existing.id;
|
||||
} else {
|
||||
const { data: newProject } = await supabase.from('projects').insert({
|
||||
company_id: currentUser.company_id,
|
||||
name: form.project,
|
||||
status: 'active',
|
||||
}).select().single();
|
||||
projectId = newProject?.id;
|
||||
}
|
||||
|
||||
if (!projectId) { setSaving(false); return; }
|
||||
|
||||
// Create task
|
||||
const { data: task } = await supabase.from('tasks').insert({
|
||||
project_id: projectId,
|
||||
title: form.title.trim() || form.serviceType,
|
||||
status: 'not_started',
|
||||
current_version: 0,
|
||||
}).select().single();
|
||||
|
||||
if (!task) { setSaving(false); return; }
|
||||
|
||||
// Create submission
|
||||
const { data: submission } = await supabase.from('submissions').insert({
|
||||
task_id: task.id,
|
||||
version_number: 1,
|
||||
type: 'initial',
|
||||
service_type: form.serviceType,
|
||||
deadline: form.deadline || null,
|
||||
description: form.description,
|
||||
submitted_by: currentUser.id,
|
||||
submitted_by_name: currentUser.name,
|
||||
}).select().single();
|
||||
|
||||
// Upload files
|
||||
if (submission && files.length > 0) {
|
||||
for (const file of files) {
|
||||
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploaded) {
|
||||
await supabase.from('submission_files').insert({
|
||||
submission_id: submission.id,
|
||||
name: file.name,
|
||||
storage_path: path,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
clientEmail: currentUser.email,
|
||||
company: currentUser.company?.name || '',
|
||||
serviceType: form.serviceType,
|
||||
projectName: form.project,
|
||||
deadline: form.deadline,
|
||||
description: form.description,
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
if (!currentUser.company_id) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Account Not Yet Active</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
|
||||
</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Request Submitted!</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
|
||||
Thanks, {currentUser?.name?.split(' ')[0]}! We've received your request for <strong>{form.serviceType}</strong>
|
||||
{form.project && <> under <strong>{form.project}</strong></>}.
|
||||
Our team will review it and update you shortly.
|
||||
</p>
|
||||
<div className="action-buttons" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-projects')}>View Projects</button>
|
||||
<button className="btn btn-outline" onClick={() => { setSubmitted(false); setForm({ project: '', serviceType: '', title: '', deadline: '', description: '' }); setFiles([]); }}>
|
||||
Submit Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">New Request</div>
|
||||
<div className="page-subtitle">Tell us what you need and we'll get to work.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 600 }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Project *</label>
|
||||
{isTypingProject ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter project name..."
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<select value={form.project} onChange={handleProjectSelect} required>
|
||||
<option value="">Select a project...</option>
|
||||
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||
<option value="__new__">+ Create new project...</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={form.serviceType} onChange={set('serviceType')} required>
|
||||
<option value="">Select a service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Desired Deadline</label>
|
||||
<input type="date" value={form.deadline} onChange={set('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Request Title
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>optional — defaults to service type if left blank</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={form.serviceType ? `e.g. ${form.serviceType} — 2026` : 'e.g. Brand Book — 2026'}
|
||||
value={form.title}
|
||||
onChange={set('title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Project Description *</label>
|
||||
<textarea
|
||||
placeholder="Tell us about your project — what you need, your brand, style preferences, any references..."
|
||||
value={form.description}
|
||||
onChange={set('description')}
|
||||
style={{ minHeight: 140 }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FileAttachment files={files} onChange={setFiles} />
|
||||
|
||||
<div className="notification notification-info" style={{ marginBottom: 16 }}>
|
||||
Submitting as <strong>{currentUser?.name}</strong> · {currentUser?.company?.name}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-lg" disabled={saving}>
|
||||
{saving ? 'Submitting...' : 'Submit Request'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+350
@@ -0,0 +1,350 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import FileAttachment from '../../components/FileAttachment';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
|
||||
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
||||
|
||||
export default function RequestDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
const [project, setProject] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [action, setAction] = useState(null);
|
||||
const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: '', description: '' });
|
||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
|
||||
if (!t) { setLoading(false); return; }
|
||||
setTask(t);
|
||||
|
||||
const [{ data: p }, { data: subs }] = await Promise.all([
|
||||
supabase.from('projects').select('*').eq('id', t.project_id).single(),
|
||||
supabase.from('submissions').select('*, delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
|
||||
]);
|
||||
setProject(p);
|
||||
setSubmissions(subs || []);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setSaving(true);
|
||||
await supabase.from('tasks').update({ status: 'client_approved', completed_at: new Date().toISOString() }).eq('id', id);
|
||||
setTask(t => ({ ...t, status: 'client_approved' }));
|
||||
setAction('approved');
|
||||
sendEmail('client_approved', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
serviceType: task.title,
|
||||
projectName: project?.name,
|
||||
taskId: id,
|
||||
});
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSaving(true);
|
||||
|
||||
const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id);
|
||||
if (subs && subs.length > 0) {
|
||||
const { data: storageFiles } = await supabase
|
||||
.from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id));
|
||||
if (storageFiles && storageFiles.length > 0) {
|
||||
await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path));
|
||||
}
|
||||
const { data: deliveries } = await supabase
|
||||
.from('deliveries').select('id').in('submission_id', subs.map(s => s.id));
|
||||
if (deliveries && deliveries.length > 0) {
|
||||
const { data: deliveryFiles } = await supabase
|
||||
.from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id));
|
||||
if (deliveryFiles && deliveryFiles.length > 0) {
|
||||
await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await supabase.from('tasks').delete().eq('id', id);
|
||||
|
||||
const { data: remaining } = await supabase.from('tasks').select('id').eq('project_id', task.project_id);
|
||||
if (!remaining || remaining.length === 0) {
|
||||
await supabase.from('projects').delete().eq('id', task.project_id);
|
||||
}
|
||||
|
||||
navigate('/my-projects');
|
||||
};
|
||||
|
||||
const handleRevisionSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
if (action === 'edit') {
|
||||
const latestSub = submissions[submissions.length - 1];
|
||||
const updatedDescription = latestSub
|
||||
? `${latestSub.description}\n\n─── Edited ${new Date().toLocaleDateString()} ───\n${revisionForm.description}`
|
||||
: revisionForm.description;
|
||||
|
||||
await supabase.from('submissions').update({
|
||||
description: updatedDescription,
|
||||
deadline: revisionForm.deadline || latestSub?.deadline || null,
|
||||
}).eq('id', latestSub.id);
|
||||
|
||||
if (revisionFiles.length > 0) {
|
||||
for (const file of revisionFiles) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploaded) {
|
||||
await supabase.from('submission_files').insert({
|
||||
submission_id: latestSub.id, name: file.name, storage_path: path, size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newVersion = (task.current_version || 0) + 1;
|
||||
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion }).eq('id', id);
|
||||
|
||||
const { data: newSub } = await supabase.from('submissions').insert({
|
||||
task_id: id,
|
||||
version_number: newVersion + 1,
|
||||
type: 'revision',
|
||||
service_type: revisionForm.serviceType,
|
||||
deadline: revisionForm.deadline || null,
|
||||
description: revisionForm.description,
|
||||
submitted_by: currentUser.id,
|
||||
submitted_by_name: currentUser.name,
|
||||
}).select().single();
|
||||
|
||||
if (newSub && revisionFiles.length > 0) {
|
||||
for (const file of revisionFiles) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploaded) {
|
||||
await supabase.from('submission_files').insert({
|
||||
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTask(t => ({ ...t, status: 'not_started', current_version: newVersion }));
|
||||
|
||||
sendEmail('revision_submitted', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
serviceType: task.title,
|
||||
projectName: project?.name,
|
||||
version: vLabel(newVersion),
|
||||
deadline: revisionForm.deadline,
|
||||
description: revisionForm.description,
|
||||
taskId: id,
|
||||
});
|
||||
}
|
||||
|
||||
const { data: refreshed } = await supabase
|
||||
.from('submissions')
|
||||
.select('*, delivery:deliveries(*, files:delivery_files(*))')
|
||||
.eq('task_id', id)
|
||||
.order('version_number');
|
||||
setSubmissions(refreshed || []);
|
||||
|
||||
setSubmitted(true);
|
||||
setAction(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const set = (field) => (e) => setRevisionForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const getFileUrl = async (path) => {
|
||||
const { data } = await supabase.storage.from('deliveries').createSignedUrl(path, 3600);
|
||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!task) return <Layout><p>Job not found.</p></Layout>;
|
||||
|
||||
const canEdit = ['not_started', 'in_progress'].includes(task.status);
|
||||
const canReview = task.status === 'client_review';
|
||||
const canReopen = task.status === 'client_approved';
|
||||
const titleWithVersion = `${task.title} ${vLabel(task.current_version)}`;
|
||||
|
||||
const formTitle = action === 'edit'
|
||||
? `Edit Request — will become ${vLabel((task.current_version || 0) + 1)}`
|
||||
: action === 'reopen'
|
||||
? `Request New Revision — will become ${vLabel((task.current_version || 0) + 1)}`
|
||||
: `Request a Revision — will become ${vLabel((task.current_version || 0) + 1)}`;
|
||||
|
||||
const formPlaceholder = action === 'edit'
|
||||
? "Describe what you'd like to update or change..."
|
||||
: "Describe exactly what you'd like us to change or improve...";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{titleWithVersion}</div>
|
||||
<div className="page-subtitle">{project?.name}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
{action !== 'confirm-delete' && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: '#ef4444', color: 'white', border: 'none' }}
|
||||
onClick={() => setAction('confirm-delete')}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action === 'confirm-delete' && (
|
||||
<div className="card" style={{ background: '#fef2f2', borderColor: '#fecaca', marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>⚠ Delete this request?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
This will permanently delete <strong>{titleWithVersion}</strong> and all its history. This cannot be undone.
|
||||
</p>
|
||||
<div className="action-buttons">
|
||||
<button className="btn" style={{ background: '#ef4444', color: 'white', border: 'none' }} onClick={handleDelete} disabled={saving}>
|
||||
{saving ? 'Deleting...' : 'Yes, Delete'}
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitted && (
|
||||
<div className="notification notification-success">
|
||||
✓ Your {action === 'edit' ? 'changes have' : 'revision request has'} been submitted as {vLabel(task.current_version)}. Our team will get started shortly.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'approved' && (
|
||||
<div className="notification notification-success">
|
||||
✓ You've approved {vLabel(task.current_version)}. This job is now complete!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReview && !submitted && action !== 'confirm-delete' && action !== 'revision' && (
|
||||
<div className="card" style={{ background: '#fffbeb', borderColor: '#fde68a', marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🎨 Your work is ready for review!</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
Please review the delivered work for <strong>{titleWithVersion}</strong> and let us know if you're happy or need changes.
|
||||
</p>
|
||||
<div className="action-buttons">
|
||||
<button className="btn btn-success" onClick={handleApprove} disabled={saving}>✓ Approve — I'm Happy!</button>
|
||||
<button className="btn btn-warning" onClick={() => { setAction('revision'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>✏️ Request Revision</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && !submitted && action !== 'confirm-delete' && action !== 'edit' && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>✏️ Need to make changes?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
Your request is still being worked on. You can update the details or requirements.
|
||||
</p>
|
||||
<button className="btn btn-warning" onClick={() => { setAction('edit'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>Edit Request</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReopen && !submitted && action !== 'confirm-delete' && action !== 'reopen' && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🔄 Need more changes?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
This job was approved but you can still request a new revision if needed.
|
||||
</p>
|
||||
<button className="btn btn-warning" onClick={() => { setAction('reopen'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>Request New Revision</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(action === 'revision' || action === 'edit' || action === 'reopen') && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">{formTitle}</div>
|
||||
<form onSubmit={handleRevisionSubmit}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type</label>
|
||||
<input type="text" value={revisionForm.serviceType} readOnly disabled style={{ opacity: 0.6, cursor: 'not-allowed' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Deadline</label>
|
||||
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{action === 'edit' ? 'What would you like to change? *' : 'What needs to be changed? *'}</label>
|
||||
<textarea placeholder={formPlaceholder} value={revisionForm.description} onChange={set('description')} required />
|
||||
</div>
|
||||
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Submitting...' : 'Submit'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-title">Version History</div>
|
||||
<div className="version-timeline">
|
||||
{submissions.map(sub => {
|
||||
const delivery = sub.delivery;
|
||||
return (
|
||||
<div key={sub.id} className="version-item">
|
||||
<div className="version-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="version-number">{vLabel(sub.version_number - 1)}</div>
|
||||
<StatusBadge status={sub.type} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{sub.submitted_by_name && <span>{sub.submitted_by_name} · </span>}
|
||||
{new Date(sub.submitted_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item"><label>Service</label><p>{sub.service_type}</p></div>
|
||||
<div className="detail-item"><label>Deadline</label><p>{sub.deadline || '—'}</p></div>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Description</label>
|
||||
<p style={{ marginTop: 4, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{sub.description}</p>
|
||||
</div>
|
||||
{delivery && delivery.files && delivery.files.length > 0 && (
|
||||
<div style={{ marginTop: 12, padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, border: '1px solid #bbf7d0' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>
|
||||
✓ Delivered {new Date(delivery.sent_at).toLocaleDateString()}
|
||||
</div>
|
||||
{delivery.files.map((file, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'white', borderRadius: 6, border: '1px solid #bbf7d0', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📄</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => getFileUrl(file.storage_path)}>📥 View</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export default function Companies() {
|
||||
const navigate = useNavigate();
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ name: '', email: '', phone: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: prof }, { data: p }, { data: t }] = await Promise.all([
|
||||
supabase.from('companies').select('*').order('name'),
|
||||
supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'),
|
||||
supabase.from('projects').select('id, company_id, status'),
|
||||
supabase.from('tasks').select('id, project_id, status'),
|
||||
]);
|
||||
setCompanies(co || []);
|
||||
setProfiles(prof || []);
|
||||
setProjects(p || []);
|
||||
setTasks(t || []);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newForm.name.trim()) return;
|
||||
setSaving(true);
|
||||
const { data } = await supabase.from('companies').insert({
|
||||
name: newForm.name.trim(),
|
||||
email: newForm.email.trim(),
|
||||
phone: newForm.phone.trim(),
|
||||
}).select().single();
|
||||
setSaving(false);
|
||||
if (data) {
|
||||
setShowNew(false);
|
||||
setNewForm({ name: '', email: '', phone: '' });
|
||||
navigate(`/companies/${data.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const unassigned = profiles.filter(p => !p.company_id);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Companies</div>
|
||||
<div className="page-subtitle">
|
||||
{companies.length} company{companies.length !== 1 ? 'ies' : ''}
|
||||
{unassigned.length > 0 && (
|
||||
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
|
||||
· {unassigned.length} unassigned user{unassigned.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowNew(s => !s)}>
|
||||
{showNew ? 'Cancel' : '+ New Company'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNew && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||||
<div className="card-title">New Company</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label>Company Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Acme Corp"
|
||||
value={newForm.name}
|
||||
onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="contact@acme.com"
|
||||
value={newForm.email}
|
||||
onChange={e => setNewForm(f => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Phone</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
value={newForm.phone}
|
||||
onChange={e => setNewForm(f => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
||||
{saving ? 'Creating...' : 'Create Company'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 24, borderColor: 'var(--danger)' }}>
|
||||
<div className="card-title" style={{ color: 'var(--danger)' }}>Unassigned Users</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
These users have signed up but haven't been assigned to a company yet.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{unassigned.map(user => (
|
||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500 }}>No company</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>
|
||||
Open a company and assign them from the Users tab.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companies.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No companies yet</h3>
|
||||
<p>Create a company to get started.</p>
|
||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowNew(true)}>+ New Company</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{companies.map(company => {
|
||||
const companyProfiles = profiles.filter(p => p.company_id === company.id);
|
||||
const companyProjects = projects.filter(p => p.company_id === company.id);
|
||||
const projectIds = companyProjects.map(p => p.id);
|
||||
const activeTasks = tasks.filter(t => projectIds.includes(t.project_id) && t.status !== 'client_approved');
|
||||
|
||||
return (
|
||||
<div key={company.id} style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)' }}>{company.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
{companyProfiles.length} user{companyProfiles.length !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{companyProjects.length} project{companyProjects.length !== 1 ? 's' : ''}
|
||||
{activeTasks.length > 0 && <> · <span style={{ color: 'var(--accent)', fontWeight: 600 }}>{activeTasks.length} active</span></>}
|
||||
{company.email && <> · {company.email}</>}
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/companies/${company.id}`} className="btn btn-outline btn-sm">View</Link>
|
||||
</div>
|
||||
|
||||
{companyProfiles.length > 0 && (
|
||||
<div>
|
||||
{companyProfiles.map((profile, i) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 18px',
|
||||
borderBottom: i < companyProfiles.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 4, background: 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: '#111', flexShrink: 0,
|
||||
}}>
|
||||
{profile.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{profile.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{profile.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
|
||||
export default function CompanyDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [company, setCompany] = useState(null);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [unassigned, setUnassigned] = useState([]);
|
||||
const [prices, setPrices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState('projects');
|
||||
const [savingPrice, setSavingPrice] = useState(null);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: p }, { data: pr }, { data: u }, { data: unassignedUsers }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', id).single(),
|
||||
supabase.from('projects').select('*').eq('company_id', id).order('created_at', { ascending: false }),
|
||||
supabase.from('company_prices').select('*').eq('company_id', id),
|
||||
supabase.from('profiles').select('id, name, email, created_at').eq('company_id', id).eq('role', 'client'),
|
||||
supabase.from('profiles').select('id, name, email').eq('role', 'client').is('company_id', null),
|
||||
]);
|
||||
setCompany(co);
|
||||
const projectList = p || [];
|
||||
setProjects(projectList);
|
||||
setPrices(pr || []);
|
||||
setUsers(u || []);
|
||||
setUnassigned(unassignedUsers || []);
|
||||
|
||||
if (projectList.length > 0) {
|
||||
const { data: t } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.in('project_id', projectList.map(pr => pr.id));
|
||||
setTasks(t || []);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const handleAssignUser = async (userId) => {
|
||||
setAssigning(true);
|
||||
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
|
||||
// Move user from unassigned to users list
|
||||
const user = unassigned.find(u => u.id === userId);
|
||||
if (user) {
|
||||
setUsers(prev => [...prev, { ...user, created_at: new Date().toISOString() }]);
|
||||
setUnassigned(prev => prev.filter(u => u.id !== userId));
|
||||
}
|
||||
setAssigning(false);
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (userId) => {
|
||||
if (!window.confirm('Remove this user from the company? They will lose access to all company data.')) return;
|
||||
await supabase.from('profiles').update({ company_id: null }).eq('id', userId);
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (user) {
|
||||
setUsers(prev => prev.filter(u => u.id !== userId));
|
||||
setUnassigned(prev => [...prev, user]);
|
||||
}
|
||||
};
|
||||
|
||||
const getPrice = (serviceType) => prices.find(p => p.service_type === serviceType)?.price ?? '';
|
||||
|
||||
const handlePriceChange = (serviceType, value) => {
|
||||
setPrices(prev => {
|
||||
const existing = prev.find(p => p.service_type === serviceType);
|
||||
if (existing) return prev.map(p => p.service_type === serviceType ? { ...p, price: value } : p);
|
||||
return [...prev, { service_type: serviceType, price: value, company_id: id }];
|
||||
});
|
||||
};
|
||||
|
||||
const handlePriceSave = async (serviceType) => {
|
||||
setSavingPrice(serviceType);
|
||||
const priceVal = getPrice(serviceType);
|
||||
const existing = prices.find(p => p.service_type === serviceType && p.id);
|
||||
if (existing) {
|
||||
await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
|
||||
} else {
|
||||
const { data } = await supabase.from('company_prices').insert({
|
||||
company_id: id, service_type: serviceType, price: Number(priceVal),
|
||||
}).select().single();
|
||||
if (data) setPrices(prev => prev.map(p => p.service_type === serviceType ? data : p));
|
||||
}
|
||||
setSavingPrice(null);
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!company) return <Layout><p>Company not found.</p></Layout>;
|
||||
|
||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||
const completedTasks = tasks.filter(t => t.status === 'client_approved');
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/companies')}>← Back to Companies</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{company.name}</div>
|
||||
<div className="page-subtitle">
|
||||
{company.email && <>{company.email}</>}
|
||||
{company.email && company.phone && ' · '}
|
||||
{company.phone && <>{company.phone}</>}
|
||||
{!company.email && !company.phone && 'No contact info'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge badge-client" style={{ fontSize: 13, padding: '6px 14px' }}>Company</span>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 28 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📁</div>
|
||||
<div className="stat-value">{projects.length}</div>
|
||||
<div className="stat-label">Projects</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⚡</div>
|
||||
<div className="stat-value">{activeTasks.length}</div>
|
||||
<div className="stat-label">Active Jobs</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">✅</div>
|
||||
<div className="stat-value">{completedTasks.length}</div>
|
||||
<div className="stat-label">Completed</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📅</div>
|
||||
<div className="stat-value" style={{ fontSize: 16 }}>{new Date(company.created_at).toLocaleDateString()}</div>
|
||||
<div className="stat-label">Since</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
|
||||
{['projects', 'users', 'pricing'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '8px 16px', fontSize: 13, fontWeight: 600,
|
||||
color: tab === t ? 'var(--accent)' : 'var(--text-muted)',
|
||||
borderBottom: tab === t ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
marginBottom: -1, textTransform: 'capitalize', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
{t === 'users' && unassigned.length > 0 && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, background: 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}>
|
||||
{unassigned.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Projects Tab */}
|
||||
{tab === 'projects' && projects.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Projects will appear here when requests come in.</p>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'projects' && projects.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{projects.map(project => {
|
||||
const projectTasks = tasks.filter(t => t.project_id === project.id);
|
||||
const active = projectTasks.filter(t => t.status !== 'client_approved');
|
||||
return (
|
||||
<div key={project.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
<Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link>
|
||||
</div>
|
||||
{project.description && <div className="request-card-meta">{project.description}</div>}
|
||||
<div className="request-card-meta" style={{ marginTop: 4 }}>
|
||||
Started {new Date(project.created_at).toLocaleDateString()} · {projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''} · {active.length} active
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
<Link to={`/projects/${project.id}`} className="btn btn-outline btn-sm">View</Link>
|
||||
</div>
|
||||
</div>
|
||||
{projectTasks.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
{projectTasks.map(task => (
|
||||
<Link key={task.id} to={`/tasks/${task.id}`} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 12px', borderRadius: 6,
|
||||
background: 'var(--bg)', border: '1px solid var(--border)',
|
||||
fontSize: 12, fontWeight: 500, color: 'var(--text-primary)',
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
{task.title}
|
||||
<StatusBadge status={task.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{tab === 'users' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Assigned Users</div>
|
||||
{users.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No users assigned to this company yet.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{users.map((user, i) => (
|
||||
<div key={user.id} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderBottom: i < users.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 4, background: 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 12, fontWeight: 700, color: '#111', flexShrink: 0,
|
||||
}}>
|
||||
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ fontSize: 11, color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => handleRemoveUser(user.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">Unassigned Users</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
|
||||
These users have signed up but aren't assigned to any company yet.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{unassigned.map(user => (
|
||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleAssignUser(user.id)}
|
||||
disabled={assigning}
|
||||
>
|
||||
Assign to {company.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pricing Tab */}
|
||||
{tab === 'pricing' && (
|
||||
<div className="card">
|
||||
<div className="card-title">Price List — {company.name}</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
|
||||
Set prices per service type for this company. These auto-fill when creating an invoice.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{serviceTypes.map(serviceType => (
|
||||
<div key={serviceType} style={{ display: 'grid', gridTemplateColumns: '1fr 160px 80px', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{serviceType}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={getPrice(serviceType)}
|
||||
onChange={e => handlePriceChange(serviceType, e.target.value)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handlePriceSave(serviceType)}
|
||||
disabled={savingPrice === serviceType}
|
||||
>
|
||||
{savingPrice === serviceType ? '...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
function newItem(description = '', unit_price = '', quantity = 1, task_id = null) {
|
||||
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id };
|
||||
}
|
||||
|
||||
export default function CreateInvoice() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState('');
|
||||
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
|
||||
const [priceList, setPriceList] = useState([]);
|
||||
const [items, setItems] = useState([newItem()]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const net30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('companies').select('id, name, email').order('name').then(({ data }) => setCompanies(data || []));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId) { setUninvoicedTasks([]); setPriceList([]); setItems([newItem()]); return; }
|
||||
setLoadingTasks(true);
|
||||
Promise.all([
|
||||
supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
|
||||
supabase.from('company_prices').select('*').eq('company_id', selectedCompanyId),
|
||||
]).then(async ([{ data: projects }, { data: prices }]) => {
|
||||
setPriceList(prices || []);
|
||||
if (projects && projects.length > 0) {
|
||||
const { data: tasks } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name)')
|
||||
.in('project_id', projects.map(p => p.id))
|
||||
.eq('invoiced', false);
|
||||
setUninvoicedTasks(tasks || []);
|
||||
} else {
|
||||
setUninvoicedTasks([]);
|
||||
}
|
||||
setLoadingTasks(false);
|
||||
});
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const addTaskAsItem = (task) => {
|
||||
const price = priceList.find(p => p.service_type === task.title);
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
return [newItem(task.title, price?.price || '', 1, task.id)];
|
||||
}
|
||||
return [...prev, newItem(task.title, price?.price || '', 1, task.id)];
|
||||
});
|
||||
};
|
||||
|
||||
const updateItem = (id, field, value) => {
|
||||
setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
|
||||
};
|
||||
|
||||
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
|
||||
|
||||
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
|
||||
|
||||
const handleSave = async (status) => {
|
||||
if (!selectedCompanyId) return alert('Please select a company.');
|
||||
if (items.every(i => !i.description)) return alert('Please add at least one line item.');
|
||||
setSaving(true);
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const { count } = await supabase.from('invoices').select('*', { count: 'exact', head: true }).gte('created_at', `${year}-01-01`);
|
||||
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
|
||||
|
||||
const { data: invoice, error } = await supabase.from('invoices').insert({
|
||||
company_id: selectedCompanyId,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: today,
|
||||
due_date: net30,
|
||||
status,
|
||||
notes: notes || null,
|
||||
total,
|
||||
created_by: currentUser?.id,
|
||||
}).select().single();
|
||||
|
||||
if (error || !invoice) { setSaving(false); alert('Error saving invoice.'); return; }
|
||||
|
||||
const validItems = items.filter(i => i.description);
|
||||
if (validItems.length > 0) {
|
||||
await supabase.from('invoice_items').insert(
|
||||
validItems.map(item => ({
|
||||
invoice_id: invoice.id,
|
||||
task_id: item.task_id || null,
|
||||
description: item.description,
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const taskIds = validItems.filter(i => i.task_id).map(i => i.task_id);
|
||||
if (taskIds.length > 0) {
|
||||
await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
|
||||
}
|
||||
|
||||
navigate(`/invoices/${invoice.id}`);
|
||||
};
|
||||
|
||||
const selectedCompany = companies.find(c => c.id === selectedCompanyId);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices')}>← Back to Invoices</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">New Invoice</div>
|
||||
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
|
||||
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Finalise & Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ gap: 24, marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Company</div>
|
||||
<div className="form-group">
|
||||
<label>Select Company *</label>
|
||||
<select value={selectedCompanyId} onChange={e => setSelectedCompanyId(e.target.value)}>
|
||||
<option value="">Choose a company...</option>
|
||||
{companies.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedCompany && (
|
||||
<div style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600 }}>{selectedCompany.name}</div>
|
||||
{selectedCompany.email && <div style={{ color: 'var(--text-muted)', marginTop: 2 }}>{selectedCompany.email}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Uninvoiced Requests</div>
|
||||
{!selectedCompanyId ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their uninvoiced requests.</p>
|
||||
) : loadingTasks ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : uninvoicedTasks.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No uninvoiced requests found.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{uninvoicedTasks.map(task => {
|
||||
const price = priceList.find(p => p.service_type === task.title);
|
||||
const alreadyAdded = items.some(i => i.task_id === task.id);
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{task.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{task.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||||
onClick={() => !alreadyAdded && addTaskAsItem(task)}
|
||||
disabled={alreadyAdded}
|
||||
>
|
||||
{alreadyAdded ? 'Added' : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
||||
{['Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
|
||||
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 1 ? 'right' : 'left' }}>{h}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map(item => (
|
||||
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}>
|
||||
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
|
||||
<input type="number" min="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
|
||||
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
|
||||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||||
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
|
||||
</div>
|
||||
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
|
||||
+ Add Line Item
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Notes</div>
|
||||
<textarea placeholder="Additional notes, payment instructions, or terms..." value={notes} onChange={e => setNotes(e.target.value)} style={{ minHeight: 80 }} />
|
||||
</div>
|
||||
|
||||
<div className="action-buttons">
|
||||
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
|
||||
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Finalise & Send'}
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
function CompanyGroup({ company, tasks, projects }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '10px 14px',
|
||||
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
|
||||
borderBottom: open ? '1px solid var(--border)' : 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{company.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div>
|
||||
{tasks.map(task => {
|
||||
const project = projects.find(p => p.id === task.project_id);
|
||||
return (
|
||||
<div key={task.id} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Link to={`/tasks/${task.id}`} className="table-link" style={{ fontSize: 13 }}>
|
||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version).padStart(2, '0')}</span>
|
||||
</Link>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{project?.name}</span>
|
||||
<span style={{ fontSize: 11, color: task.assigned_name ? 'var(--text-secondary)' : 'var(--text-muted)' }}>
|
||||
{task.assigned_name || 'Unassigned'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedColumn({ tasks, companies, projects, emptyText }) {
|
||||
if (tasks.length === 0) return (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
|
||||
const groups = companies
|
||||
.map(company => {
|
||||
const companyProjectIds = projects.filter(p => p.company_id === company.id).map(p => p.id);
|
||||
const companyTasks = tasks.filter(t => companyProjectIds.includes(t.project_id));
|
||||
return { company, tasks: companyTasks };
|
||||
})
|
||||
.filter(g => g.tasks.length > 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{groups.map(({ company, tasks: groupTasks }) => (
|
||||
<CompanyGroup key={company.id} company={company} tasks={groupTasks} projects={projects} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { currentUser } = useAuth();
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [{ data: t }, { data: p }, { data: co }] = await Promise.all([
|
||||
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
|
||||
supabase.from('projects').select('*'),
|
||||
supabase.from('companies').select('*').order('name'),
|
||||
]);
|
||||
setTasks(t || []);
|
||||
setProjects(p || []);
|
||||
setCompanies(co || []);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const activeTasks = tasks.filter(t => ['in_progress', 'on_hold', 'client_review'].includes(t.status));
|
||||
const notStartedTasks = tasks.filter(t => t.status === 'not_started');
|
||||
const completedTasks = tasks.filter(t => t.status === 'client_approved');
|
||||
const activeProjects = projects.filter(p => p.status === 'active');
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
||||
<div className="page-subtitle">Here's what's happening across your projects.</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-outline" onClick={() => setShowCompleted(s => !s)}>
|
||||
{showCompleted ? 'Hide Completed' : 'Show Completed'}
|
||||
</button>
|
||||
<Link to="/requests" className="btn btn-primary">View Requests</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📁</div>
|
||||
<div className="stat-value">{activeProjects.length}</div>
|
||||
<div className="stat-label">Active Projects</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⚡</div>
|
||||
<div className="stat-value">{activeTasks.length}</div>
|
||||
<div className="stat-label">Active Jobs</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🔴</div>
|
||||
<div className="stat-value">{notStartedTasks.length}</div>
|
||||
<div className="stat-label">Not Started</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">✅</div>
|
||||
<div className="stat-value">{completedTasks.length}</div>
|
||||
<div className="stat-label">Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: showCompleted ? '1fr 1fr 1fr' : '1fr 1fr', gap: 24 }}>
|
||||
<div>
|
||||
<div className="card-title">Not Started</div>
|
||||
<GroupedColumn tasks={notStartedTasks} companies={companies} projects={projects} emptyText="Nothing waiting to start" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="card-title">Active Jobs</div>
|
||||
<GroupedColumn tasks={activeTasks} companies={companies} projects={projects} emptyText="No active jobs" />
|
||||
</div>
|
||||
{showCompleted && (
|
||||
<div>
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
Completed
|
||||
<button onClick={() => setShowCompleted(false)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer' }}>
|
||||
Hide ✕
|
||||
</button>
|
||||
</div>
|
||||
<GroupedColumn tasks={completedTasks} companies={companies} projects={projects} emptyText="No completed jobs yet" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [invoice, setInvoice] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
|
||||
if (!inv) { setLoading(false); return; }
|
||||
setInvoice(inv);
|
||||
|
||||
const [{ data: co }, { data: its }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
|
||||
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
|
||||
]);
|
||||
setCompany(co);
|
||||
setItems(its || []);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const updateStatus = async (status) => {
|
||||
setSaving(true);
|
||||
await supabase.from('invoices').update({ status }).eq('id', id);
|
||||
setInvoice(i => ({ ...i, status }));
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this invoice? This cannot be undone.')) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id').eq('invoice_id', id);
|
||||
const taskIds = (freshItems || []).filter(i => i.task_id).map(i => i.task_id);
|
||||
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', taskIds);
|
||||
const { error } = await supabase.from('invoices').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
navigate('/invoices');
|
||||
} catch {
|
||||
alert('Failed to delete invoice. Please try again.');
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
await generateInvoicePDF(invoice, company, items);
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!invoice) return <Layout><p>Invoice not found.</p></Layout>;
|
||||
|
||||
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices')}>← Back to Invoices</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">
|
||||
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<span className={`badge badge-${statusColor[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
|
||||
{invoice.status}{isOverdue ? ' · Overdue' : ''}
|
||||
</span>
|
||||
<button className="btn btn-primary" onClick={handleDownload}>Download PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Bill To</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{company?.name}</div>
|
||||
{company?.email && <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{company.email}</div>}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Link to={`/companies/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Invoice Details</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Invoice Date</label><p>{new Date(invoice.invoice_date).toLocaleDateString()}</p></div>
|
||||
<div className="detail-item"><label>Due Date</label>
|
||||
<p style={{ color: isOverdue ? 'var(--danger)' : 'inherit' }}>{new Date(invoice.due_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="detail-item"><label>Terms</label><p>Net 30</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty</th>
|
||||
<th style={{ textAlign: 'right' }}>Unit Price</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${(Number(item.quantity) * Number(item.unit_price)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoice.notes && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Notes</div>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{invoice.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Actions</div>
|
||||
<div className="action-buttons">
|
||||
{invoice.status === 'draft' && (
|
||||
<button className="btn btn-primary" onClick={() => updateStatus('sent')} disabled={saving}>Mark as Sent</button>
|
||||
)}
|
||||
{invoice.status === 'sent' && (
|
||||
<button className="btn btn-success" onClick={() => updateStatus('paid')} disabled={saving}>Mark as Paid</button>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
<button className="btn btn-outline" onClick={() => updateStatus('sent')} disabled={saving}>Reopen</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleDownload}>Download PDF</button>
|
||||
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete Invoice</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
export default function Invoices() {
|
||||
const navigate = useNavigate();
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, company:companies(name, email)')
|
||||
.order('created_at', { ascending: false });
|
||||
setInvoices(data || []);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const filtered = filter === 'all' ? invoices : invoices.filter(inv => inv.status === filter);
|
||||
|
||||
const totals = {
|
||||
all: invoices.reduce((s, i) => s + Number(i.total), 0),
|
||||
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
|
||||
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
|
||||
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices</div>
|
||||
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} total</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
{[
|
||||
{ label: 'Outstanding', value: totals.sent, count: invoices.filter(i => i.status === 'sent').length },
|
||||
{ label: 'Paid', value: totals.paid, count: invoices.filter(i => i.status === 'paid').length },
|
||||
{ label: 'Draft', value: totals.draft, count: invoices.filter(i => i.status === 'draft').length },
|
||||
{ label: 'Total Billed', value: totals.all, count: invoices.length },
|
||||
].map(({ label, value, count }) => (
|
||||
<div key={label} className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${value.toFixed(2)}</div>
|
||||
<div className="stat-label">{label} · {count} invoice{count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
{['all', 'draft', 'sent', 'paid'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-outline'}`}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices</h3>
|
||||
<p>Create your first invoice to get started.</p>
|
||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Company</th>
|
||||
<th>Date</th>
|
||||
<th>Due</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(inv => (
|
||||
<tr key={inv.id}>
|
||||
<td><Link to={`/invoices/${inv.id}`} className="table-link">{inv.invoice_number}</Link></td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{inv.company?.name}</div>
|
||||
{inv.company?.email && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{inv.company.email}</div>}
|
||||
</td>
|
||||
<td>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'inherit' }}>
|
||||
{new Date(inv.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||
<td style={{ fontWeight: 700 }}>${Number(inv.total).toFixed(2)}</td>
|
||||
<td>
|
||||
<Link to={`/invoices/${inv.id}`} className="btn btn-outline btn-sm">View</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||
if (!p) { setLoading(false); return; }
|
||||
setProject(p);
|
||||
|
||||
const [{ data: co }, { data: t }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', p.company_id).single(),
|
||||
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
||||
]);
|
||||
setCompany(co);
|
||||
setTasks(t || []);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate(`/companies/${company?.id}`)}>
|
||||
← Back to {company?.name}
|
||||
</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{project.name}</div>
|
||||
<div className="page-subtitle">
|
||||
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>
|
||||
{company?.name}
|
||||
</Link>
|
||||
{' · '}Started {new Date(project.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Project Info</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
|
||||
<div className="detail-item"><label>Contact</label><p>{company?.email || '—'}</p></div>
|
||||
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
|
||||
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Project Summary</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Total Jobs</label><p>{tasks.length}</p></div>
|
||||
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
|
||||
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
|
||||
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-title">Jobs</div>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>No jobs yet</h3>
|
||||
<p>Jobs will appear here when requests come in.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map(task => (
|
||||
<tr key={task.id}>
|
||||
<td>
|
||||
{task.title}
|
||||
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{'v' + String(task.current_version).padStart(2, '0')}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
||||
{task.assigned_name || 'Unassigned'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-not_started">v{task.current_version}</span>
|
||||
</td>
|
||||
<td><StatusBadge status={task.status} /></td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>
|
||||
{new Date(task.submitted_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
<Link to={`/tasks/${task.id}`} className="btn btn-outline btn-sm">View</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { mockProjects, mockTasks } from '../../data/mockData';
|
||||
|
||||
export default function Projects() {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Projects</div>
|
||||
<div className="page-subtitle">All client engagements and their tasks.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Client</th>
|
||||
<th>Company</th>
|
||||
<th>Jobs</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockProjects.map(project => {
|
||||
const tasks = mockTasks.filter(t => t.projectId === project.id);
|
||||
const activeTasks = tasks.filter(t => t.status !== 'approved');
|
||||
return (
|
||||
<tr key={project.id}>
|
||||
<td>
|
||||
<Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link>
|
||||
</td>
|
||||
<td>
|
||||
{project.clientName}
|
||||
{!project.clientId && (
|
||||
<span className="badge badge-not_started" style={{ marginLeft: 6, fontSize: 10 }}>Guest</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>{project.company || '—'}</td>
|
||||
<td>
|
||||
<span style={{ fontWeight: 600 }}>{activeTasks.length}</span>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}> / {tasks.length} active jobs</span>
|
||||
</td>
|
||||
<td><StatusBadge status={project.status} /></td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>{project.createdAt}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export default function Requests() {
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await Promise.all([
|
||||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('*'),
|
||||
supabase.from('projects').select('*'),
|
||||
supabase.from('companies').select('id, name'),
|
||||
]);
|
||||
setSubmissions(subs || []);
|
||||
setTasks(t || []);
|
||||
setProjects(p || []);
|
||||
setCompanies(co || []);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Requests Inbox</div>
|
||||
<div className="page-subtitle">All incoming submissions — initial requests and revisions.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No requests yet</h3>
|
||||
<p>Client requests will appear here.</p>
|
||||
</div>
|
||||
) : submissions.map(sub => {
|
||||
const task = tasks.find(t => t.id === sub.task_id);
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
|
||||
return (
|
||||
<div key={sub.id} className="request-card">
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
{sub.service_type}
|
||||
{sub.type === 'revision' && (
|
||||
<span className="badge badge-revision" style={{ marginLeft: 8 }}>Revision v{sub.version_number}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="request-card-meta">
|
||||
From <strong>{sub.submitted_by_name}</strong>
|
||||
{company && (
|
||||
<> · <Link to={`/companies/${company.id}`} className="table-link">{company.name}</Link></>
|
||||
)}
|
||||
{' · '}{new Date(sub.submitted_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StatusBadge status={task?.status || 'not_started'} />
|
||||
{task && <Link to={`/tasks/${task.id}`} className="btn btn-outline btn-sm">View Job</Link>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 24, marginBottom: 12 }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Deadline</span>
|
||||
<div style={{ fontSize: 13, marginTop: 2 }}>{sub.deadline || 'Not specified'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Project</span>
|
||||
<div style={{ fontSize: 13, marginTop: 2 }}>
|
||||
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{sub.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Executable
+378
@@ -0,0 +1,378 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const vLabel = (v) => 'v' + String(v).padStart(2, '0');
|
||||
const MAX_FILES = 20;
|
||||
const MAX_SIZE_MB = 10;
|
||||
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
|
||||
const formatSize = (bytes) => bytes < 1024 * 1024 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
|
||||
export default function TaskDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
const [project, setProject] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [teamMembers, setTeamMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [notification, setNotification] = useState(null);
|
||||
const [showSendForm, setShowSendForm] = useState(false);
|
||||
const [sendForm, setSendForm] = useState({ files: [], message: '' });
|
||||
const [fileErrors, setFileErrors] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
|
||||
if (!t) { setLoading(false); return; }
|
||||
setTask(t);
|
||||
|
||||
const [{ data: p }, { data: subs }, { data: team }] = await Promise.all([
|
||||
supabase.from('projects').select('*').eq('id', t.project_id).single(),
|
||||
supabase.from('submissions').select('*, delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
|
||||
supabase.from('profiles').select('*').eq('role', 'team'),
|
||||
]);
|
||||
setProject(p);
|
||||
setSubmissions(subs || []);
|
||||
setTeamMembers(team || []);
|
||||
|
||||
if (p) {
|
||||
const { data: co } = await supabase.from('companies').select('*').eq('id', p.company_id).single();
|
||||
setCompany(co);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const updateStatus = async (newStatus, message) => {
|
||||
setSaving(true);
|
||||
await supabase.from('tasks').update({ status: newStatus }).eq('id', id);
|
||||
setTask(t => ({ ...t, status: newStatus }));
|
||||
setNotification(message);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleStart = () => updateStatus('in_progress', '✓ Job started — status set to In Progress.');
|
||||
const handleOnHold = () => updateStatus('on_hold', '✓ Job placed on hold.');
|
||||
const handleResume = () => updateStatus('in_progress', '✓ Job resumed — back to In Progress.');
|
||||
|
||||
const handleAssign = async (e) => {
|
||||
const member = teamMembers.find(m => m.id === e.target.value);
|
||||
await supabase.from('tasks').update({
|
||||
assigned_to: e.target.value || null,
|
||||
assigned_name: member?.name || null,
|
||||
}).eq('id', id);
|
||||
setTask(t => ({ ...t, assigned_to: e.target.value || null, assigned_name: member?.name || null }));
|
||||
setNotification('✓ Job assigned.');
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const incoming = Array.from(e.target.files);
|
||||
const combined = [...sendForm.files, ...incoming];
|
||||
const errors = [];
|
||||
if (combined.length > MAX_FILES) errors.push(`Maximum ${MAX_FILES} files allowed.`);
|
||||
incoming.filter(f => f.size > MAX_SIZE_BYTES).forEach(f => errors.push(`"${f.name}" exceeds 10 MB limit.`));
|
||||
if (errors.length > 0) { setFileErrors(errors); return; }
|
||||
setFileErrors([]);
|
||||
setSendForm(f => ({ ...f, files: combined }));
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeFile = (index) => {
|
||||
setSendForm(f => ({ ...f, files: f.files.filter((_, i) => i !== index) }));
|
||||
setFileErrors([]);
|
||||
};
|
||||
|
||||
const handleSendToClient = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
const latestSub = submissions[submissions.length - 1];
|
||||
if (!latestSub) return;
|
||||
|
||||
const uploadedFiles = [];
|
||||
for (const file of sendForm.files) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded } = await supabase.storage.from('deliveries').upload(path, file);
|
||||
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
|
||||
}
|
||||
|
||||
const { data: delivery } = await supabase.from('deliveries').insert({
|
||||
submission_id: latestSub.id,
|
||||
sent_by: currentUser?.name,
|
||||
message: sendForm.message,
|
||||
}).select().single();
|
||||
|
||||
if (delivery && uploadedFiles.length > 0) {
|
||||
await supabase.from('delivery_files').insert(
|
||||
uploadedFiles.map(f => ({ ...f, delivery_id: delivery.id }))
|
||||
);
|
||||
}
|
||||
|
||||
await supabase.from('tasks').update({ status: 'client_review' }).eq('id', id);
|
||||
setTask(t => ({ ...t, status: 'client_review' }));
|
||||
|
||||
const { data: subs } = await supabase
|
||||
.from('submissions')
|
||||
.select('*, delivery:deliveries(*, files:delivery_files(*))')
|
||||
.eq('task_id', id)
|
||||
.order('version_number');
|
||||
setSubmissions(subs || []);
|
||||
|
||||
if (company?.email) {
|
||||
sendEmail('sent_to_client', company.email, {
|
||||
clientFirstName: company.name,
|
||||
serviceType: task.title,
|
||||
projectName: project?.name,
|
||||
message: sendForm.message,
|
||||
taskId: id,
|
||||
});
|
||||
}
|
||||
|
||||
setShowSendForm(false);
|
||||
setSendForm({ files: [], message: '' });
|
||||
setNotification(`✓ Sent to client — ${company?.name} has been notified with ${uploadedFiles.length} file${uploadedFiles.length !== 1 ? 's' : ''}.`);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const getFileUrl = async (path) => {
|
||||
const { data } = await supabase.storage.from('deliveries').createSignedUrl(path, 3600);
|
||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!task) return <Layout><p>Job not found.</p></Layout>;
|
||||
|
||||
const titleWithVersion = `${task.title} ${vLabel(task.current_version)}`;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate(`/projects/${task.project_id}`)}>
|
||||
← Back to {project?.name}
|
||||
</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{titleWithVersion}</div>
|
||||
<div className="page-subtitle">
|
||||
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
|
||||
{' › '}
|
||||
<Link to={`/projects/${project?.id}`} style={{ color: 'var(--accent)' }}>{project?.name}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
|
||||
{notification && <div className="notification notification-success">{notification}</div>}
|
||||
|
||||
{showSendForm && (
|
||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||||
<div className="card-title">Send to Client — {company?.name}</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
Upload the completed file and add an optional message. An email will be sent to <strong>{company?.email}</strong>.
|
||||
</p>
|
||||
<form onSubmit={handleSendToClient}>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Attach Files *
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>
|
||||
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each
|
||||
</span>
|
||||
</label>
|
||||
<div style={{
|
||||
border: `2px dashed ${sendForm.files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8, padding: '20px 16px', textAlign: 'center',
|
||||
background: sendForm.files.length > 0 ? '#fffbeb' : '#fafafa',
|
||||
}}>
|
||||
<input type="file" multiple onChange={handleFileChange} style={{ display: 'none' }} id="file-upload" />
|
||||
<label htmlFor="file-upload" style={{ cursor: 'pointer' }}>
|
||||
<div style={{ fontSize: 24, marginBottom: 6 }}>📎</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{sendForm.files.length > 0 ? `${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''} added` : 'Click to add files'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>Any file type accepted</div>
|
||||
</label>
|
||||
</div>
|
||||
{fileErrors.map((err, i) => <div key={i} style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>⚠ {err}</div>)}
|
||||
{sendForm.files.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{sendForm.files.map((file, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'white', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📄</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => removeFile(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16 }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Message to Client <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<textarea
|
||||
placeholder={`Hi ${company?.name}, your ${task.title} is ready for review!`}
|
||||
value={sendForm.message}
|
||||
onChange={e => setSendForm(f => ({ ...f, message: e.target.value }))}
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-success" disabled={sendForm.files.length === 0 || saving}>
|
||||
{saving ? 'Uploading...' : `✉️ Send to Client${sendForm.files.length > 0 ? ` (${sendForm.files.length} file${sendForm.files.length !== 1 ? 's' : ''})` : ''}`}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowSendForm(false); setSendForm({ files: [], message: '' }); }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Job Details</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 16 }}>
|
||||
<div className="detail-item"><label>Status</label><p><StatusBadge status={task.status} /></p></div>
|
||||
<div className="detail-item"><label>Version</label><p style={{ fontWeight: 700, fontSize: 16 }}>{vLabel(task.current_version)}</p></div>
|
||||
<div className="detail-item"><label>Submitted</label><p>{new Date(task.submitted_at).toLocaleDateString()}</p></div>
|
||||
<div className="detail-item"><label>Completed</label><p>{task.completed_at ? new Date(task.completed_at).toLocaleDateString() : '—'}</p></div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Assigned To</label>
|
||||
<select value={task.assigned_to || ''} onChange={handleAssign} style={{ width: '100%' }}>
|
||||
<option value="">Unassigned</option>
|
||||
{teamMembers.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="action-buttons" style={{ marginTop: 8 }}>
|
||||
{task.status === 'not_started' && (
|
||||
<button className="btn btn-primary" onClick={handleStart} disabled={saving}>▶ Start Job</button>
|
||||
)}
|
||||
{task.status === 'in_progress' && !showSendForm && (
|
||||
<>
|
||||
<button className="btn btn-success" onClick={() => setShowSendForm(true)}>✉️ Send to Client</button>
|
||||
<button className="btn btn-outline" onClick={handleOnHold} disabled={saving}>⏸ Put On Hold</button>
|
||||
</>
|
||||
)}
|
||||
{task.status === 'on_hold' && (
|
||||
<button className="btn btn-primary" onClick={handleResume} disabled={saving}>▶ Resume</button>
|
||||
)}
|
||||
{task.status === 'client_review' && (
|
||||
<div style={{ padding: '10px 14px', background: '#f5f3ff', borderRadius: 8, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
|
||||
⏳ Awaiting client review — no action needed.
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'client_approved' && (
|
||||
<div style={{ padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, fontSize: 13, color: '#16a34a', fontWeight: 500 }}>
|
||||
✓ Client approved this job.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Current Request Notes</div>
|
||||
{submissions.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No request notes yet.</p>
|
||||
) : (() => {
|
||||
const latest = submissions[submissions.length - 1];
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 13 }}>{vLabel(latest.version_number - 1)}</span>
|
||||
<StatusBadge status={latest.type} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
|
||||
{latest.submitted_by_name} · {new Date(latest.submitted_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 14 }}>
|
||||
<div className="detail-item"><label>Service Type</label><p>{latest.service_type}</p></div>
|
||||
<div className="detail-item"><label>Deadline</label><p>{latest.deadline || '—'}</p></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Description</label>
|
||||
<p style={{ marginTop: 8, fontSize: 14, lineHeight: 1.7, color: 'var(--text-primary)', background: 'var(--bg)', padding: '12px 14px', borderRadius: 8, border: '1px solid var(--border)', whiteSpace: 'pre-wrap' }}>
|
||||
{latest.description}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submissions.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">Version History</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{submissions.map((sub, i) => {
|
||||
const delivery = sub.delivery;
|
||||
const isCurrent = i === submissions.length - 1;
|
||||
return (
|
||||
<div key={sub.id} style={{ borderRadius: 8, border: `1px solid ${isCurrent ? '#fde68a' : 'var(--border)'}`, background: isCurrent ? '#fffbeb' : 'var(--bg)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 13 }}>{vLabel(sub.version_number - 1)}</span>
|
||||
<StatusBadge status={sub.type} />
|
||||
{isCurrent && <span style={{ fontSize: 11, color: '#d97706', fontWeight: 600 }}>Current</span>}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
|
||||
{sub.submitted_by_name} · {new Date(sub.submitted_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', borderBottom: delivery ? '1px solid var(--border)' : 'none' }}>
|
||||
<div style={{ display: 'flex', gap: 24, marginBottom: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Service</span>
|
||||
<div style={{ fontSize: 13, marginTop: 2 }}>{sub.service_type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Deadline</span>
|
||||
<div style={{ fontSize: 13, marginTop: 2 }}>{sub.deadline || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{sub.description}</p>
|
||||
</div>
|
||||
{delivery ? (
|
||||
<div style={{ padding: '10px 16px', background: '#f0fdf4', borderTop: '1px solid #bbf7d0' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#16a34a', marginBottom: 8 }}>
|
||||
✓ Delivered by {delivery.sent_by} on {new Date(delivery.sent_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(delivery.files || []).map((file, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'white', borderRadius: 6, border: '1px solid #bbf7d0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📄</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
||||
{file.size > 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</span>}
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => getFileUrl(file.storage_path)}>
|
||||
📥 View
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '10px 16px', background: '#f8f8f8' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>📎 No file delivered yet for this version.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user