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:
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
||||
|
||||
export default function Companies() {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,9 +14,12 @@ export default function Companies() {
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
|
||||
const [showNewUser, setShowNewUser] = useState(false);
|
||||
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '' });
|
||||
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [userError, setUserError] = useState('');
|
||||
const [editingUserId, setEditingUserId] = useState(null);
|
||||
const [editUserVal, setEditUserVal] = useState('');
|
||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -52,6 +56,37 @@ export default function Companies() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCompany = async (company) => {
|
||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||||
|
||||
const companyProjects = projects.filter(p => p.company_id === company.id);
|
||||
const projectIds = companyProjects.map(p => p.id);
|
||||
if (projectIds.length) {
|
||||
const companyTaskIds = tasks.filter(t => projectIds.includes(t.project_id)).map(t => t.id);
|
||||
await cleanupTaskStorage(companyTaskIds);
|
||||
}
|
||||
|
||||
await supabase.from('companies').delete().eq('id', company.id);
|
||||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||||
};
|
||||
|
||||
const handleEditUserSave = async (userId) => {
|
||||
if (!editUserVal.trim()) return;
|
||||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||||
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
||||
setEditingUserId(null);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user) => {
|
||||
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
|
||||
setDeletingUserId(user.id);
|
||||
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
|
||||
const errMsg = data?.error || error?.message;
|
||||
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
|
||||
setProfiles(prev => prev.filter(u => u.id !== user.id));
|
||||
setDeletingUserId(null);
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setUserError('');
|
||||
@@ -61,7 +96,8 @@ export default function Companies() {
|
||||
name: userForm.name.trim(),
|
||||
email: userForm.email.trim(),
|
||||
password: userForm.password,
|
||||
company_id: userForm.company_id || null,
|
||||
role: userForm.role,
|
||||
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
@@ -71,7 +107,7 @@ export default function Companies() {
|
||||
return;
|
||||
}
|
||||
setShowNewUser(false);
|
||||
setUserForm({ name: '', email: '', password: '', company_id: '' });
|
||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
load();
|
||||
};
|
||||
|
||||
@@ -83,7 +119,7 @@ export default function Companies() {
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Companies</div>
|
||||
<div className="page-title">Clients & Users</div>
|
||||
<div className="page-subtitle">
|
||||
{companies.length} {companies.length !== 1 ? 'companies' : 'company'}
|
||||
{unassigned.length > 0 && (
|
||||
@@ -94,10 +130,10 @@ export default function Companies() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-outline" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}>
|
||||
{showNewUser ? 'Cancel' : '+ New User'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}>
|
||||
{showNew ? 'Cancel' : '+ New Company'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -126,13 +162,23 @@ export default function Companies() {
|
||||
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Assign to Company</label>
|
||||
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
|
||||
<option value="">No company yet</option>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
<label>Role *</label>
|
||||
<select value={userForm.role} onChange={e => setUserForm(f => ({ ...f, role: e.target.value, company_id: '' }))}>
|
||||
<option value="client">Client</option>
|
||||
<option value="team">Team</option>
|
||||
<option value="external">External</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{userForm.role === 'client' && (
|
||||
<div className="form-group">
|
||||
<label>Assign to Company</label>
|
||||
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
|
||||
<option value="">No company yet</option>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
@@ -198,17 +244,42 @@ export default function Companies() {
|
||||
<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 style={{ flex: 1 }}>
|
||||
{editingUserId === user.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editUserVal}
|
||||
onChange={e => setEditUserVal(e.target.value)}
|
||||
autoFocus
|
||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||
</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>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500, marginRight: 4 }}>No company</span>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={deletingUserId === user.id}
|
||||
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>
|
||||
Open a company and assign them from the Users tab.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -228,7 +299,7 @@ export default function Companies() {
|
||||
|
||||
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)' }}>
|
||||
<Link to={`/companies/${company.id}`} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)', textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)' }}>{company.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
@@ -239,8 +310,16 @@ export default function Companies() {
|
||||
{company.phone && <> · {company.phone}</>}
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/companies/${company.id}`} className="btn btn-outline btn-sm">View</Link>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>›</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.preventDefault(); e.stopPropagation(); handleDeleteCompany(company); }}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px', lineHeight: 1 }}
|
||||
title="Delete company"
|
||||
>✕</button>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{companyProfiles.length > 0 && (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user