Refactor: clients → companies schema v2
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user