Add Stripe fee tracking on paid invoices + backfill function
- Store stripe_fee on invoices when webhook receives checkout.session.completed - Display Stripe fee and net received in InvoiceDetail when paid via Stripe - Add backfill-stripe-fees edge function to populate fee on existing paid invoices - Migration: add stripe_fee column to invoices table - Includes all pending portal changes (brand book, sign survey, task/project/company updates, etc.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
const MAX_FILES = 20;
|
||||
const MAX_SIZE_MB = 10;
|
||||
@@ -11,24 +11,48 @@ const formatSize = (bytes) => {
|
||||
|
||||
export default function FileAttachment({ files, onChange }) {
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const incoming = Array.from(e.target.files);
|
||||
const processFiles = (incoming) => {
|
||||
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 (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);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
processFiles(Array.from(e.target.files));
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current++;
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) setDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setDragging(false);
|
||||
const dropped = Array.from(e.dataTransfer.files);
|
||||
if (dropped.length > 0) processFiles(dropped);
|
||||
};
|
||||
|
||||
const remove = (index) => {
|
||||
setErrors([]);
|
||||
onChange(files.filter((_, i) => i !== index));
|
||||
@@ -43,16 +67,27 @@ export default function FileAttachment({ files, onChange }) {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{
|
||||
border: `2px dashed ${files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8, padding: '18px 16px', textAlign: 'center',
|
||||
background: 'var(--bg)', transition: 'all 0.15s',
|
||||
}}>
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8, padding: '18px 16px', textAlign: 'center',
|
||||
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)',
|
||||
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={{ fontSize: 22, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</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'}
|
||||
{dragging
|
||||
? 'Drop files here'
|
||||
: files.length > 0
|
||||
? `${files.length} file${files.length !== 1 ? 's' : ''} attached — click or drag to add more`
|
||||
: 'Click or drag files here'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
|
||||
</label>
|
||||
|
||||
@@ -7,9 +7,10 @@ function TeamNav({ onNav }) {
|
||||
<div className="sidebar-section">
|
||||
{[
|
||||
{ to: '/dashboard', label: 'Dashboard' },
|
||||
{ to: '/requests', label: 'Requests' },
|
||||
{ to: '/requests', label: 'Requests Inbox' },
|
||||
{ to: '/brand-book', label: 'Brand Book (beta)' },
|
||||
{ to: '/invoices', label: 'Invoices' },
|
||||
{ to: '/companies', label: 'Companies' },
|
||||
{ to: '/companies', label: 'Clients & Users' },
|
||||
].map(({ to, label }) => (
|
||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
{label}
|
||||
@@ -36,6 +37,16 @@ function ClientNav({ onNav }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalNav({ onNav }) {
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
<NavLink to="/dashboard" onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -69,14 +80,16 @@ export default function Layout({ children }) {
|
||||
|
||||
{currentUser?.role === 'team'
|
||||
? <TeamNav onNav={() => setMenuOpen(false)} />
|
||||
: <ClientNav onNav={() => setMenuOpen(false)} />}
|
||||
: currentUser?.role === 'external'
|
||||
? <ExternalNav 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 className="sidebar-user-role">{currentUser?.role === 'external' ? 'Team' : currentUser?.role}</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '0 12px', gap: 8 }}>
|
||||
|
||||
@@ -4,9 +4,11 @@ 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-dashboard'} replace />;
|
||||
if (role) {
|
||||
const allowed = Array.isArray(role) ? role : [role];
|
||||
if (!allowed.includes(currentUser.role)) {
|
||||
return <Navigate to={currentUser.role === 'client' ? '/my-dashboard' : '/dashboard'} replace />;
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const labels = {
|
||||
client_revision: 'Client Revision',
|
||||
fourge_error: 'Fourge Error',
|
||||
not_started: 'Not Started',
|
||||
in_progress: 'In Progress',
|
||||
on_hold: 'On Hold',
|
||||
|
||||
Reference in New Issue
Block a user