Fix file sharing load speed and move error; misc updates
- Remove recursive directory size calculations (single Seafile API call per list) - Remove 'Used in this location' usage display - Fix move using v2 per-type endpoints instead of broken batch endpoint - Send entry type from frontend for correct move routing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+3002
-268
File diff suppressed because it is too large
Load Diff
@@ -1,505 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateBrandBookPDF } from '../../lib/brandbook';
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
{ name: 'Primary', hex: '#1a1a1a' },
|
||||
{ name: 'Accent', hex: '#F5A523' },
|
||||
{ name: 'White', hex: '#ffffff' },
|
||||
];
|
||||
|
||||
const EMPTY_FORM = {
|
||||
// Cover page
|
||||
selectedCompanyId: '',
|
||||
projectLogoDataUrl: null,
|
||||
creationDate: new Date().toISOString().slice(0, 10),
|
||||
revisionDate: '',
|
||||
customerName: '',
|
||||
streetAddress: '',
|
||||
clientLogoUrl: '',
|
||||
clientContactName: '',
|
||||
clientContactEmail: '',
|
||||
clientContactPhone: '',
|
||||
approvedDate: '',
|
||||
approvalNotes: '',
|
||||
// Brand identity
|
||||
companyName: '',
|
||||
brandName: '',
|
||||
tagline: '',
|
||||
brandStory: '',
|
||||
brandValues: '',
|
||||
colors: DEFAULT_COLORS,
|
||||
primaryFont: '',
|
||||
secondaryFont: '',
|
||||
fontNotes: '',
|
||||
brandVoice: '',
|
||||
brandAdjectives: '',
|
||||
logoNotes: '',
|
||||
dos: '',
|
||||
donts: '',
|
||||
};
|
||||
|
||||
export default function BrandBook() {
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [notification, setNotification] = useState(null);
|
||||
const [uploadingClientLogo, setUploadingClientLogo] = useState(false);
|
||||
const [savingClientInfo, setSavingClientInfo] = useState(false);
|
||||
const [clientInfoSaved, setClientInfoSaved] = useState(false);
|
||||
|
||||
const projectLogoRef = useRef();
|
||||
const clientLogoRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('companies').select('id, name').order('name').then(({ data }) => setCompanies(data || []));
|
||||
}, []);
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const handleCompanyChange = async (e) => {
|
||||
const companyId = e.target.value;
|
||||
if (!companyId) {
|
||||
setForm(f => ({ ...f, selectedCompanyId: '', companyName: '', customerName: '', streetAddress: '', clientLogoUrl: '', clientContactName: '', clientContactEmail: '', clientContactPhone: '' }));
|
||||
return;
|
||||
}
|
||||
const { data: co } = await supabase.from('companies').select('*').eq('id', companyId).single();
|
||||
if (co) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
selectedCompanyId: companyId,
|
||||
companyName: co.name,
|
||||
customerName: f.customerName || co.name,
|
||||
streetAddress: co.address || f.streetAddress,
|
||||
clientLogoUrl: co.client_logo_url || '',
|
||||
clientContactName: co.contact_name || '',
|
||||
clientContactEmail: co.contact_email || '',
|
||||
clientContactPhone: co.contact_phone || '',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectLogoUpload = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setForm(f => ({ ...f, projectLogoDataUrl: ev.target.result }));
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleClientLogoUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!form.selectedCompanyId) {
|
||||
setNotification({ type: 'error', msg: 'Select a company before uploading their logo.' });
|
||||
return;
|
||||
}
|
||||
setUploadingClientLogo(true);
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
const path = `${form.selectedCompanyId}/logo.${ext}`;
|
||||
await supabase.storage.from('company-logos').remove([path]);
|
||||
const { error } = await supabase.storage.from('company-logos').upload(path, file, { upsert: true });
|
||||
if (!error) {
|
||||
const { data: { publicUrl } } = supabase.storage.from('company-logos').getPublicUrl(path);
|
||||
await supabase.from('companies').update({ client_logo_url: publicUrl }).eq('id', form.selectedCompanyId);
|
||||
setForm(f => ({ ...f, clientLogoUrl: publicUrl }));
|
||||
} else {
|
||||
setNotification({ type: 'error', msg: 'Failed to upload client logo.' });
|
||||
}
|
||||
setUploadingClientLogo(false);
|
||||
};
|
||||
|
||||
const handleSaveClientInfo = async () => {
|
||||
if (!form.selectedCompanyId) return;
|
||||
setSavingClientInfo(true);
|
||||
await supabase.from('companies').update({
|
||||
address: form.streetAddress || null,
|
||||
contact_name: form.clientContactName || null,
|
||||
contact_email: form.clientContactEmail || null,
|
||||
contact_phone: form.clientContactPhone || null,
|
||||
}).eq('id', form.selectedCompanyId);
|
||||
setSavingClientInfo(false);
|
||||
setClientInfoSaved(true);
|
||||
setTimeout(() => setClientInfoSaved(false), 2500);
|
||||
};
|
||||
|
||||
const setColor = (i, field, value) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
colors: f.colors.map((c, idx) => idx === i ? { ...c, [field]: value } : c),
|
||||
}));
|
||||
};
|
||||
|
||||
const addColor = () => setForm(f => ({ ...f, colors: [...f.colors, { name: '', hex: '#cccccc' }] }));
|
||||
const removeColor = (i) => setForm(f => ({ ...f, colors: f.colors.filter((_, idx) => idx !== i) }));
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!form.brandName.trim()) {
|
||||
setNotification({ type: 'error', msg: 'Brand name is required.' });
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
setNotification(null);
|
||||
try {
|
||||
await generateBrandBookPDF(form);
|
||||
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
|
||||
} catch (err) {
|
||||
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm('Clear all fields and start over?')) {
|
||||
setForm(EMPTY_FORM);
|
||||
setNotification(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Brand Book <span style={{ fontSize: 13, fontWeight: 500, color: 'var(--accent)', marginLeft: 8, padding: '2px 8px', border: '1px solid var(--accent)', borderRadius: 4 }}>beta</span></div>
|
||||
<div className="page-subtitle">Fill in the brand details below and generate a PDF brand book.</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={handleReset}>Reset</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleGenerate} disabled={generating}>
|
||||
{generating ? 'Generating...' : '⬇ Generate PDF'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notification && (
|
||||
<div className={`notification ${notification.type === 'error' ? 'notification-error' : 'notification-success'}`} style={{ marginBottom: 24 }}>
|
||||
{notification.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* ── COVER PAGE ─────────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Cover Page</div>
|
||||
|
||||
{/* Company + Brand Name */}
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Client Company</label>
|
||||
<select value={form.selectedCompanyId} onChange={handleCompanyChange}>
|
||||
<option value="">— Select company —</option>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Brand Name *</label>
|
||||
<input type="text" placeholder="e.g. Acme Corp" value={form.brandName} onChange={set('brandName')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Logo */}
|
||||
<div className="form-group">
|
||||
<label>Project Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(top left, 5"×5" area)</span></label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{form.projectLogoDataUrl && (
|
||||
<img
|
||||
src={form.projectLogoDataUrl}
|
||||
alt="Project logo preview"
|
||||
style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => projectLogoRef.current?.click()}>
|
||||
{form.projectLogoDataUrl ? 'Replace Logo' : 'Upload Logo'}
|
||||
</button>
|
||||
{form.projectLogoDataUrl && (
|
||||
<button className="btn btn-outline btn-sm" style={{ marginLeft: 8, color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={() => setForm(f => ({ ...f, projectLogoDataUrl: null }))}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<input ref={projectLogoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleProjectLogoUpload} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Creation Date</label>
|
||||
<input type="date" value={form.creationDate} onChange={set('creationDate')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Revision Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="date" value={form.revisionDate} onChange={set('revisionDate')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer info */}
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Customer Name</label>
|
||||
<input type="text" placeholder="e.g. Acme Corp" value={form.customerName} onChange={set('customerName')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Street Address <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="text" placeholder="e.g. 123 Main St, City, State 00000" value={form.streetAddress} onChange={set('streetAddress')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '8px 0 20px' }} />
|
||||
|
||||
{/* Client info (saved per company) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Client Info</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Saved to the selected company — reused across all brand books.</div>
|
||||
</div>
|
||||
{form.selectedCompanyId && (
|
||||
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo}>
|
||||
{savingClientInfo ? 'Saving...' : clientInfoSaved ? '✓ Saved' : 'Save to Company'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client logo */}
|
||||
<div className="form-group">
|
||||
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{form.clientLogoUrl && (
|
||||
<img
|
||||
src={form.clientLogoUrl}
|
||||
alt="Client logo preview"
|
||||
style={{ maxHeight: 50, maxWidth: 140, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => clientLogoRef.current?.click()}
|
||||
disabled={uploadingClientLogo}
|
||||
>
|
||||
{uploadingClientLogo ? 'Uploading...' : form.clientLogoUrl ? 'Replace Logo' : 'Upload Logo'}
|
||||
</button>
|
||||
{!form.selectedCompanyId && (
|
||||
<span style={{ marginLeft: 10, fontSize: 12, color: 'var(--text-muted)' }}>Select a company first</span>
|
||||
)}
|
||||
<input ref={clientLogoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleClientLogoUpload} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginTop: 4 }}>
|
||||
<div className="form-group">
|
||||
<label>Contact Name</label>
|
||||
<input type="text" placeholder="e.g. Jane Smith" value={form.clientContactName} onChange={set('clientContactName')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" placeholder="e.g. jane@client.com" value={form.clientContactEmail} onChange={set('clientContactEmail')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ maxWidth: 320 }}>
|
||||
<label>Phone</label>
|
||||
<input type="text" placeholder="e.g. (555) 000-0000" value={form.clientContactPhone} onChange={set('clientContactPhone')} />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '8px 0 20px' }} />
|
||||
|
||||
{/* Approval */}
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Approved Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="date" value={form.approvedDate} onChange={set('approvedDate')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<textarea
|
||||
placeholder="Any approval notes or conditions..."
|
||||
value={form.approvalNotes}
|
||||
onChange={set('approvalNotes')}
|
||||
style={{ minHeight: 70 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── BRAND IDENTITY ─────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Brand Identity</div>
|
||||
<div className="form-group">
|
||||
<label>Tagline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="text" placeholder="e.g. Built for the bold." value={form.tagline} onChange={set('tagline')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── BRAND STORY ────────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Brand Story & Values</div>
|
||||
<div className="form-group">
|
||||
<label>Brand Story</label>
|
||||
<textarea
|
||||
placeholder="Describe the brand — its origin, mission, and what makes it unique..."
|
||||
value={form.brandStory}
|
||||
onChange={set('brandStory')}
|
||||
style={{ minHeight: 120 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Brand Values
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>one per line</span>
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={"Innovation\nAuthenticity\nCommunity"}
|
||||
value={form.brandValues}
|
||||
onChange={set('brandValues')}
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── COLOR PALETTE ──────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>Color Palette</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={addColor}>+ Add Color</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{form.colors.map((color, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={color.hex}
|
||||
onChange={e => setColor(i, 'hex', e.target.value)}
|
||||
style={{ width: 44, height: 38, padding: 2, borderRadius: 6, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color.hex}
|
||||
onChange={e => setColor(i, 'hex', e.target.value)}
|
||||
placeholder="#000000"
|
||||
style={{ width: 100, margin: 0, fontFamily: 'monospace', fontSize: 13 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color.name}
|
||||
onChange={e => setColor(i, 'name', e.target.value)}
|
||||
placeholder="Color name"
|
||||
style={{ flex: 1, margin: 0 }}
|
||||
/>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 6, background: color.hex, border: '1px solid var(--border)', flexShrink: 0 }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeColor(i)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px', flexShrink: 0 }}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TYPOGRAPHY ─────────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Typography</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Primary Font</label>
|
||||
<input type="text" placeholder="e.g. Neue Haas Grotesk" value={form.primaryFont} onChange={set('primaryFont')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Secondary Font</label>
|
||||
<input type="text" placeholder="e.g. Freight Text Pro" value={form.secondaryFont} onChange={set('secondaryFont')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Usage Notes</label>
|
||||
<textarea
|
||||
placeholder="e.g. Primary font used for headings and UI. Secondary font for body copy and long-form text..."
|
||||
value={form.fontNotes}
|
||||
onChange={set('fontNotes')}
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── BRAND VOICE ────────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Brand Voice & Tone</div>
|
||||
<div className="form-group">
|
||||
<label>Voice Description</label>
|
||||
<textarea
|
||||
placeholder="Describe how the brand communicates — its personality, tone, and style of writing..."
|
||||
value={form.brandVoice}
|
||||
onChange={set('brandVoice')}
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Brand Adjectives
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>comma separated</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Bold, Approachable, Modern, Trustworthy"
|
||||
value={form.brandAdjectives}
|
||||
onChange={set('brandAdjectives')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── LOGO GUIDELINES ────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Logo Usage Guidelines</div>
|
||||
<div className="form-group">
|
||||
<label>Guidelines</label>
|
||||
<textarea
|
||||
placeholder="e.g. Always use the full-color logo on light backgrounds. Use the white version on dark backgrounds. Minimum size 40px. Never stretch, rotate, or recolor the logo..."
|
||||
value={form.logoNotes}
|
||||
onChange={set('logoNotes')}
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── DO'S & DON'TS ──────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Do's & Don'ts</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label style={{ color: '#16a34a' }}>✓ Do's <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>one per line</span></label>
|
||||
<textarea
|
||||
placeholder={"Use brand colors consistently\nMaintain clear space around the logo\nUse approved fonts only"}
|
||||
value={form.dos}
|
||||
onChange={set('dos')}
|
||||
style={{ minHeight: 120 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ color: '#dc2626' }}>✕ Don'ts <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>one per line</span></label>
|
||||
<textarea
|
||||
placeholder={"Don't alter the logo colors\nDon't use unapproved fonts\nDon't place logo on busy backgrounds"}
|
||||
value={form.donts}
|
||||
onChange={set('donts')}
|
||||
style={{ minHeight: 120 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
|
||||
<button className="btn btn-outline" onClick={handleReset}>Reset</button>
|
||||
<button className="btn btn-primary btn-lg" onClick={handleGenerate} disabled={generating}>
|
||||
{generating ? 'Generating...' : '⬇ Generate Brand Book PDF'}
|
||||
</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+472
-150
@@ -1,16 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
||||
import { deleteCompanyData } from '../../lib/deleteHelpers';
|
||||
import { restoreCompanyArchive } from '../../lib/archiveHelpers';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
||||
|
||||
function getRoleLabel(role) {
|
||||
if (role === 'external') return 'Subcontractor';
|
||||
if (role === 'client') return 'Client';
|
||||
if (role === 'team') return 'Team';
|
||||
return role || '—';
|
||||
}
|
||||
|
||||
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 cached = readPageCache('team_companies');
|
||||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||||
const [profiles, setProfiles] = useState(() => cached?.profiles || []);
|
||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
|
||||
const [showNewUser, setShowNewUser] = useState(false);
|
||||
@@ -20,25 +30,29 @@ export default function Companies() {
|
||||
const [editingUserId, setEditingUserId] = useState(null);
|
||||
const [editUserVal, setEditUserVal] = useState('');
|
||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||
const [restoringArchive, setRestoringArchive] = useState(false);
|
||||
const [restoreStatus, setRestoreStatus] = useState('');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('companies');
|
||||
const restoreInputRef = useRef(null);
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
|
||||
supabase.from('companies').select('*').order('name'),
|
||||
supabase.from('profiles').select('id, name, email, company_id, role').in('role', ['client', 'external']).order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
]);
|
||||
setCompanies(co || []);
|
||||
setProfiles(prof || []);
|
||||
setCompanyMemberships(memberships || []);
|
||||
writePageCache('team_companies', { companies: co || [], profiles: prof || [], companyMemberships: memberships || [] });
|
||||
setLoading(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;
|
||||
@@ -50,6 +64,7 @@ export default function Companies() {
|
||||
}).select().single();
|
||||
setSaving(false);
|
||||
if (data) {
|
||||
syncSeafileFolders().catch((error) => console.warn('Seafile folder sync failed:', error.message));
|
||||
setShowNew(false);
|
||||
setNewForm({ name: '', phone: '', address: '' });
|
||||
navigate(`/companies/${data.id}`);
|
||||
@@ -58,16 +73,39 @@ 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);
|
||||
await deleteCompanyData(company.id);
|
||||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||||
load();
|
||||
};
|
||||
|
||||
const handleRestoreArchive = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setRestoringArchive(true);
|
||||
try {
|
||||
const result = await restoreCompanyArchive(file, { onProgress: setRestoreStatus });
|
||||
await load();
|
||||
|
||||
const missing = [];
|
||||
if (result.missingProfiles.companyProfiles) missing.push(`${result.missingProfiles.companyProfiles} client profiles were not re-linked`);
|
||||
if (result.missingProfiles.projectMembers) missing.push(`${result.missingProfiles.projectMembers} external project memberships were skipped`);
|
||||
if (result.missingProfiles.taskAssignments) missing.push(`${result.missingProfiles.taskAssignments} task assignments were cleared`);
|
||||
if (result.missingProfiles.submissions) missing.push(`${result.missingProfiles.submissions} submission user links were cleared`);
|
||||
if (result.missingProfiles.invoices) missing.push(`${result.missingProfiles.invoices} invoice creator links were cleared`);
|
||||
|
||||
alert(
|
||||
missing.length
|
||||
? `Restored ${result.companyCount || 1} compan${(result.companyCount || 1) === 1 ? 'y' : 'ies'}.\n\nNote: ${missing.join('; ')}.`
|
||||
: `Restored ${result.companyCount || 1} compan${(result.companyCount || 1) === 1 ? 'y' : 'ies'} successfully.`
|
||||
);
|
||||
} catch (error) {
|
||||
alert(`Restore failed: ${error.message}`);
|
||||
} finally {
|
||||
setRestoringArchive(false);
|
||||
setRestoreStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUserSave = async (userId) => {
|
||||
@@ -81,7 +119,8 @@ export default function Companies() {
|
||||
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;
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || 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);
|
||||
@@ -101,19 +140,33 @@ export default function Companies() {
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
const errMsg = data?.error || error?.message || (error ? JSON.stringify(error) : null);
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
if (errMsg) {
|
||||
setUserError(errMsg);
|
||||
return;
|
||||
}
|
||||
setShowNewUser(false);
|
||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
|
||||
load();
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const unassigned = profiles.filter(p => !p.company_id);
|
||||
const getProfileCompanyIds = (profile) => {
|
||||
const ids = new Set(
|
||||
companyMemberships
|
||||
.filter(membership => membership.profile_id === profile.id && profile.role === 'client')
|
||||
.map(membership => membership.company_id)
|
||||
);
|
||||
if (profile.role === 'client' && profile.company_id) ids.add(profile.company_id);
|
||||
return [...ids];
|
||||
};
|
||||
|
||||
const clientProfiles = profiles.filter(profile => profile.role === 'client');
|
||||
const subcontractors = profiles.filter(profile => profile.role === 'external');
|
||||
const unassigned = clientProfiles.filter(profile => getProfileCompanyIds(profile).length === 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -122,20 +175,48 @@ export default function Companies() {
|
||||
<div className="page-title">Clients & Users</div>
|
||||
<div className="page-subtitle">
|
||||
{companies.length} {companies.length !== 1 ? 'companies' : 'company'}
|
||||
<span style={{ marginLeft: 10 }}>
|
||||
· {clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={{ marginLeft: 10 }}>
|
||||
· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{unassigned.length > 0 && (
|
||||
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
|
||||
· {unassigned.length} unassigned user{unassigned.length !== 1 ? 's' : ''}
|
||||
· {unassigned.length} unassigned client{unassigned.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setShowNewUser(s => !s); setShowNew(false); setUserError(''); }}>
|
||||
{showNewUser ? 'Cancel' : '+ New User'}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(s => !s); setShowNewUser(false); }}>
|
||||
{showNew ? 'Cancel' : '+ New Company'}
|
||||
</button>
|
||||
<input
|
||||
ref={restoreInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleRestoreArchive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🏢</div>
|
||||
<div className="stat-value">{companies.length}</div>
|
||||
<div className="stat-label">Companies</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">👥</div>
|
||||
<div className="stat-value">{clientProfiles.length}</div>
|
||||
<div className="stat-label">Client Users</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🧾</div>
|
||||
<div className="stat-value">{subcontractors.length}</div>
|
||||
<div className="stat-label">Subcontractors</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⚠️</div>
|
||||
<div className="stat-value">{unassigned.length}</div>
|
||||
<div className="stat-label">Unassigned Clients</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +247,7 @@ export default function Companies() {
|
||||
<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>
|
||||
<option value="external">Subcontractor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,121 +316,362 @@ export default function Companies() {
|
||||
</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 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>
|
||||
{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>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'companies', label: 'Companies' },
|
||||
{ id: 'clients', label: 'Clients' },
|
||||
{ id: 'subcontractors', label: 'Subcontractors' },
|
||||
].map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{activeTab === 'companies' && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setShowNew(true); setShowNewUser(false); }}
|
||||
>
|
||||
+ New Company
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'clients' && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'client' })); }}
|
||||
>
|
||||
+ New Client
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'subcontractors' && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'external', company_id: '' })); }}
|
||||
>
|
||||
+ New Subcontractor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'companies' && (
|
||||
<>
|
||||
<div className="card request-toolbar-card">
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Restore Archive</div>
|
||||
<div className="request-toolbar-actions">
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => restoreInputRef.current?.click()}
|
||||
disabled={restoringArchive}
|
||||
>
|
||||
{restoringArchive ? 'Restoring...' : 'Restore Archive'}
|
||||
</button>
|
||||
</div>
|
||||
{restoreStatus && <div className="request-toolbar-status">{restoreStatus}</div>}
|
||||
</div>
|
||||
</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 className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Clients</th>
|
||||
<th>Phone</th>
|
||||
<th>Address</th>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{companies.map(company => {
|
||||
const companyProfiles = clientProfiles.filter(profile => getProfileCompanyIds(profile).includes(company.id));
|
||||
|
||||
return (
|
||||
<tr key={company.id} onClick={() => navigate(`/companies/${company.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{company.name}</div>
|
||||
{companyProfiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{companyProfiles.map(profile => (
|
||||
<div key={profile.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, color: 'var(--text-muted)', fontSize: 12, lineHeight: 1.4 }}>
|
||||
<span style={{ color: 'var(--accent)', lineHeight: 1.2 }}>•</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontWeight: 600 }}>{profile.name || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{companyProfiles.length}</td>
|
||||
<td>{company.phone || '—'}</td>
|
||||
<td>{company.address || '—'}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => handleDeleteCompany(company)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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)' }}>
|
||||
<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 }}>
|
||||
{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.phone && <> · {company.phone}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>›</span>
|
||||
{activeTab === 'clients' && (
|
||||
<>
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
{companies.length > 0 && (
|
||||
<div className="request-toolbar-grid">
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||||
<div className="request-filter-row">
|
||||
<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>
|
||||
{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',
|
||||
}}
|
||||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany('')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{companies.map(company => (
|
||||
<button
|
||||
key={company.id}
|
||||
className={`btn btn-sm ${filterCompany === company.id ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany(current => current === company.id ? '' : company.id)}
|
||||
>
|
||||
<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>
|
||||
{company.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 24, borderColor: 'var(--danger)' }}>
|
||||
<div className="card-title" style={{ color: 'var(--danger)' }}>Unassigned Client Users</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
These client users are not linked 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 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>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clientProfiles.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany)).length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No client users</h3>
|
||||
<p>Create a client user to link them to a company.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Company</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clientProfiles
|
||||
.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany))
|
||||
.map(user => {
|
||||
const companyNames = getProfileCompanyIds(user)
|
||||
.map(companyId => companies.find(company => company.id === companyId)?.name)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{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>
|
||||
) : (
|
||||
user.name || '—'
|
||||
)}
|
||||
</td>
|
||||
<td>{user.email || '—'}</td>
|
||||
<td>{companyNames.length ? companyNames.join(', ') : '—'}</td>
|
||||
<td>{getRoleLabel(user.role)}</td>
|
||||
<td>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'subcontractors' && (
|
||||
<div>
|
||||
{subcontractors.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '20px 18px' }}>
|
||||
<h3>No subcontractors yet</h3>
|
||||
<p>Create a subcontractor user to manage external access and POs.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subcontractors.map(user => (
|
||||
<tr key={user.id}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{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>
|
||||
) : (
|
||||
user.name || '—'
|
||||
)}
|
||||
</td>
|
||||
<td>{user.email || '—'}</td>
|
||||
<td>{getRoleLabel(user.role)}</td>
|
||||
<td>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
@@ -4,7 +4,8 @@ import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
||||
import { cleanupTaskStorage, deleteCompanyData } from '../../lib/deleteHelpers';
|
||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
||||
|
||||
export default function CompanyDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -14,7 +15,7 @@ export default function CompanyDetail() {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [unassigned, setUnassigned] = useState([]);
|
||||
const [availableUsers, setAvailableUsers] = useState([]);
|
||||
const [prices, setPrices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState('users');
|
||||
@@ -30,28 +31,38 @@ export default function CompanyDetail() {
|
||||
const [editUserVal, setEditUserVal] = useState('');
|
||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: p }, { data: pr }, { data: u }, { data: unassignedUsers }, { data: t }] = await Promise.all([
|
||||
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = 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),
|
||||
supabase.from('company_members').select('profile_id, created_at, profile:profiles(id, name, email, created_at, role, company_id)').eq('company_id', id),
|
||||
supabase.from('profiles').select('id, name, email, created_at, role, company_id').eq('role', 'client').order('name'),
|
||||
supabase.from('tasks').select('*, project:projects!inner(company_id)').eq('project.company_id', id),
|
||||
]);
|
||||
const assignedMap = new Map();
|
||||
(memberRows || []).forEach(row => {
|
||||
if (row.profile?.role === 'client') assignedMap.set(row.profile.id, { ...row.profile, membership_created_at: row.created_at });
|
||||
});
|
||||
(allUsers || []).filter(user => user.company_id === id).forEach(user => {
|
||||
if (!assignedMap.has(user.id)) assignedMap.set(user.id, user);
|
||||
});
|
||||
const assignedUsers = [...assignedMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const assignedIds = new Set(assignedUsers.map(user => user.id));
|
||||
setCompany(co);
|
||||
setProjects(p || []);
|
||||
setPrices(pr || []);
|
||||
setUsers(u || []);
|
||||
setUnassigned(unassignedUsers || []);
|
||||
setUsers(assignedUsers);
|
||||
setAvailableUsers((allUsers || []).filter(user => !assignedIds.has(user.id)));
|
||||
setTasks(t || []);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleCompanyNameSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!nameVal.trim()) return;
|
||||
@@ -73,38 +84,61 @@ export default function CompanyDetail() {
|
||||
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account and all access. 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;
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
|
||||
setUsers(prev => prev.filter(u => u.id !== user.id));
|
||||
setAvailableUsers(prev => prev.filter(u => u.id !== user.id));
|
||||
setDeletingUserId(null);
|
||||
};
|
||||
|
||||
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);
|
||||
const user = availableUsers.find(u => u.id === userId);
|
||||
const { error } = await supabase
|
||||
.from('company_members')
|
||||
.upsert({ company_id: id, profile_id: userId }, { onConflict: 'company_id,profile_id' });
|
||||
if (error) {
|
||||
alert('Failed to assign user. Please try again.');
|
||||
setAssigning(false);
|
||||
return;
|
||||
}
|
||||
if (user && !user.company_id) {
|
||||
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
|
||||
}
|
||||
if (user) {
|
||||
setUsers(prev => [...prev, { ...user, created_at: new Date().toISOString() }]);
|
||||
setUnassigned(prev => prev.filter(u => u.id !== userId));
|
||||
setUsers(prev => [...prev, { ...user, company_id: user.company_id || id, created_at: user.created_at || new Date().toISOString() }]
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
||||
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
|
||||
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
|
||||
}
|
||||
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);
|
||||
if (!window.confirm('Remove this user from the company? They will lose access to this company data.')) return;
|
||||
await supabase.from('company_members').delete().eq('company_id', id).eq('profile_id', userId);
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (user?.company_id === id) {
|
||||
const { data: nextMembership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.eq('profile_id', userId)
|
||||
.neq('company_id', id)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
await supabase.from('profiles').update({ company_id: nextMembership?.company_id || null }).eq('id', userId);
|
||||
}
|
||||
if (user) {
|
||||
setUsers(prev => prev.filter(u => u.id !== userId));
|
||||
setUnassigned(prev => [...prev, user]);
|
||||
setAvailableUsers(prev => [...prev, { ...user, company_id: user.company_id === id ? null : user.company_id }]
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCompany = async () => {
|
||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
|
||||
await cleanupTaskStorage(tasks.map(t => t.id));
|
||||
await supabase.from('companies').delete().eq('id', id);
|
||||
await deleteCompanyData(id);
|
||||
navigate('/companies');
|
||||
};
|
||||
|
||||
@@ -247,9 +281,9 @@ export default function CompanyDetail() {
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
{t === 'users' && unassigned.length > 0 && (
|
||||
{t === 'users' && availableUsers.length > 0 && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, background: 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}>
|
||||
{unassigned.length}
|
||||
{availableUsers.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -297,6 +331,7 @@ export default function CompanyDetail() {
|
||||
<>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -327,14 +362,14 @@ export default function CompanyDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
{availableUsers.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">Unassigned Users</div>
|
||||
<div className="card-title">Available 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.
|
||||
Add an existing client user to this company. External subcontractors are assigned to projects instead.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{unassigned.map(user => (
|
||||
{availableUsers.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 style={{ flex: 1 }}>
|
||||
{editingUserId === user.id ? (
|
||||
@@ -354,6 +389,7 @@ export default function CompanyDetail() {
|
||||
<>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -422,8 +458,8 @@ export default function CompanyDetail() {
|
||||
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
|
||||
const done = projectTasks.filter(t => t.status === 'client_approved').length;
|
||||
return (
|
||||
<div key={project.id} style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<Link to={`/projects/${project.id}`} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import JSZip from 'jszip';
|
||||
import { heicTo, isHeic } from 'heic-to/csp';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
|
||||
const INPUT_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
|
||||
const OUTPUT_FORMATS = [
|
||||
{ value: 'jpeg', label: 'JPG', mime: 'image/jpeg', extension: 'jpg' },
|
||||
{ value: 'png', label: 'PNG', mime: 'image/png', extension: 'png' },
|
||||
{ value: 'webp', label: 'WEBP', mime: 'image/webp', extension: 'webp' },
|
||||
];
|
||||
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
|
||||
const MAX_FILES = 100;
|
||||
|
||||
function getExtension(name = '') {
|
||||
return name.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
function isHeicFile(file) {
|
||||
return HEIC_EXTENSIONS.has(getExtension(file.name)) || file.type === 'image/heic' || file.type === 'image/heif';
|
||||
}
|
||||
|
||||
function stripExtension(name = '') {
|
||||
return name.replace(/\.[^.]+$/, '') || 'converted-image';
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
async function loadImageFromBlob(blob) {
|
||||
if ('createImageBitmap' in window) {
|
||||
try {
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Fallback to HTMLImageElement below.
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Could not decode image.'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function canvasToBlob(canvas, type, quality) {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Conversion failed.'));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
}, type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
async function convertRasterBlob(blob, format, quality) {
|
||||
const image = await loadImageFromBlob(blob);
|
||||
const width = image.width || image.naturalWidth;
|
||||
const height = image.height || image.naturalHeight;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) throw new Error('Canvas is not available in this browser.');
|
||||
|
||||
if (format.mime === 'image/jpeg') {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
ctx.drawImage(image, 0, 0, width, height);
|
||||
const outputBlob = await canvasToBlob(canvas, format.mime, quality);
|
||||
|
||||
if (typeof image.close === 'function') image.close();
|
||||
return outputBlob;
|
||||
}
|
||||
|
||||
async function convertHeicFile(file, format, quality) {
|
||||
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
|
||||
try {
|
||||
return await heicTo({ blob: file, type: format.mime, quality });
|
||||
} catch (primaryError) {
|
||||
try {
|
||||
return await convertRasterBlob(file, format, quality);
|
||||
} catch {
|
||||
throw new Error(primaryError?.message || 'HEIC format not supported.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bitmap = await heicTo({ blob: file, type: 'bitmap' });
|
||||
const width = bitmap.width || bitmap.naturalWidth;
|
||||
const height = bitmap.height || bitmap.naturalHeight;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas is not available in this browser.');
|
||||
|
||||
if (format.mime === 'image/jpeg') {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
if (typeof bitmap.close === 'function') bitmap.close();
|
||||
return canvasToBlob(canvas, format.mime, quality);
|
||||
}
|
||||
|
||||
async function convertFile(file, format, quality) {
|
||||
const looksHeic = isHeicFile(file);
|
||||
const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false;
|
||||
if (looksHeic || confirmedHeic) {
|
||||
return convertHeicFile(file, format, quality);
|
||||
}
|
||||
|
||||
return convertRasterBlob(file, format, quality);
|
||||
}
|
||||
|
||||
export default function Converters() {
|
||||
const inputRef = useRef(null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [outputFormat, setOutputFormat] = useState('jpeg');
|
||||
const [quality, setQuality] = useState(0.92);
|
||||
const [results, setResults] = useState([]);
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [downloading, setDownloading] = useState('');
|
||||
const [limitMessage, setLimitMessage] = useState('');
|
||||
|
||||
const selectedFormat = useMemo(
|
||||
() => OUTPUT_FORMATS.find((format) => format.value === outputFormat) || OUTPUT_FORMATS[0],
|
||||
[outputFormat]
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
results.forEach((result) => {
|
||||
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
|
||||
});
|
||||
}, [results]);
|
||||
|
||||
const addFiles = (incoming) => {
|
||||
const nextFiles = Array.from(incoming || []).filter(Boolean);
|
||||
if (!nextFiles.length) return;
|
||||
|
||||
setFiles((current) => {
|
||||
const existing = new Set(current.map(file => `${file.name}-${file.size}-${file.lastModified}`));
|
||||
const deduped = nextFiles.filter(file => !existing.has(`${file.name}-${file.size}-${file.lastModified}`));
|
||||
const availableSlots = Math.max(0, MAX_FILES - current.length);
|
||||
const accepted = deduped.slice(0, availableSlots);
|
||||
const skippedCount = deduped.length - accepted.length;
|
||||
|
||||
setLimitMessage(skippedCount > 0
|
||||
? `Only the first ${MAX_FILES} files can be queued at one time. ${skippedCount} file${skippedCount === 1 ? '' : 's'} were skipped.`
|
||||
: ''
|
||||
);
|
||||
|
||||
return [...current, ...accepted];
|
||||
});
|
||||
};
|
||||
|
||||
const removeFile = (targetIndex) => {
|
||||
setFiles((current) => current.filter((_, index) => index !== targetIndex));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setFiles([]);
|
||||
setResults((current) => {
|
||||
current.forEach((result) => {
|
||||
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
|
||||
});
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (!files.length) return;
|
||||
|
||||
setConverting(true);
|
||||
setResults((current) => {
|
||||
current.forEach((result) => {
|
||||
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
|
||||
});
|
||||
return files.map((file) => ({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}`,
|
||||
name: file.name,
|
||||
status: 'pending',
|
||||
previewUrl: '',
|
||||
blob: null,
|
||||
error: '',
|
||||
}));
|
||||
});
|
||||
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index];
|
||||
const id = `${file.name}-${file.size}-${file.lastModified}`;
|
||||
|
||||
setResults((current) => current.map((result) => (
|
||||
result.id === id ? { ...result, status: 'converting', error: '' } : result
|
||||
)));
|
||||
|
||||
try {
|
||||
const blob = await convertFile(file, selectedFormat, quality);
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
|
||||
setResults((current) => current.map((result) => {
|
||||
if (result.id !== id) return result;
|
||||
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
|
||||
return {
|
||||
...result,
|
||||
status: 'done',
|
||||
blob,
|
||||
previewUrl,
|
||||
error: '',
|
||||
};
|
||||
}));
|
||||
} catch (error) {
|
||||
setResults((current) => current.map((result) => (
|
||||
result.id === id
|
||||
? { ...result, status: 'error', blob: null, previewUrl: '', error: error.message || 'Conversion failed.' }
|
||||
: result
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
setConverting(false);
|
||||
};
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
const completed = results.filter(result => result.status === 'done' && result.blob);
|
||||
if (!completed.length || downloading) return;
|
||||
|
||||
setDownloading('all');
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
completed.forEach((result) => {
|
||||
zip.file(`${stripExtension(result.name)}.${selectedFormat.extension}`, result.blob);
|
||||
});
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadBlob(blob, `converted-images-${selectedFormat.extension}.zip`);
|
||||
} finally {
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
const completedCount = results.filter(result => result.status === 'done').length;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Image Converter</div>
|
||||
<div className="page-subtitle">Convert image files in bulk, including HEIC/HEIF to JPG, PNG, or WEBP.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Image Converter</div>
|
||||
<div style={{ display: 'grid', gap: 18 }}>
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
style={{
|
||||
border: '2px dashed var(--border)',
|
||||
borderRadius: 10,
|
||||
padding: '28px 18px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: 'var(--card-bg-2)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||
Drop images here or click to upload
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
Supports JPG, PNG, WEBP, GIF, BMP, TIFF, AVIF, HEIC, and HEIF. Max {MAX_FILES} files per batch.
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={INPUT_ACCEPT}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 14 }}>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Convert To</label>
|
||||
<select value={outputFormat} onChange={(e) => setOutputFormat(e.target.value)}>
|
||||
{OUTPUT_FORMATS.map((format) => (
|
||||
<option key={format.value} value={format.value}>{format.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Quality</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={quality}
|
||||
disabled={selectedFormat.mime === 'image/png'}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
|
||||
{selectedFormat.mime === 'image/png'
|
||||
? 'PNG uses lossless output.'
|
||||
: `${Math.round(quality * 100)}% output quality.`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={handleConvert} disabled={!files.length || converting}>
|
||||
{converting ? 'Converting...' : `Convert ${files.length ? `(${files.length})` : ''}`}
|
||||
</button>
|
||||
<LoadingButton className="btn btn-outline" loading={downloading === 'all'} disabled={!completedCount || Boolean(downloading)} loadingText="Preparing..." onClick={handleDownloadAll}>Download All</LoadingButton>
|
||||
<button className="btn btn-outline" onClick={clearAll} disabled={!files.length && !results.length}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{limitMessage && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{limitMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>Files</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{files.length} selected · {completedCount} converted
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!files.length ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No files added yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{files.map((file, index) => {
|
||||
const id = `${file.name}-${file.size}-${file.lastModified}`;
|
||||
const result = results.find(item => item.id === id);
|
||||
const outputName = `${stripExtension(file.name)}.${selectedFormat.extension}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
||||
gap: 14,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{file.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
{file.type || 'Unknown type'} · {(file.size / 1024 / 1024).toFixed(file.size >= 1024 * 1024 ? 2 : 3)} MB
|
||||
</div>
|
||||
{result?.status === 'error' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
|
||||
)}
|
||||
{result?.status === 'done' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 8 }}>
|
||||
Ready as {outputName}
|
||||
</div>
|
||||
)}
|
||||
{result?.status === 'converting' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 8 }}>Converting...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{result?.status === 'done' && result.blob && (
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => downloadBlob(result.blob, outputName)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-outline btn-sm" onClick={() => removeFile(index)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,34 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
|
||||
// Computed at module load time — stable for the lifetime of the invoice creation session
|
||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||
const INVOICE_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) {
|
||||
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id };
|
||||
}
|
||||
|
||||
const buildNewItemDescription = (task) => {
|
||||
const projectName = task.project?.name || 'No Project';
|
||||
const taskTitle = task.title || task.service_type || 'Untitled';
|
||||
return `${projectName} • ${taskTitle}`;
|
||||
};
|
||||
|
||||
const buildRevisionItemDescription = (revision) => {
|
||||
const projectName = revision.task?.project?.name || 'No Project';
|
||||
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
|
||||
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
|
||||
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
||||
};
|
||||
|
||||
export default function CreateInvoice() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
@@ -17,28 +38,56 @@ export default function CreateInvoice() {
|
||||
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
|
||||
const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]);
|
||||
const [priceList, setPriceList] = useState([]);
|
||||
const [billTo, setBillTo] = useState('');
|
||||
const [invoiceEmail, setInvoiceEmail] = useState('');
|
||||
const [companyRecipients, setCompanyRecipients] = useState([]);
|
||||
const [items, setItems] = useState([newItem()]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
const dragItem = useRef(null);
|
||||
const dragOver = useRef(null);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const net30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const today = INVOICE_TODAY;
|
||||
const net30 = INVOICE_NET30;
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('companies').select('id, name').order('name').then(({ data }) => setCompanies(data || []));
|
||||
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setCompanies(data || []));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId) { setUninvoicedTasks([]); setUninvoicedRevisions([]); setPriceList([]); setItems([newItem()]); return; }
|
||||
if (!selectedCompanyId) {
|
||||
setUninvoicedTasks([]);
|
||||
setUninvoicedRevisions([]);
|
||||
setPriceList([]);
|
||||
setItems([newItem()]);
|
||||
setBillTo('');
|
||||
setInvoiceEmail('');
|
||||
setCompanyRecipients([]);
|
||||
return;
|
||||
}
|
||||
setBillTo(companies.find(c => c.id === selectedCompanyId)?.name || '');
|
||||
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 }]) => {
|
||||
supabase.from('company_members').select('profile:profiles(id, name, email, role, company_id)').eq('company_id', selectedCompanyId),
|
||||
supabase.from('profiles').select('id, name, email, role, company_id').eq('company_id', selectedCompanyId).in('role', ['client', 'external']).order('name'),
|
||||
]).then(async ([{ data: projects }, { data: prices }, { data: memberRows }, { data: primaryUsers }]) => {
|
||||
setPriceList(prices || []);
|
||||
const recipientMap = new Map();
|
||||
(memberRows || []).forEach(row => {
|
||||
if (row.profile?.email) recipientMap.set(row.profile.id, row.profile);
|
||||
});
|
||||
(primaryUsers || []).forEach(user => {
|
||||
if (user.email) recipientMap.set(user.id, user);
|
||||
});
|
||||
const recipients = [...recipientMap.values()]
|
||||
.sort((a, b) => {
|
||||
if (a.role === 'client' && b.role !== 'client') return -1;
|
||||
if (a.role !== 'client' && b.role === 'client') return 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
setCompanyRecipients(recipients);
|
||||
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
|
||||
if (projects && projects.length > 0) {
|
||||
const projectIds = projects.map(p => p.id);
|
||||
const [{ data: tasks }, { data: revisions }] = await Promise.all([
|
||||
@@ -69,13 +118,11 @@ export default function CreateInvoice() {
|
||||
}
|
||||
setLoadingTasks(false);
|
||||
});
|
||||
}, [selectedCompanyId]);
|
||||
}, [selectedCompanyId, companies]);
|
||||
|
||||
const addTaskAsItem = (task) => {
|
||||
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
||||
const description = task.service_type && task.service_type !== task.title
|
||||
? `${task.service_type} — ${task.title}`
|
||||
: task.title;
|
||||
const description = buildNewItemDescription(task);
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
return [newItem(description, price?.price || '', 1, task.id)];
|
||||
@@ -90,12 +137,8 @@ export default function CreateInvoice() {
|
||||
};
|
||||
|
||||
const addRevisionAsItem = (revision) => {
|
||||
const versionLabel = 'v' + String((revision.version_number || 1) - 1).padStart(2, '0');
|
||||
const serviceLabel = getRevisionServiceType(revision);
|
||||
const taskTitle = revision.task?.title;
|
||||
const description = serviceLabel && taskTitle && serviceLabel !== taskTitle
|
||||
? `${serviceLabel} — ${taskTitle} — Revision ${versionLabel}`
|
||||
: `${serviceLabel || taskTitle || 'Revision'} — Revision ${versionLabel}`;
|
||||
const description = buildRevisionItemDescription(revision);
|
||||
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
@@ -137,52 +180,129 @@ export default function CreateInvoice() {
|
||||
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.');
|
||||
if (status === 'sent' && !invoiceEmail.trim()) return alert('Please enter an email recipient before finalizing and sending.');
|
||||
if (status === 'sent' && !selectedCompany) return alert('Please select a valid company before finalizing and sending.');
|
||||
setSaving(true);
|
||||
try {
|
||||
const year = new Date().getFullYear();
|
||||
const { count, error: countError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', `${year}-01-01`);
|
||||
if (countError) throw countError;
|
||||
|
||||
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 invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
|
||||
const initialStatus = status === 'sent' ? 'draft' : status;
|
||||
const { data: invoice, error } = await supabase.from('invoices').insert({
|
||||
company_id: selectedCompanyId,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: today,
|
||||
due_date: net30,
|
||||
status: initialStatus,
|
||||
bill_to: billTo || null,
|
||||
invoice_email: invoiceEmail.trim() || null,
|
||||
notes: notes || null,
|
||||
total,
|
||||
created_by: currentUser?.id,
|
||||
}).select().single();
|
||||
if (error || !invoice) throw error || new Error('Invoice record was not created.');
|
||||
|
||||
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();
|
||||
const validItems = items.filter(i => i.description);
|
||||
if (validItems.length > 0) {
|
||||
const { error: itemError } = await supabase.from('invoice_items').insert(
|
||||
validItems.map(item => ({
|
||||
invoice_id: invoice.id,
|
||||
task_id: item.task_id || null,
|
||||
submission_id: item.submission_id || null,
|
||||
description: item.description,
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
}))
|
||||
);
|
||||
if (itemError) throw itemError;
|
||||
}
|
||||
|
||||
if (error || !invoice) { setSaving(false); alert('Error saving invoice.'); return; }
|
||||
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
||||
if (taskIds.length > 0) {
|
||||
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
|
||||
if (taskError) throw taskError;
|
||||
}
|
||||
|
||||
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,
|
||||
submission_id: item.submission_id || null,
|
||||
description: item.description,
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
}))
|
||||
);
|
||||
const submissionIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
|
||||
if (submissionIds.length > 0) {
|
||||
const { error: submissionError } = await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
|
||||
if (submissionError) throw submissionError;
|
||||
}
|
||||
|
||||
let nextInvoice = invoice;
|
||||
if (status === 'sent') {
|
||||
try {
|
||||
const dueDate = new Date(net30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
|
||||
const invoiceForPdf = { ...invoice, status: 'sent' };
|
||||
const pdfItems = validItems.map(item => ({
|
||||
description: item.description,
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
}));
|
||||
const emailData = {
|
||||
invoiceNumber,
|
||||
billTo: billTo || selectedCompany.name,
|
||||
total: `$${total.toFixed(2)}`,
|
||||
dueDate,
|
||||
payUrl,
|
||||
notes: notes || '',
|
||||
};
|
||||
|
||||
let attachments = [];
|
||||
let attachmentWarning = '';
|
||||
try {
|
||||
const invoicePdf = await withTimeout(
|
||||
generateInvoicePDF(invoiceForPdf, selectedCompany, pdfItems, { save: false }),
|
||||
8000,
|
||||
'Invoice PDF generation'
|
||||
);
|
||||
const attachment = await withTimeout(
|
||||
blobToEmailAttachment(invoicePdf, `${invoiceNumber}.pdf`),
|
||||
5000,
|
||||
'Invoice attachment encoding'
|
||||
);
|
||||
attachments = [attachment];
|
||||
} catch (attachmentError) {
|
||||
console.error('Invoice PDF attachment skipped during creation:', attachmentError);
|
||||
attachmentWarning = ' The invoice email was sent without the PDF attachment.';
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
sendEmail('invoice_sent', invoiceEmail.trim(), emailData, attachments),
|
||||
12000,
|
||||
'Sending invoice email'
|
||||
);
|
||||
|
||||
const { data: sentInvoice, error: sentError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', invoice.id)
|
||||
.select()
|
||||
.single();
|
||||
if (sentError) throw sentError;
|
||||
nextInvoice = sentInvoice || { ...invoice, status: 'sent' };
|
||||
if (attachmentWarning) {
|
||||
alert(`Invoice sent successfully.${attachmentWarning}`);
|
||||
}
|
||||
} catch (sendError) {
|
||||
console.error('Failed to send invoice during creation:', sendError);
|
||||
alert(`Invoice was saved as a draft, but the email was not sent: ${sendError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
navigate(`/invoices/${invoice.id}`, { state: { invoice: nextInvoice } });
|
||||
} catch (saveError) {
|
||||
console.error('Failed to save invoice:', saveError);
|
||||
alert(`Failed to save invoice: ${saveError.message || 'Unknown error'}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
// Mark tasks as invoiced (only items without a submission_id are base task items)
|
||||
const taskIds = validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
||||
if (taskIds.length > 0) {
|
||||
await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
|
||||
}
|
||||
|
||||
// Mark client revisions as invoiced
|
||||
const submissionIds = validItems.filter(i => i.submission_id).map(i => i.submission_id);
|
||||
if (submissionIds.length > 0) {
|
||||
await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
|
||||
}
|
||||
|
||||
navigate(`/invoices/${invoice.id}`, { state: { invoice } });
|
||||
};
|
||||
|
||||
const selectedCompany = companies.find(c => c.id === selectedCompanyId);
|
||||
@@ -196,12 +316,6 @@ export default function CreateInvoice() {
|
||||
<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 btn-sm" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleSave('sent')} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Finalise & Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
@@ -216,8 +330,33 @@ export default function CreateInvoice() {
|
||||
</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>
|
||||
<div className="grid-2" style={{ marginTop: 12 }}>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Bill To</label>
|
||||
<input
|
||||
type="text"
|
||||
value={billTo}
|
||||
onChange={e => setBillTo(e.target.value)}
|
||||
placeholder={selectedCompany.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Email To</label>
|
||||
<input
|
||||
type="email"
|
||||
list="company-invoice-recipients"
|
||||
value={invoiceEmail}
|
||||
onChange={e => setInvoiceEmail(e.target.value)}
|
||||
placeholder={companyRecipients[0]?.email || 'client@example.com'}
|
||||
/>
|
||||
<datalist id="company-invoice-recipients">
|
||||
{companyRecipients.map(recipient => (
|
||||
<option key={recipient.id} value={recipient.email}>
|
||||
{recipient.name ? `${recipient.name} (${recipient.role})` : recipient.role}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -248,8 +387,10 @@ export default function CreateInvoice() {
|
||||
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.service_type && task.service_type !== task.title ? `${task.service_type} — ${task.title}` : 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 style={{ fontSize: 13, fontWeight: 600 }}>{buildNewItemDescription(task)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||||
@@ -283,19 +424,16 @@ export default function CreateInvoice() {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{uninvoicedRevisions.map(rev => {
|
||||
const versionLabel = 'v' + String((rev.version_number || 1) - 1).padStart(2, '0');
|
||||
const revServiceType = getRevisionServiceType(rev);
|
||||
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
|
||||
const alreadyAdded = items.some(i => i.submission_id === rev.id);
|
||||
return (
|
||||
<div key={rev.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 }}>
|
||||
{revServiceType && rev.task?.title && revServiceType !== rev.task?.title
|
||||
? `${revServiceType} — ${rev.task.title} — Revision ${versionLabel}`
|
||||
: `${revServiceType || rev.task?.title || 'Revision'} — Revision ${versionLabel}`}
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{buildRevisionItemDescription(rev)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{revServiceType || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{rev.task?.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||||
@@ -381,9 +519,9 @@ export default function CreateInvoice() {
|
||||
|
||||
<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>
|
||||
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
|
||||
Finalise & Send
|
||||
</LoadingButton>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
|
||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
|
||||
const blankSubcontractorPO = () => ({
|
||||
profile_id: '',
|
||||
project_id: '',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
due_date: '',
|
||||
description: '',
|
||||
terms: 'Net 15',
|
||||
notes: '',
|
||||
line_items: [{ task_id: '', description: '', amount: '' }],
|
||||
});
|
||||
|
||||
function getTaskServiceType(task) {
|
||||
const initial = (task?.submissions || []).find(submission => submission.type === 'initial') || (task?.submissions || [])[0];
|
||||
return initial?.service_type || task?.service_type || task?.title || 'Work Item';
|
||||
}
|
||||
|
||||
export default function CreateSubcontractorPO() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
const [externalProfiles, setExternalProfiles] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [projectTasks, setProjectTasks] = useState([]);
|
||||
const [purchaseOrders, setPurchaseOrders] = useState([]);
|
||||
const [usedTaskItems, setUsedTaskItems] = useState([]);
|
||||
const [form, setForm] = useState(blankSubcontractorPO());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [{ data: subcontractors }, { data: projectRows }, { data: taskRows }, { data: poRows }, { data: usedRows }] = await Promise.all([
|
||||
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
||||
supabase.from('projects').select('id, name, company:companies(name)').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, project_id, title, status, submissions(service_type, type)').order('submitted_at', { ascending: false }),
|
||||
supabase.from('subcontractor_payments').select('po_number').order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('subcontractor_po_items')
|
||||
.select('task_id, po:subcontractor_payments!inner(id, po_number, status, profile:profiles!subcontractor_payments_profile_id_fkey(name, email))')
|
||||
.not('task_id', 'is', null)
|
||||
.neq('po.status', 'cancelled'),
|
||||
]);
|
||||
setExternalProfiles(subcontractors || []);
|
||||
setProjects(projectRows || []);
|
||||
setProjectTasks(taskRows || []);
|
||||
setPurchaseOrders(poRows || []);
|
||||
setUsedTaskItems(usedRows || []);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const nextPONumber = () => {
|
||||
const year = new Date().getFullYear();
|
||||
const sameYear = purchaseOrders
|
||||
.map(po => po.po_number)
|
||||
.filter(number => typeof number === 'string' && number.startsWith(`PO-${year}-`));
|
||||
const max = sameYear.reduce((highest, number) => {
|
||||
const value = Number(number.split('-').pop());
|
||||
return Number.isFinite(value) ? Math.max(highest, value) : highest;
|
||||
}, 0);
|
||||
return `PO-${year}-${String(max + 1).padStart(4, '0')}`;
|
||||
};
|
||||
|
||||
const filteredTasks = form.project_id
|
||||
? projectTasks.filter(task => task.project_id === form.project_id)
|
||||
: [];
|
||||
const availableTasks = filteredTasks.filter(task => !usedTaskItems.some(item => item.task_id === task.id));
|
||||
const getUsedTaskItem = (taskId) => usedTaskItems.find(item => item.task_id === taskId);
|
||||
|
||||
const getValidLineItems = () => form.line_items
|
||||
.map((item, index) => {
|
||||
const task = projectTasks.find(row => row.id === item.task_id);
|
||||
return {
|
||||
task_id: item.task_id || null,
|
||||
description: item.description.trim() || (task ? getTaskServiceType(task) : ''),
|
||||
amount: Number(item.amount),
|
||||
sort_order: index,
|
||||
};
|
||||
})
|
||||
.filter(item => item.description && Number.isFinite(item.amount) && item.amount > 0);
|
||||
|
||||
const total = getValidLineItems().reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
const handleProjectChange = (projectId) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
project_id: projectId,
|
||||
line_items: [{ task_id: '', description: '', amount: '' }],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateLineItem = (index, updates) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
line_items: prev.line_items.map((item, itemIndex) => itemIndex === index ? { ...item, ...updates } : item),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLineTaskChange = (index, taskId) => {
|
||||
const task = projectTasks.find(row => row.id === taskId);
|
||||
updateLineItem(index, { task_id: taskId, description: task ? getTaskServiceType(task) : '' });
|
||||
};
|
||||
|
||||
const addTaskAsLineItem = (task) => {
|
||||
const nextItem = { task_id: task.id, description: getTaskServiceType(task), amount: '' };
|
||||
setForm(prev => {
|
||||
if (prev.line_items.length === 1 && !prev.line_items[0].description && !prev.line_items[0].amount) {
|
||||
return { ...prev, line_items: [nextItem] };
|
||||
}
|
||||
if (prev.line_items.some(item => item.task_id === task.id)) return prev;
|
||||
return { ...prev, line_items: [...prev.line_items, nextItem] };
|
||||
});
|
||||
};
|
||||
|
||||
const addLineItem = () => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
line_items: [...prev.line_items, { task_id: '', description: '', amount: '' }],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeLineItem = (index) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
line_items: prev.line_items.length > 1
|
||||
? prev.line_items.filter((_, itemIndex) => itemIndex !== index)
|
||||
: [{ task_id: '', description: '', amount: '' }],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (status = 'draft') => {
|
||||
const lineItems = getValidLineItems();
|
||||
if (!form.profile_id) return alert('Please select a subcontractor.');
|
||||
if (lineItems.length === 0) return alert('Please add at least one line item with an amount.');
|
||||
|
||||
setSaving(true);
|
||||
const summary = form.description.trim() || lineItems.map(item => item.description).join(', ');
|
||||
const { data, error } = await supabase
|
||||
.from('subcontractor_payments')
|
||||
.insert({
|
||||
po_number: nextPONumber(),
|
||||
profile_id: form.profile_id,
|
||||
project_id: form.project_id || null,
|
||||
date: form.date,
|
||||
due_date: form.due_date || null,
|
||||
description: summary,
|
||||
amount: total,
|
||||
terms: form.terms.trim() || 'Net 15',
|
||||
notes: form.notes.trim(),
|
||||
status,
|
||||
sent_at: status === 'sent' ? new Date().toISOString() : null,
|
||||
created_by: currentUser?.id || null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
setSaving(false);
|
||||
alert(`Failed to create PO: ${error?.message || 'unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: itemError } = await supabase
|
||||
.from('subcontractor_po_items')
|
||||
.insert(lineItems.map(item => ({ ...item, po_id: data.id })));
|
||||
|
||||
setSaving(false);
|
||||
if (itemError) {
|
||||
alert(`PO was created, but line items failed: ${itemError.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'sent') {
|
||||
const subcontractor = externalProfiles.find(profile => profile.id === form.profile_id);
|
||||
const project = projects.find(row => row.id === form.project_id);
|
||||
if (subcontractor?.email) {
|
||||
try {
|
||||
await sendEmail('subcontractor_po_sent', subcontractor.email, {
|
||||
poNumber: data.po_number || 'Purchase Order',
|
||||
subcontractorName: subcontractor.name || 'there',
|
||||
projectName: project?.name || 'Subcontractor Work',
|
||||
companyName: project?.company?.name || 'Fourge Branding',
|
||||
amount: `$${total.toFixed(2)}`,
|
||||
dueDate: form.due_date ? new Date(form.due_date).toLocaleDateString() : 'Not set',
|
||||
terms: form.terms.trim() || 'Net 15',
|
||||
scope: lineItems.map(item => `${item.description} - $${item.amount.toFixed(2)}`).join('\n'),
|
||||
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
|
||||
});
|
||||
} catch (emailError) {
|
||||
alert(`PO was finalized, but the email failed: ${emailError.message || 'unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
alert('PO was finalized, but this subcontractor has no email on file.');
|
||||
}
|
||||
}
|
||||
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">New Subcontractor PO</div>
|
||||
<div className="page-subtitle">Create a draft purchase order for subcontractor work.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Subcontractor</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>External Team Member *</label>
|
||||
<select value={form.profile_id} onChange={e => setForm(prev => ({ ...prev, profile_id: e.target.value }))}>
|
||||
<option value="">Choose a subcontractor...</option>
|
||||
{externalProfiles.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name || profile.email} {profile.name && profile.email ? `(${profile.email})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Project</label>
|
||||
<select value={form.project_id} onChange={e => handleProjectChange(e.target.value)}>
|
||||
<option value="">No project</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}{project.company?.name ? ` · ${project.company.name}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.project_id && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>Project Tasks</div>
|
||||
{availableTasks.length > 0 && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => availableTasks.forEach(task => addTaskAsLineItem(task))}>
|
||||
+ Add All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No tasks found for this project.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{filteredTasks.map(task => {
|
||||
const alreadyAdded = form.line_items.some(item => item.task_id === task.id);
|
||||
const usedItem = getUsedTaskItem(task.id);
|
||||
const usedBy = usedItem?.po?.profile?.name || usedItem?.po?.profile?.email || 'another PO';
|
||||
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: 700 }}>{task.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{usedItem
|
||||
? `Already on ${usedItem.po?.po_number || 'a PO'} for ${usedBy}`
|
||||
: getTaskServiceType(task)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${alreadyAdded || usedItem ? 'btn-outline' : 'btn-primary'}`}
|
||||
onClick={() => !alreadyAdded && !usedItem && addTaskAsLineItem(task)}
|
||||
disabled={alreadyAdded || Boolean(usedItem)}
|
||||
>
|
||||
{usedItem ? 'Already Used' : alreadyAdded ? 'Added' : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 40px', gap: 8, marginBottom: 8 }}>
|
||||
{['Description', 'Pay Amount', ''].map((header, index) => (
|
||||
<div key={header} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 0 ? 'right' : 'left' }}>{header}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{form.line_items.map((item, index) => (
|
||||
<div key={index} style={{ display: 'grid', gridTemplateColumns: '1fr 120px 40px', gap: 8, alignItems: 'start' }}>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{form.project_id && (
|
||||
<select
|
||||
value={item.task_id}
|
||||
onChange={e => handleLineTaskChange(index, e.target.value)}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<option value="">Custom line item...</option>
|
||||
{availableTasks.map(task => (
|
||||
<option key={task.id} value={task.id}>{task.title}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description..."
|
||||
value={item.description}
|
||||
onChange={e => updateLineItem(index, { description: e.target.value })}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={item.amount}
|
||||
onChange={e => updateLineItem(index, { amount: e.target.value })}
|
||||
style={{ margin: 0, textAlign: 'right' }}
|
||||
/>
|
||||
<button onClick={() => removeLineItem(index)} 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={addLineItem}>
|
||||
+ 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">PO Details</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label style={FIELD_LABEL_STYLE}>PO Date</label>
|
||||
<input type="date" value={form.date} onChange={e => setForm(prev => ({ ...prev, date: e.target.value }))} style={FIELD_INPUT_STYLE} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={FIELD_LABEL_STYLE}>Due Date</label>
|
||||
<input type="date" value={form.due_date} onChange={e => setForm(prev => ({ ...prev, due_date: e.target.value }))} style={FIELD_INPUT_STYLE} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={FIELD_LABEL_STYLE}>Terms</label>
|
||||
<input type="text" value={form.terms} onChange={e => setForm(prev => ({ ...prev, terms: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Notes</div>
|
||||
<textarea
|
||||
placeholder="Optional overview, special instructions, payment notes..."
|
||||
value={form.description}
|
||||
onChange={e => setForm(prev => ({ ...prev, description: e.target.value }))}
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Internal notes, payment method, invoice number..."
|
||||
value={form.notes}
|
||||
onChange={e => setForm(prev => ({ ...prev, notes: e.target.value }))}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="action-buttons" style={{ justifyContent: 'flex-start', marginBottom: 32 }}>
|
||||
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving || loading}>
|
||||
{saving ? 'Saving...' : 'Save Draft'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving || loading}>
|
||||
{saving ? 'Saving...' : 'Finalize & Send'}
|
||||
</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+297
-93
@@ -4,12 +4,85 @@ import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { formatDateOnly, parseDateOnly } from '../../lib/dates';
|
||||
|
||||
function formatDeadline(value) {
|
||||
return formatDateOnly(value, 'No deadline');
|
||||
}
|
||||
|
||||
function getDeadlineMeta(value) {
|
||||
const date = parseDateOnly(value);
|
||||
if (!date) return null;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.round((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return { label: `${Math.abs(diffDays)} day${Math.abs(diffDays) === 1 ? '' : 's'} overdue`, color: 'var(--danger)' };
|
||||
if (diffDays === 0) return { label: 'Due today', color: '#f97316' };
|
||||
if (diffDays === 1) return { label: 'Due tomorrow', color: '#f5a523' };
|
||||
return { label: `Due in ${diffDays} days`, color: 'var(--text-muted)' };
|
||||
}
|
||||
|
||||
function TaskListCard({ title, subtitle, tasks, projects, emptyMessage }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
||||
{subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>}
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{tasks.map(task => {
|
||||
const project = projects.find(p => p.id === task.project_id);
|
||||
const deadlineMeta = getDeadlineMeta(task.deadline);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/tasks/${task.id}`}
|
||||
className="interactive-row"
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '12px 14px',
|
||||
textDecoration: 'none',
|
||||
display: 'grid',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{task.title}</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{project?.name || 'No project'}{task.assigned_name ? ` · ${task.assigned_name}` : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: deadlineMeta?.color || 'var(--text-muted)', fontWeight: deadlineMeta ? 600 : 500 }}>
|
||||
{formatDeadline(task.deadline)}
|
||||
{deadlineMeta ? ` · ${deadlineMeta.label}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)' }}>
|
||||
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<button
|
||||
className="interactive-panel-toggle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center',
|
||||
@@ -34,10 +107,10 @@ function CompanyGroup({ company, tasks, projects }) {
|
||||
{tasks.map(task => {
|
||||
const project = projects.find(p => p.id === task.project_id);
|
||||
return (
|
||||
<Link key={task.id} to={`/tasks/${task.id}`} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<Link key={task.id} to={`/tasks/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
@@ -59,8 +132,9 @@ function CompanyGroup({ company, tasks, projects }) {
|
||||
function ProjectGroup({ project, tasks }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<button
|
||||
className="interactive-panel-toggle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center',
|
||||
@@ -81,9 +155,9 @@ function ProjectGroup({ project, tasks }) {
|
||||
{open && (
|
||||
<div>
|
||||
{tasks.map(task => (
|
||||
<a key={task.id} href={`/tasks/${task.id}`} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
|
||||
<a key={task.id} href={`/tasks/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'v' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</a>
|
||||
@@ -94,6 +168,102 @@ function ProjectGroup({ project, tasks }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OutputCharts({ title, subtitle, taskPeople, revisionPeople }) {
|
||||
const taskRows = [...(taskPeople || [])].sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
||||
const revisionRows = [...(revisionPeople || [])].sort((a, b) => b.revisions - a.revisions || a.name.localeCompare(b.name));
|
||||
const hasData = taskRows.length > 0 || revisionRows.length > 0;
|
||||
|
||||
const chartColors = ['#F5A523', '#60A5FA', '#4ADE80', '#F87171', '#C084FC', '#FBBF24', '#22C55E', '#38BDF8'];
|
||||
const totalTasks = taskRows.reduce((sum, person) => sum + person.total, 0);
|
||||
const totalRevisions = revisionRows.reduce((sum, person) => sum + person.revisions, 0);
|
||||
|
||||
const taskGradient = taskRows.length
|
||||
? `conic-gradient(${taskRows.map((person, index) => {
|
||||
const start = (taskRows.slice(0, index).reduce((sum, item) => sum + item.total, 0) / Math.max(totalTasks, 1)) * 100;
|
||||
const end = ((taskRows.slice(0, index + 1).reduce((sum, item) => sum + item.total, 0)) / Math.max(totalTasks, 1)) * 100;
|
||||
return `${chartColors[index % chartColors.length]} ${start}% ${end}%`;
|
||||
}).join(', ')})`
|
||||
: 'none';
|
||||
|
||||
const revisionGradient = totalRevisions > 0
|
||||
? `conic-gradient(${revisionRows.map((person, index) => {
|
||||
const start = (revisionRows.slice(0, index).reduce((sum, item) => sum + item.revisions, 0) / totalRevisions) * 100;
|
||||
const end = ((revisionRows.slice(0, index + 1).reduce((sum, item) => sum + item.revisions, 0)) / totalRevisions) * 100;
|
||||
return `${chartColors[index % chartColors.length]} ${start}% ${end}%`;
|
||||
}).join(', ')})`
|
||||
: 'none';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No completed assigned tasks yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18 }}>
|
||||
{[
|
||||
{ title: 'New Tasks', total: totalTasks, rows: taskRows, valueKey: 'total', gradient: taskGradient },
|
||||
{ title: 'Revisions', total: totalRevisions, rows: revisionRows, valueKey: 'revisions', gradient: revisionGradient },
|
||||
].map((chart) => (
|
||||
<div key={chart.title} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 16, background: 'var(--card-bg-2)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '140px minmax(0, 1fr)', gap: 18, alignItems: 'center' }}>
|
||||
<div className="dashboard-pie-wrap">
|
||||
<div className="dashboard-pie" style={{ background: chart.gradient }}>
|
||||
<div className="dashboard-pie-center">
|
||||
<strong>{chart.total}</strong>
|
||||
<span>{chart.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-legend">
|
||||
{chart.rows.map((person, index) => {
|
||||
const value = person[chart.valueKey];
|
||||
const percent = chart.total ? Math.round((value / chart.total) * 100) : 0;
|
||||
return (
|
||||
<div key={`${chart.title}-${person.name}`} className="dashboard-legend-item">
|
||||
<span className="dashboard-legend-dot" style={{ background: chartColors[index % chartColors.length] }} />
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.name}</span>
|
||||
<strong>{value} · {percent}%</strong>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTaskPeople(tasks) {
|
||||
const completedAssignedTasks = tasks.filter(task => task.status === 'client_approved' && task.assigned_name);
|
||||
return [...completedAssignedTasks.reduce((map, task) => {
|
||||
const person = task.assigned_name;
|
||||
const entry = map.get(person) || { name: person, total: 0 };
|
||||
entry.total += 1;
|
||||
map.set(person, entry);
|
||||
return map;
|
||||
}, new Map()).values()];
|
||||
}
|
||||
|
||||
function buildRevisionPeople(submissions, tasks, roleFilter) {
|
||||
return [...(submissions || []).reduce((map, submission) => {
|
||||
if ((submission.version_number || 0) <= 0) return map;
|
||||
if (!submission.delivery?.sent_by) return map;
|
||||
if (roleFilter && submission.delivery_sender_role !== roleFilter) return map;
|
||||
if (!roleFilter && submission.delivery_sender_role === 'external') return map;
|
||||
|
||||
const person = submission.delivery.sent_by;
|
||||
const entry = map.get(person) || { name: person, revisions: 0 };
|
||||
entry.revisions += 1;
|
||||
map.set(person, entry);
|
||||
return map;
|
||||
}, new Map()).values()];
|
||||
}
|
||||
|
||||
function ExternalDashboard({ currentUser, projects, tasks }) {
|
||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||
const completedTasks = tasks.filter(t => t.status === 'client_approved');
|
||||
@@ -149,63 +319,63 @@ function ExternalDashboard({ currentUser, projects, tasks }) {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const cacheKey = isExternal ? 'team_dashboard_external' : 'team_dashboard';
|
||||
const cached = readPageCache(cacheKey, 5 * 60_000);
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (isExternal) {
|
||||
const [{ data: p }, { data: t }] = await Promise.all([
|
||||
supabase.from('projects').select('*').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
|
||||
]);
|
||||
setProjects(p || []);
|
||||
setTasks(t || []);
|
||||
} else {
|
||||
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 || []);
|
||||
try {
|
||||
if (isExternal) {
|
||||
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
|
||||
supabase.from('projects').select('id, name').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id').order('submitted_at', { ascending: false }),
|
||||
]), 12000, 'Dashboard load');
|
||||
setProjects(p || []);
|
||||
setTasks(t || []);
|
||||
setSubmissions([]);
|
||||
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [] });
|
||||
} else {
|
||||
const [{ data: t }, { data: p }, { data: submissions }, { data: profiles }] = await withTimeout(Promise.all([
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').order('submitted_at', { ascending: false }),
|
||||
supabase.from('projects').select('id, name, status, company_id'),
|
||||
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, delivery:deliveries(sent_by, sent_at)').order('version_number', { ascending: false }),
|
||||
supabase.from('profiles').select('id, role, name'),
|
||||
]), 12000, 'Dashboard load');
|
||||
|
||||
const roleByProfileId = new Map((profiles || []).map(profile => [profile.id, profile.role]));
|
||||
const roleByProfileName = new Map((profiles || []).map(profile => [profile.name, profile.role]));
|
||||
|
||||
const tasksWithDeadlines = (t || []).map(task => ({
|
||||
...task,
|
||||
deadline: getDeadlineSourceSubmission(task, submissions)?.deadline || null,
|
||||
assignee_role: roleByProfileId.get(task.assigned_to) || null,
|
||||
}));
|
||||
|
||||
const submissionsWithRole = (submissions || []).map(submission => ({
|
||||
...submission,
|
||||
submitter_role: roleByProfileId.get(submission.submitted_by) || null,
|
||||
delivery_sender_role: roleByProfileName.get(submission.delivery?.sent_by) || null,
|
||||
}));
|
||||
|
||||
setTasks(tasksWithDeadlines);
|
||||
setProjects(p || []);
|
||||
writePageCache(cacheKey, { tasks: tasksWithDeadlines, projects: p || [], submissions: submissionsWithRole });
|
||||
setSubmissions(submissionsWithRole);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Dashboard load failed:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [isExternal]);
|
||||
}, [cacheKey, isExternal]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
@@ -213,11 +383,32 @@ export default function Dashboard() {
|
||||
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} />;
|
||||
}
|
||||
|
||||
const activeTasks = tasks.filter(t => ['in_progress', 'on_hold', 'client_review'].includes(t.status));
|
||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
||||
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');
|
||||
|
||||
const onHoldTasks = tasks.filter(t => t.status === 'on_hold');
|
||||
const reviewTasks = tasks.filter(t => t.status === 'client_review');
|
||||
const upcomingDeadlineTasks = [...tasks]
|
||||
.filter(task => task.deadline && task.status !== 'client_approved')
|
||||
.sort((a, b) => parseDateOnly(a.deadline) - parseDateOnly(b.deadline))
|
||||
.slice(0, 6);
|
||||
const assignedToMeTasks = [...tasks]
|
||||
.filter(task => task.assigned_to === currentUser?.id && task.status !== 'client_approved')
|
||||
.sort((a, b) => {
|
||||
const aDate = parseDateOnly(a.deadline);
|
||||
const bDate = parseDateOnly(b.deadline);
|
||||
if (aDate && bDate) return aDate - bDate;
|
||||
if (aDate) return -1;
|
||||
if (bDate) return 1;
|
||||
return 0;
|
||||
})
|
||||
.slice(0, 6);
|
||||
const teamOutputTasks = tasks.filter(task => task.assignee_role !== 'external');
|
||||
const subcontractorOutputTasks = tasks.filter(task => task.assignee_role === 'external');
|
||||
const teamTaskPeople = buildTaskPeople(teamOutputTasks);
|
||||
const subcontractorTaskPeople = buildTaskPeople(subcontractorOutputTasks);
|
||||
const teamRevisionPeople = buildRevisionPeople(submissions, tasks, null);
|
||||
const subcontractorRevisionPeople = buildRevisionPeople(submissions, tasks, 'external');
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
@@ -225,58 +416,71 @@ export default function Dashboard() {
|
||||
<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 btn-sm" onClick={() => setShowCompleted(s => !s)}>
|
||||
{showCompleted ? 'Hide Completed' : 'Show Completed'}
|
||||
</button>
|
||||
<Link to="/requests" className="btn btn-primary btn-sm">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-card stat-card-highlight">
|
||||
<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-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 className="stat-icon">▶</div>
|
||||
<div className="stat-value">{inProgressTasks.length}</div>
|
||||
<div className="stat-label">In Progress</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⏸</div>
|
||||
<div className="stat-value">{onHoldTasks.length}</div>
|
||||
<div className="stat-label">On Hold</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🕓</div>
|
||||
<div className="stat-value">{reviewTasks.length}</div>
|
||||
<div className="stat-label">Awaiting Client Review</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 style={{ marginTop: 24 }}>
|
||||
<OutputCharts
|
||||
title="Completed By Team Member"
|
||||
subtitle="Completed-task output by team assignee, with revisions counted by the person who submitted them."
|
||||
taskPeople={teamTaskPeople}
|
||||
revisionPeople={teamRevisionPeople}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<OutputCharts
|
||||
title="Completed By Subcontractor"
|
||||
subtitle="Completed-task output by subcontractor assignee, with revisions counted by the person who submitted them."
|
||||
taskPeople={subcontractorTaskPeople}
|
||||
revisionPeople={subcontractorRevisionPeople}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<TaskListCard
|
||||
title="Deadlines"
|
||||
subtitle="Upcoming due dates across active work."
|
||||
tasks={upcomingDeadlineTasks}
|
||||
projects={projects}
|
||||
emptyMessage="No active deadlines right now."
|
||||
/>
|
||||
<TaskListCard
|
||||
title="Assigned To You"
|
||||
subtitle="Your active jobs, sorted by deadline."
|
||||
tasks={assignedToMeTasks}
|
||||
projects={projects}
|
||||
emptyMessage="Nothing is assigned to you right now."
|
||||
/>
|
||||
</div>
|
||||
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function FileSharing() {
|
||||
const { currentUser } = useAuth();
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [usageBytes, setUsageBytes] = useState(0);
|
||||
const [configured, setConfigured] = useState(true);
|
||||
const [parentPath, setParentPath] = useState('/');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -41,6 +40,7 @@ export default function FileSharing() {
|
||||
const [error, setError] = useState('');
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [movingEntry, setMovingEntry] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
||||
@@ -77,7 +77,6 @@ export default function FileSharing() {
|
||||
const data = await apiFetch(`/api/seafile?${params.toString()}`);
|
||||
setConfigured(data.configured !== false);
|
||||
setEntries(data.entries || []);
|
||||
setUsageBytes(Number(data.usageBytes || 0));
|
||||
setCurrentPath(data.path || '/');
|
||||
setParentPath(data.parentPath || '/');
|
||||
if (data.configured === false) setError(data.error || 'Seafile is not configured yet.');
|
||||
@@ -224,9 +223,27 @@ export default function FileSharing() {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (!configured || loading || working) return;
|
||||
if (!e.dataTransfer.files?.length) return;
|
||||
uploadFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const moveEntry = async (entry, targetFolderPath) => {
|
||||
setWorking(`move:${entry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch('/api/seafile?action=move', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ srcPath: entry.path, dstDir: targetFolderPath, type: entry.type }),
|
||||
});
|
||||
setMovingEntry(null);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
@@ -234,9 +251,6 @@ export default function FileSharing() {
|
||||
<div className="page-title">File Sharing</div>
|
||||
<div className="page-subtitle">Shared Seafile workspace for team members and subcontractors.</div>
|
||||
</div>
|
||||
<div className="page-subtitle" style={{ marginTop: 6 }}>
|
||||
Used in this location: {formatBytes(usageBytes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
@@ -337,30 +351,62 @@ export default function FileSharing() {
|
||||
<h3>No files here yet</h3>
|
||||
<p>Upload files or create a folder to start this workspace.</p>
|
||||
</div>
|
||||
) : entries.map(entry => (
|
||||
<div className="file-row" key={`${entry.type}:${entry.path}`}>
|
||||
<span className="file-icon">{entry.type === 'dir' ? '▣' : '□'}</span>
|
||||
{entry.type === 'dir' ? (
|
||||
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
|
||||
{entry.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="file-name">{entry.name}</span>
|
||||
)}
|
||||
<span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span>
|
||||
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
||||
<span className="file-row-actions">
|
||||
{entry.type === 'file' && (
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="Opening..." onClick={() => downloadFile(entry)}>
|
||||
Download
|
||||
</LoadingButton>
|
||||
) : entries.map(entry => {
|
||||
const isMoving = movingEntry?.path === entry.path;
|
||||
const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
|
||||
return (
|
||||
<div className="file-row" key={`${entry.type}:${entry.path}`}>
|
||||
<span className="file-icon">{entry.type === 'dir' ? '▣' : '□'}</span>
|
||||
{entry.type === 'dir' ? (
|
||||
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
|
||||
{entry.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="file-name">{entry.name}</span>
|
||||
)}
|
||||
<LoadingButton className="btn btn-danger btn-sm" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="Deleting..." onClick={() => deleteEntry(entry)}>
|
||||
Delete
|
||||
</LoadingButton>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span>
|
||||
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
||||
<span className="file-row-actions">
|
||||
{isMoving ? (
|
||||
<>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
|
||||
{targetFolders.length === 0 ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
|
||||
) : targetFolders.map(folder => (
|
||||
<LoadingButton
|
||||
key={folder.path}
|
||||
className="btn btn-outline btn-sm"
|
||||
loading={working === `move:${entry.path}`}
|
||||
disabled={Boolean(working)}
|
||||
loadingText="Moving..."
|
||||
onClick={() => moveEntry(entry, folder.path)}
|
||||
>
|
||||
{folder.name}
|
||||
</LoadingButton>
|
||||
))}
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}>✕</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{entry.type === 'file' && (
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="Opening..." onClick={() => downloadFile(entry)}>
|
||||
Download
|
||||
</LoadingButton>
|
||||
)}
|
||||
{targetFolders.length > 0 && (
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => setMovingEntry(entry)}>
|
||||
Move
|
||||
</button>
|
||||
)}
|
||||
<LoadingButton className="btn btn-danger btn-sm" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="Deleting..." onClick={() => deleteEntry(entry)}>
|
||||
Delete
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
service_name: '',
|
||||
service_url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
function sortEntries(entries) {
|
||||
return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unknown';
|
||||
return new Date(value).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
return `https://${raw}`;
|
||||
}
|
||||
|
||||
async function invokeCrypto(action, payload) {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const accessToken = sessionData.session?.access_token;
|
||||
const response = await fetch('/api/fourge-passwords-crypto', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ action, ...payload }),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(data?.error || `Vault operation failed (${response.status})`);
|
||||
if (data?.error) throw new Error(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function FourgePasswords() {
|
||||
const { currentUser } = useAuth();
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [revealed, setRevealed] = useState({});
|
||||
|
||||
async function loadEntries() {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('fourge_passwords')
|
||||
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
|
||||
.order('service_name', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
setStatus(`Failed to load passwords: ${error.message}`);
|
||||
setEntries([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setEntries(sortEntries(data || []));
|
||||
setLoading(false);
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries();
|
||||
}, []);
|
||||
|
||||
function beginCreate() {
|
||||
setEditingId('new');
|
||||
setForm(emptyForm());
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function beginEdit(entry) {
|
||||
setEditingId(entry.id);
|
||||
setForm({
|
||||
service_name: entry.service_name || '',
|
||||
service_url: entry.service_url || '',
|
||||
username: entry.username || '',
|
||||
password: '',
|
||||
notes: entry.notes || '',
|
||||
});
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm());
|
||||
}
|
||||
|
||||
async function handleSave(event) {
|
||||
event.preventDefault();
|
||||
if (!form.service_name.trim() || !form.username.trim()) {
|
||||
setStatus('Service and username are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingId === 'new' && !form.password) {
|
||||
setStatus('Password is required for new entries.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
let encryptedPayload = null;
|
||||
if (form.password) {
|
||||
encryptedPayload = await invokeCrypto('encrypt', { plaintext: form.password });
|
||||
}
|
||||
|
||||
const payload = {
|
||||
service_name: form.service_name.trim(),
|
||||
service_url: form.service_url.trim(),
|
||||
username: form.username.trim(),
|
||||
notes: form.notes.trim(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (encryptedPayload) {
|
||||
payload.encrypted_password = encryptedPayload.ciphertext;
|
||||
payload.password_iv = encryptedPayload.iv;
|
||||
}
|
||||
|
||||
if (editingId === 'new') {
|
||||
payload.created_by = currentUser?.id || null;
|
||||
const { data, error } = await supabase
|
||||
.from('fourge_passwords')
|
||||
.insert(payload)
|
||||
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
setEntries(prev => sortEntries([...prev, data]));
|
||||
setStatus('Password saved.');
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('fourge_passwords')
|
||||
.update(payload)
|
||||
.eq('id', editingId)
|
||||
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
setEntries(prev => sortEntries(prev.map(entry => entry.id === editingId ? data : entry)));
|
||||
setStatus('Password updated.');
|
||||
}
|
||||
|
||||
cancelEdit();
|
||||
} catch (error) {
|
||||
setStatus(`Save failed: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(entry) {
|
||||
if (!window.confirm(`Delete the password entry for "${entry.service_name}"?`)) return;
|
||||
|
||||
setStatus('');
|
||||
const { error } = await supabase.from('fourge_passwords').delete().eq('id', entry.id);
|
||||
if (error) {
|
||||
setStatus(`Delete failed: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setEntries(prev => prev.filter(item => item.id !== entry.id));
|
||||
setRevealed(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[entry.id];
|
||||
return next;
|
||||
});
|
||||
setStatus('Password deleted.');
|
||||
}
|
||||
|
||||
async function handleReveal(entry) {
|
||||
if (revealed[entry.id]) {
|
||||
setRevealed(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[entry.id];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('');
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('fourge_passwords')
|
||||
.select('encrypted_password, password_iv')
|
||||
.eq('id', entry.id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const decrypted = await invokeCrypto('decrypt', {
|
||||
ciphertext: data.encrypted_password,
|
||||
iv: data.password_iv,
|
||||
});
|
||||
|
||||
setRevealed(prev => ({ ...prev, [entry.id]: decrypted.plaintext }));
|
||||
} catch (error) {
|
||||
setStatus(`Reveal failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy(entry) {
|
||||
try {
|
||||
let password = revealed[entry.id];
|
||||
if (!password) {
|
||||
const { data, error } = await supabase
|
||||
.from('fourge_passwords')
|
||||
.select('encrypted_password, password_iv')
|
||||
.eq('id', entry.id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const decrypted = await invokeCrypto('decrypt', {
|
||||
ciphertext: data.encrypted_password,
|
||||
iv: data.password_iv,
|
||||
});
|
||||
password = decrypted.plaintext;
|
||||
setRevealed(prev => ({ ...prev, [entry.id]: password }));
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(password);
|
||||
setStatus(`Copied password for ${entry.service_name}.`);
|
||||
} catch (error) {
|
||||
setStatus(`Copy failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Fourge Passwords</div>
|
||||
<div className="page-subtitle">Encrypted team-only vault entries for services, logins, and internal access.</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={beginCreate}>
|
||||
+ New Password
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(editingId || status) && (
|
||||
<div className="card" style={{ marginBottom: 20 }}>
|
||||
{editingId && (
|
||||
<form onSubmit={handleSave} style={{ display: 'grid', gap: 14 }}>
|
||||
<div className="card-title">{editingId === 'new' ? 'Add Password' : 'Edit Password'}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
|
||||
<input className="input" placeholder="Service name" value={form.service_name} onChange={(e) => setForm(prev => ({ ...prev, service_name: e.target.value }))} />
|
||||
<input className="input" placeholder="Service URL" value={form.service_url} onChange={(e) => setForm(prev => ({ ...prev, service_url: e.target.value }))} />
|
||||
<input className="input" placeholder="Username / email" value={form.username} onChange={(e) => setForm(prev => ({ ...prev, username: e.target.value }))} />
|
||||
<input className="input" placeholder={editingId === 'new' ? 'Password' : 'New password (leave blank to keep current)'} value={form.password} onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))} />
|
||||
</div>
|
||||
<textarea className="input" rows="4" placeholder="Notes" value={form.notes} onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))} />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary btn-sm" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" type="button" onClick={cancelEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{status && <div style={{ marginTop: editingId ? 12 : 0, fontSize: 13, color: 'var(--text-secondary)' }}>{status}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3 style={{ marginBottom: 8 }}>No passwords yet</h3>
|
||||
<p>Add encrypted password entries for internal services and team access.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{entries.map(entry => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="interactive-row"
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{entry.service_name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
Updated {formatDate(entry.updated_at || entry.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleReveal(entry)}>
|
||||
{revealed[entry.id] ? 'Hide' : 'Reveal'}
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleCopy(entry)}>
|
||||
Copy
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => beginEdit(entry)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleDelete(entry)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Username</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{entry.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Password</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4, fontFamily: 'monospace' }}>
|
||||
{revealed[entry.id] || '••••••••••••'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Service URL</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4, wordBreak: 'break-word' }}>
|
||||
{entry.service_url ? <a href={normalizeUrl(entry.service_url)} target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>{entry.service_url}</a> : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry.notes && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Notes</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, whiteSpace: 'pre-wrap' }}>{entry.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+833
-63
@@ -1,15 +1,73 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
|
||||
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
const poStatusColor = {
|
||||
draft: 'not_started',
|
||||
sent: 'in_progress',
|
||||
approved: 'client_approved',
|
||||
ready_to_pay: 'in_progress',
|
||||
paid: 'client_approved',
|
||||
cancelled: 'needs_revision',
|
||||
};
|
||||
const poStatusLabel = {
|
||||
draft: 'Draft',
|
||||
sent: 'Sent',
|
||||
approved: 'Approved',
|
||||
ready_to_pay: 'Ready to Pay',
|
||||
paid: 'Paid',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
const RECEIPT_BUCKET = 'expense-receipts';
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
|
||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
|
||||
const blankExpense = () => ({
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
description: '',
|
||||
category: 'Other',
|
||||
amount: '',
|
||||
notes: '',
|
||||
receipt: null,
|
||||
receipt_path: null,
|
||||
receipt_name: null,
|
||||
removeReceipt: false,
|
||||
});
|
||||
|
||||
function getFileExt(name = '') {
|
||||
const clean = String(name).split('.').pop()?.toLowerCase();
|
||||
return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin';
|
||||
}
|
||||
|
||||
export default function Invoices() {
|
||||
const navigate = useNavigate();
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const location = useLocation();
|
||||
const cached = readPageCache('team_invoices');
|
||||
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices');
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const [expenses, setExpenses] = useState([]);
|
||||
const [expensesLoading, setExpensesLoading] = useState(true);
|
||||
const [expensesError, setExpensesError] = useState('');
|
||||
const [newExpense, setNewExpense] = useState(blankExpense());
|
||||
const [addingExpense, setAddingExpense] = useState(false);
|
||||
const [editingExpenseId, setEditingExpenseId] = useState('');
|
||||
const [expenseFilter, setExpenseFilter] = useState('all');
|
||||
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
|
||||
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
||||
const [subcontractorError, setSubcontractorError] = useState('');
|
||||
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -18,98 +76,810 @@ export default function Invoices() {
|
||||
.select('*, company:companies(name)')
|
||||
.order('created_at', { ascending: false });
|
||||
setInvoices(data || []);
|
||||
writePageCache('team_invoices', { invoices: data || [] });
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const filtered = filter === 'all' ? invoices : invoices.filter(inv => inv.status === filter);
|
||||
useEffect(() => {
|
||||
async function loadExpenses() {
|
||||
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([
|
||||
supabase.from('expenses').select('*').order('date', { ascending: false }),
|
||||
supabase
|
||||
.from('subcontractor_payments')
|
||||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
||||
.order('date', { ascending: false }),
|
||||
]);
|
||||
if (error) {
|
||||
console.error('Failed to load expenses:', error);
|
||||
setExpensesError(error.message || 'Failed to load expenses.');
|
||||
setExpenses([]);
|
||||
} else {
|
||||
setExpenses(data || []);
|
||||
setExpensesError('');
|
||||
}
|
||||
if (purchaseOrdersError) {
|
||||
console.error('Failed to load subcontractor POs:', purchaseOrdersError);
|
||||
setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.');
|
||||
setSubcontractorPOs([]);
|
||||
} else {
|
||||
setSubcontractorPOs(purchaseOrders || []);
|
||||
setSubcontractorError('');
|
||||
}
|
||||
setExpensesLoading(false);
|
||||
setSubcontractorLoading(false);
|
||||
}
|
||||
loadExpenses();
|
||||
}, []);
|
||||
|
||||
const startEditExpense = (expense) => {
|
||||
setEditingExpenseId(expense.id);
|
||||
setNewExpense({
|
||||
date: expense.date,
|
||||
description: expense.description || '',
|
||||
category: expense.category || 'Other',
|
||||
amount: String(expense.amount ?? ''),
|
||||
notes: expense.notes || '',
|
||||
receipt: null,
|
||||
receipt_path: expense.receipt_path || null,
|
||||
receipt_name: expense.receipt_name || null,
|
||||
removeReceipt: false,
|
||||
});
|
||||
setExpensesError('');
|
||||
};
|
||||
|
||||
const cancelExpenseEdit = () => {
|
||||
setEditingExpenseId('');
|
||||
setNewExpense(blankExpense());
|
||||
setExpensesError('');
|
||||
};
|
||||
|
||||
const handleAddExpense = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newExpense.description.trim() || !newExpense.amount) return;
|
||||
setAddingExpense(true);
|
||||
setExpensesError('');
|
||||
try {
|
||||
const existingExpense = editingExpenseId ? expenses.find(expense => expense.id === editingExpenseId) : null;
|
||||
let receiptPath = existingExpense?.receipt_path || newExpense.receipt_path || null;
|
||||
let receiptName = existingExpense?.receipt_name || newExpense.receipt_name || null;
|
||||
|
||||
if (newExpense.removeReceipt && existingExpense?.receipt_path) {
|
||||
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
|
||||
receiptPath = null;
|
||||
receiptName = null;
|
||||
}
|
||||
|
||||
if (newExpense.receipt) {
|
||||
const ext = getFileExt(newExpense.receipt.name);
|
||||
receiptName = newExpense.receipt.name;
|
||||
receiptPath = `${newExpense.date}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
|
||||
const { error: uploadError } = await supabase.storage.from(RECEIPT_BUCKET).upload(receiptPath, newExpense.receipt, { upsert: false });
|
||||
if (uploadError) throw uploadError;
|
||||
if (existingExpense?.receipt_path) {
|
||||
await supabase.storage.from(RECEIPT_BUCKET).remove([existingExpense.receipt_path]);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
date: newExpense.date,
|
||||
description: newExpense.description.trim(),
|
||||
category: newExpense.category,
|
||||
amount: Number(newExpense.amount),
|
||||
notes: newExpense.notes.trim(),
|
||||
receipt_path: receiptPath,
|
||||
receipt_name: receiptName,
|
||||
};
|
||||
|
||||
const query = editingExpenseId
|
||||
? supabase.from('expenses').update(payload).eq('id', editingExpenseId)
|
||||
: supabase.from('expenses').insert(payload);
|
||||
const { data, error } = await query.select().single();
|
||||
|
||||
if (error) {
|
||||
if (newExpense.receipt && receiptPath) {
|
||||
await supabase.storage.from(RECEIPT_BUCKET).remove([receiptPath]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setExpenses(prev => editingExpenseId
|
||||
? prev.map(expense => expense.id === editingExpenseId ? data : expense)
|
||||
: [data, ...prev]
|
||||
);
|
||||
setNewExpense(blankExpense());
|
||||
setEditingExpenseId('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
|
||||
setExpensesError(error.message || `Failed to ${editingExpenseId ? 'update' : 'add'} expense.`);
|
||||
alert(`Failed to ${editingExpenseId ? 'update' : 'add'} expense: ${error.message || 'unknown error'}`);
|
||||
}
|
||||
setAddingExpense(false);
|
||||
};
|
||||
|
||||
const handleDeleteExpense = async (id) => {
|
||||
if (!window.confirm('Delete this expense?')) return;
|
||||
const expense = expenses.find(e => e.id === id);
|
||||
if (expense?.receipt_path) {
|
||||
await supabase.storage.from(RECEIPT_BUCKET).remove([expense.receipt_path]);
|
||||
}
|
||||
await supabase.from('expenses').delete().eq('id', id);
|
||||
setExpenses(prev => prev.filter(e => e.id !== id));
|
||||
};
|
||||
|
||||
const handleViewReceipt = async (expense) => {
|
||||
if (!expense.receipt_path) return;
|
||||
const { data, error } = await supabase.storage.from(RECEIPT_BUCKET).createSignedUrl(expense.receipt_path, 3600, {
|
||||
download: expense.receipt_name || undefined,
|
||||
});
|
||||
if (error || !data?.signedUrl) {
|
||||
const message = error?.message || 'Failed to open receipt.';
|
||||
setExpensesError(message);
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
window.open(data.signedUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => {
|
||||
const { data, error } = await supabase
|
||||
.from('subcontractor_payments')
|
||||
.update(updates)
|
||||
.eq('id', po.id)
|
||||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
||||
.single();
|
||||
if (error) {
|
||||
alert(`${errorLabel}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
if (data) {
|
||||
setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row));
|
||||
setSelectedSubcontractorPOId(current => current === po.id ? data.id : current);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleSendSubcontractorPO = async (po) => {
|
||||
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
||||
if (!sentPO?.profile?.email) return;
|
||||
try {
|
||||
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
|
||||
poNumber: sentPO.po_number || 'Purchase Order',
|
||||
subcontractorName: sentPO.profile?.name || 'there',
|
||||
projectName: sentPO.project?.name || 'Subcontractor Work',
|
||||
companyName: sentPO.project?.company?.name || 'Fourge Branding',
|
||||
amount: `$${Number(sentPO.amount).toFixed(2)}`,
|
||||
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
|
||||
terms: sentPO.terms || 'Net 15',
|
||||
scope: sentPO.items?.length
|
||||
? sentPO.items.map(item => `${item.description} — $${Number(item.amount).toFixed(2)}`).join('\n')
|
||||
: sentPO.description,
|
||||
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Failed to email subcontractor PO:', emailError);
|
||||
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReadyToPaySubcontractorPO = (po) => {
|
||||
updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay');
|
||||
};
|
||||
|
||||
const handleMarkSubcontractorPaid = async (po) => {
|
||||
const paidAt = new Date().toISOString().slice(0, 10);
|
||||
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
|
||||
};
|
||||
|
||||
const handleReopenSubcontractorPO = async (po) => {
|
||||
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
|
||||
};
|
||||
|
||||
const handleCancelSubcontractorPO = async (po) => {
|
||||
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
|
||||
updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
|
||||
};
|
||||
|
||||
const handleDeleteSubcontractorPO = async (id) => {
|
||||
if (!window.confirm('Delete this subcontractor PO?')) return;
|
||||
await supabase.from('subcontractor_payments').delete().eq('id', id);
|
||||
setSubcontractorPOs(prev => prev.filter(po => po.id !== id));
|
||||
setSelectedSubcontractorPOId(current => current === id ? '' : current);
|
||||
};
|
||||
|
||||
const handleDownloadSubcontractorPO = async (po) => {
|
||||
try {
|
||||
await generateSubcontractorPOPDF(po);
|
||||
} catch (error) {
|
||||
console.error('Failed to download subcontractor PO:', error);
|
||||
alert(`Failed to download PO: ${error.message || 'unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCPAExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const yearInvoices = invoices.filter(inv =>
|
||||
inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear
|
||||
);
|
||||
const yearExpenses = expenses.filter(exp =>
|
||||
new Date(exp.date).getFullYear() === exportYear
|
||||
);
|
||||
const yearSubcontractorExpenses = subcontractorPOs
|
||||
.filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear)
|
||||
.map(po => ({
|
||||
date: po.paid_at || po.date,
|
||||
category: 'Contractor',
|
||||
description: `Subcontractor: ${po.profile?.name || 'External'} — ${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`,
|
||||
amount: po.amount,
|
||||
notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '),
|
||||
}));
|
||||
const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses];
|
||||
if (!yearInvoices.length && !exportExpenses.length) {
|
||||
alert(`No data found for ${exportYear}.`);
|
||||
return;
|
||||
}
|
||||
const { data: allItems } = yearInvoices.length ? await supabase
|
||||
.from('invoice_items')
|
||||
.select('invoice_id, description')
|
||||
.in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] };
|
||||
const itemsByInvoice = (allItems || []).reduce((acc, item) => {
|
||||
(acc[item.invoice_id] = acc[item.invoice_id] || []).push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const paidYears = [...new Set(
|
||||
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
|
||||
)].sort((a, b) => b - a);
|
||||
|
||||
const companyNames = [...new Set(invoices.map(inv => inv.company?.name).filter(Boolean))].sort();
|
||||
const filtered = invoices.filter(inv => {
|
||||
if (filter !== 'all' && inv.status !== filter) return false;
|
||||
if (filterCompany && inv.company?.name !== filterCompany) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
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),
|
||||
netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0),
|
||||
};
|
||||
|
||||
const filteredExpenses = expenseFilter === 'all'
|
||||
? expenses
|
||||
: expenses.filter(e => e.category === expenseFilter);
|
||||
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
|
||||
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
|
||||
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
|
||||
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
|
||||
const totalPayableSubcontractors = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
|
||||
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
|
||||
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||||
const currentYearPaidSubcontractors = paidSubcontractorPOs
|
||||
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
|
||||
.reduce((s, po) => s + Number(po.amount), 0);
|
||||
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractors;
|
||||
const revenue = totals.paid;
|
||||
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
|
||||
|
||||
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 className="page-title">Invoices & Expenses</div>
|
||||
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} · {expenses.length} expense{expenses.length !== 1 ? 's' : ''} · {subcontractorPOs.length} subcontractor PO{subcontractorPOs.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{paidYears.length > 0 && (
|
||||
<select
|
||||
value={exportYear}
|
||||
onChange={e => setExportYear(Number(e.target.value))}
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{paidYears.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button className="btn btn-outline btn-sm" onClick={handleCPAExport} disabled={exporting}>
|
||||
{exporting ? 'Exporting…' : 'Export for CPA'}
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stats-grid" style={{ marginBottom: 18 }}>
|
||||
{[
|
||||
{ label: 'Revenue', value: revenue, detail: 'paid invoices' },
|
||||
{ label: 'Profit', value: profit, detail: 'net minus expenses' },
|
||||
{ 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>
|
||||
{ label: 'Net Received', value: totals.netReceived, detail: 'after Stripe fees' },
|
||||
].map(({ label, value, count, detail }) => (
|
||||
<div key={label} className={`stat-card${label === 'Revenue' ? ' stat-card-highlight' : ''}`}>
|
||||
<div className="stat-value" style={{ fontSize: 22, color: label === 'Profit' && value < 0 ? 'var(--danger)' : undefined }}>${value.toFixed(2)}</div>
|
||||
<div className="stat-label">{count !== undefined ? `${label} · ${count} invoice${count !== 1 ? 's' : ''}` : `${label} · ${detail}`}</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 className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${currentYearTotalExpenses.toFixed(2)}</div>
|
||||
<div className="stat-label">Expenses This Year · includes paid subcontractors</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${totalExpenses.toFixed(2)}</div>
|
||||
<div className="stat-label">Filtered Expenses · {filteredExpenses.length} item{filteredExpenses.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${totalPayableSubcontractors.toFixed(2)}</div>
|
||||
<div className="stat-label">Subcontractors Payable · {payableSubcontractorPOs.length} approved/ready</div>
|
||||
</div>
|
||||
</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 style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
{ id: 'subcontractor-po', label: 'Subcontractor PO' },
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
].map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(inv => (
|
||||
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{inv.company?.name}</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>
|
||||
</tr>
|
||||
{activeTab === 'invoices' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
||||
)}
|
||||
{activeTab === 'subcontractor-po' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/subcontractor-pos/new')}>+ New PO</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'invoices' && (
|
||||
<div>
|
||||
|
||||
{/* ── Invoices ── */}
|
||||
<div>
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter by Status</div>
|
||||
<div className="request-toolbar-actions">
|
||||
{['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>
|
||||
</div>
|
||||
{companyNames.length > 0 && (
|
||||
<div className="request-toolbar-grid">
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter by Company</div>
|
||||
<div className="request-filter-row">
|
||||
<button
|
||||
onClick={() => setFilterCompany('')}
|
||||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{companyNames.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => setFilterCompany(current => current === name ? '' : name)}
|
||||
className={`btn btn-sm ${filterCompany === name ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>Bill To</th>
|
||||
<th>Date</th>
|
||||
<th>Due</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(inv => (
|
||||
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
|
||||
<td style={{ fontWeight: 600 }}>{inv.bill_to || inv.company?.name}</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'expenses' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: 24, alignItems: 'start' }}>
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>Saved Expenses</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>${totalExpenses.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={() => setExpenseFilter('all')}
|
||||
className={`btn btn-sm ${expenseFilter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{CATEGORIES.filter(c => expenses.some(e => e.category === c)).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setExpenseFilter(f => f === c ? 'all' : c)}
|
||||
className={`btn btn-sm ${expenseFilter === c ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{expensesLoading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||
) : expensesError ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
|
||||
) : filteredExpenses.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ marginTop: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Category</th>
|
||||
<th>Notes</th>
|
||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||
<th style={{ width: 140, textAlign: 'right' }}>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredExpenses.map(exp => (
|
||||
<tr key={exp.id}>
|
||||
<td>{new Date(exp.date).toLocaleDateString()}</td>
|
||||
<td style={{ fontWeight: 600 }}>{exp.description}</td>
|
||||
<td>{exp.category}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>
|
||||
{exp.notes || exp.receipt_path ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span>{exp.notes || '—'}</span>
|
||||
{exp.receipt_path && (
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ width: 'fit-content' }}
|
||||
onClick={() => handleViewReceipt(exp)}
|
||||
>
|
||||
View Receipt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(exp.amount).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => startEditExpense(exp)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDeleteExpense(exp.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Expenses ── */}
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div className="card-title">{editingExpenseId ? 'Edit Expense' : 'Add Expense'}</div>
|
||||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={newExpense.date}
|
||||
onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))}
|
||||
style={FIELD_INPUT_STYLE}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Amount (USD)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="input"
|
||||
placeholder="0.00"
|
||||
value={newExpense.amount}
|
||||
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
|
||||
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 10 }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="What was this for?"
|
||||
value={newExpense.description}
|
||||
onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Category</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newExpense.category}
|
||||
onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}
|
||||
style={FIELD_INPUT_STYLE}
|
||||
>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Notes (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Any extra details"
|
||||
value={newExpense.notes}
|
||||
onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Receipt / Photo (optional)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept="image/*,.pdf"
|
||||
style={{ ...FIELD_INPUT_STYLE, padding: '9px 12px' }}
|
||||
onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))}
|
||||
/>
|
||||
{!newExpense.receipt && newExpense.receipt_path && (
|
||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt attached'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handleViewReceipt(newExpense)}
|
||||
>
|
||||
View Receipt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}
|
||||
>
|
||||
Remove Receipt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{newExpense.receipt && (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{newExpense.receipt.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-buttons" style={{ marginTop: 4 }}>
|
||||
<button className="btn btn-primary btn-sm" type="submit" disabled={addingExpense}>
|
||||
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : '+ Add Expense')}
|
||||
</button>
|
||||
{editingExpenseId && (
|
||||
<button className="btn btn-outline btn-sm" type="button" onClick={cancelExpenseEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{expensesError && (
|
||||
<div style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
{expensesError}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'subcontractor-po' && (
|
||||
<div>
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>POs & Payments</div>
|
||||
<div style={{ display: 'flex', gap: 12, fontSize: 13, fontWeight: 700 }}>
|
||||
<span style={{ color: 'var(--accent)' }}>${totalPayableSubcontractors.toFixed(2)} payable</span>
|
||||
<span>${totalPaidSubcontractors.toFixed(2)} paid</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subcontractorLoading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||
) : subcontractorError ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subcontractorError}</p>
|
||||
) : subcontractorPOs.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No subcontractor POs yet.</p>
|
||||
) : (
|
||||
<>
|
||||
{selectedSubcontractorPO && (
|
||||
<div style={{ marginBottom: 16, padding: 16, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800 }}>{selectedSubcontractorPO.po_number || 'Purchase Order'}</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
{selectedSubcontractorPO.profile?.name || 'External'} · {selectedSubcontractorPO.project?.name || 'No project'}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setSelectedSubcontractorPOId('')}>Close</button>
|
||||
</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 12 }}>
|
||||
<div className="detail-item"><label>Status</label><p><span className={`badge badge-${poStatusColor[selectedSubcontractorPO.status] || 'not_started'}`}>{poStatusLabel[selectedSubcontractorPO.status] || selectedSubcontractorPO.status}</span></p></div>
|
||||
<div className="detail-item"><label>Amount</label><p>${Number(selectedSubcontractorPO.amount).toFixed(2)}</p></div>
|
||||
<div className="detail-item"><label>Date</label><p>{new Date(selectedSubcontractorPO.date).toLocaleDateString()}</p></div>
|
||||
<div className="detail-item"><label>Due</label><p>{selectedSubcontractorPO.due_date ? new Date(selectedSubcontractorPO.due_date).toLocaleDateString() : '—'}</p></div>
|
||||
</div>
|
||||
{selectedSubcontractorPO.items?.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: 6, marginBottom: 12 }}>
|
||||
{selectedSubcontractorPO.items
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
|
||||
.map(item => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
|
||||
<span>{item.description || item.task?.title}</span>
|
||||
<strong>${Number(item.amount).toFixed(2)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedSubcontractorPO.notes && (
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 13, whiteSpace: 'pre-wrap' }}>{selectedSubcontractorPO.notes}</p>
|
||||
)}
|
||||
<div className="action-buttons" style={{ marginTop: 12 }}>
|
||||
{selectedSubcontractorPO.status === 'draft' && <button className="btn btn-primary btn-sm" onClick={() => handleSendSubcontractorPO(selectedSubcontractorPO)}>Finalize & Send</button>}
|
||||
{['sent', 'approved'].includes(selectedSubcontractorPO.status) && <button className="btn btn-outline btn-sm" onClick={() => handleReadyToPaySubcontractorPO(selectedSubcontractorPO)}>Mark Ready</button>}
|
||||
{selectedSubcontractorPO.status === 'ready_to_pay' && <button className="btn btn-success btn-sm" onClick={() => handleMarkSubcontractorPaid(selectedSubcontractorPO)}>Mark Paid</button>}
|
||||
{selectedSubcontractorPO.status === 'paid' && <button className="btn btn-outline btn-sm" onClick={() => handleReopenSubcontractorPO(selectedSubcontractorPO)}>Reopen</button>}
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleDownloadSubcontractorPO(selectedSubcontractorPO)}>Download</button>
|
||||
{!['paid', 'cancelled'].includes(selectedSubcontractorPO.status) && <button className="btn btn-outline btn-sm" onClick={() => handleCancelSubcontractorPO(selectedSubcontractorPO)}>Cancel</button>}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteSubcontractorPO(selectedSubcontractorPO.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="table-wrapper" style={{ marginTop: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PO #</th>
|
||||
<th>Subcontractor</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subcontractorPOs.map(po => (
|
||||
<tr key={po.id} onClick={() => navigate(`/subcontractor-pos/${po.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 700 }}>{po.po_number || 'PO'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{po.profile?.name || 'External'}</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{po.profile?.email || '—'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{new Date(po.paid_at || po.date).toLocaleDateString()}</div>
|
||||
{po.due_date && <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>Due {new Date(po.due_date).toLocaleDateString()}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>
|
||||
{poStatusLabel[po.status] || po.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(po.amount).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
title: '',
|
||||
attendees: '',
|
||||
meeting_at: new Date().toISOString().slice(0, 16),
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
function formatMeetingDate(value) {
|
||||
if (!value) return 'Unknown date';
|
||||
return new Date(value).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function MeetingNotes() {
|
||||
const { currentUser } = useAuth();
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadInitialNotes() {
|
||||
const { data, error } = await supabase
|
||||
.from('meeting_notes')
|
||||
.select('*')
|
||||
.order('meeting_at', { ascending: false })
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (error) {
|
||||
setStatus(`Failed to load meeting notes: ${error.message}`);
|
||||
setNotes([]);
|
||||
} else {
|
||||
setNotes(data || []);
|
||||
setStatus('');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
loadInitialNotes();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.title.trim() || !form.notes.trim()) {
|
||||
setStatus('Meeting title and notes are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
attendees: form.attendees.trim(),
|
||||
meeting_at: form.meeting_at ? new Date(form.meeting_at).toISOString() : new Date().toISOString(),
|
||||
notes: form.notes.trim(),
|
||||
created_by: currentUser?.id || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('meeting_notes')
|
||||
.insert(payload)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
setStatus(`Failed to save note: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setNotes(prev => [data, ...prev]);
|
||||
setForm(emptyForm());
|
||||
setStatus('Meeting note added.');
|
||||
};
|
||||
|
||||
const handleDelete = async (entry) => {
|
||||
if (!window.confirm(`Delete meeting note "${entry.title}"?`)) return;
|
||||
setDeletingId(entry.id);
|
||||
const { error } = await supabase.from('meeting_notes').delete().eq('id', entry.id);
|
||||
setDeletingId('');
|
||||
if (error) {
|
||||
setStatus(`Failed to delete note: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
setNotes(prev => prev.filter(note => note.id !== entry.id));
|
||||
setStatus('Meeting note deleted.');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Meeting Notes</div>
|
||||
<div className="page-subtitle">Internal team timeline for meeting recaps, decisions, and follow-ups.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 18 }}>
|
||||
<section className="card">
|
||||
<div className="card-title">Add Note</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Meeting Title</label>
|
||||
<input type="text" value={form.title} onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))} placeholder="Weekly team sync" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Meeting Date</label>
|
||||
<input type="datetime-local" value={form.meeting_at} onChange={(e) => setForm(prev => ({ ...prev, meeting_at: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Attendees</label>
|
||||
<input type="text" value={form.attendees} onChange={(e) => setForm(prev => ({ ...prev, attendees: e.target.value }))} placeholder="Team, client, subcontractor" />
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||
<label>Notes</label>
|
||||
<textarea value={form.notes} onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))} placeholder="Key decisions, next steps, blockers, and follow-up items..." style={{ minHeight: 180 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{status || 'Newest notes appear first in the timeline.'}</div>
|
||||
<LoadingButton className="btn btn-primary" loading={saving} loadingText="Saving...">Save Note</LoadingButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="card-title">Timeline</div>
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--text-muted)' }}>Loading meeting notes...</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '36px 12px' }}>
|
||||
<h3>No meeting notes yet</h3>
|
||||
<p>Add the first entry to start the internal timeline.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="meeting-timeline">
|
||||
{notes.map((entry) => (
|
||||
<article key={entry.id} className="meeting-note-card">
|
||||
<div className="meeting-note-marker" aria-hidden="true" />
|
||||
<div className="meeting-note-content">
|
||||
<div className="meeting-note-header">
|
||||
<div>
|
||||
<div className="meeting-note-title">{entry.title}</div>
|
||||
<div className="meeting-note-meta">
|
||||
<span>{formatMeetingDate(entry.meeting_at)}</span>
|
||||
{entry.attendees ? <span>Attendees: {entry.attendees}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<LoadingButton
|
||||
className="btn btn-danger btn-sm"
|
||||
loading={deletingId === entry.id}
|
||||
disabled={Boolean(deletingId)}
|
||||
loadingText="Deleting..."
|
||||
onClick={() => handleDelete(entry)}
|
||||
>
|
||||
Delete
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<div className="meeting-note-body">{entry.notes}</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+655
-143
@@ -1,192 +1,704 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
||||
import { archiveCompletedJobsToLocalZip, restoreCompletedJobsArchive } from '../../lib/archiveHelpers';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
||||
|
||||
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||
|
||||
export default function Requests() {
|
||||
const navigate = useNavigate();
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { currentUser } = useAuth();
|
||||
const cached = readPageCache('team_requests');
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||||
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
||||
const [invoiceItems, setInvoiceItems] = useState(() => cached?.invoiceItems || []);
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterUser, setFilterUser] = useState('');
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addForm, setAddForm] = useState(EMPTY_FORM());
|
||||
const [formProjects, setFormProjects] = useState([]);
|
||||
const [customProjectNames, setCustomProjectNames] = useState([]);
|
||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||
const [archivingSelected, setArchivingSelected] = useState(false);
|
||||
const [archiveStatus, setArchiveStatus] = useState('');
|
||||
const [restoringArchive, setRestoringArchive] = useState(false);
|
||||
const [restoreStatus, setRestoreStatus] = useState('');
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState([]);
|
||||
const restoreInputRef = useRef(null);
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await Promise.all([
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(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'),
|
||||
]);
|
||||
supabase.from('invoices').select('id, status'),
|
||||
supabase.from('invoice_items').select('task_id, invoice_id'),
|
||||
]), 12000, 'Requests load');
|
||||
setSubmissions(subs || []);
|
||||
setTasks(t || []);
|
||||
setProjects(p || []);
|
||||
setCompanies(co || []);
|
||||
setInvoices(inv || []);
|
||||
setInvoiceItems(itemRows || []);
|
||||
writePageCache('team_requests', {
|
||||
submissions: subs || [],
|
||||
tasks: t || [],
|
||||
projects: p || [],
|
||||
companies: co || [],
|
||||
invoices: inv || [],
|
||||
invoiceItems: itemRows || [],
|
||||
});
|
||||
const paidInvoiceIds = new Set((inv || []).filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
||||
const closedTaskIds = new Set(
|
||||
(itemRows || [])
|
||||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
||||
.map(item => item.task_id)
|
||||
);
|
||||
setSelectedTaskIds(prev => prev.filter(id => (t || []).some(task => task.id === id && task.status === 'client_approved' && !(task.invoiced && closedTaskIds.has(task.id)))));
|
||||
} catch (error) {
|
||||
console.error('Requests load failed:', error);
|
||||
setSubmissions([]);
|
||||
setTasks([]);
|
||||
setProjects([]);
|
||||
setCompanies([]);
|
||||
setInvoices([]);
|
||||
setInvoiceItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFormProjects([]);
|
||||
setCustomProjectNames([]);
|
||||
setCompanyUsers([]);
|
||||
setAddForm(f => ({ ...f, project: '', requestedBy: currentUser?.id || '' }));
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
if (!addForm.companyId) return;
|
||||
withTimeout(Promise.all([
|
||||
supabase.from('projects').select('id, name').eq('company_id', addForm.companyId).order('name'),
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', addForm.companyId).eq('role', 'client').order('name'),
|
||||
]), 10000, 'Request form load').then(([projectsResult, usersResult]) => {
|
||||
setFormProjects(projectsResult.data || []);
|
||||
setCompanyUsers(usersResult.data || []);
|
||||
}).catch((error) => {
|
||||
console.error('Request form load failed:', error);
|
||||
setFormProjects([]);
|
||||
setCompanyUsers([]);
|
||||
});
|
||||
}, [addForm.companyId]);
|
||||
|
||||
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const handleAddProjectName = () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!customProjectNames.includes(name) && !formProjects.some(p => p.name === name)) {
|
||||
setCustomProjectNames(prev => [...prev, name]);
|
||||
}
|
||||
setAddForm(f => ({ ...f, project: name }));
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
};
|
||||
|
||||
const handleAddRequest = async (e) => {
|
||||
e.preventDefault();
|
||||
if (addSaving) return;
|
||||
setAddSaving(true);
|
||||
setAddError('');
|
||||
try {
|
||||
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
|
||||
if (!requester) throw new Error('Please select who requested this task.');
|
||||
const projectName = addForm.project.trim();
|
||||
if (!projectName) throw new Error('Please select or create a project.');
|
||||
const resolvedProject = await findOrCreateProject(addForm.companyId, projectName, formProjects);
|
||||
if (!formProjects.some(project => project.id === resolvedProject.id)) {
|
||||
setFormProjects(prev => [...prev, { id: resolvedProject.id, name: resolvedProject.name }]);
|
||||
}
|
||||
if (!projects.some(project => project.id === resolvedProject.id)) {
|
||||
setProjects(prev => [...prev, resolvedProject]);
|
||||
}
|
||||
|
||||
const { task } = await createTaskForRequest({
|
||||
projectId: resolvedProject.id,
|
||||
title: addForm.title.trim() || addForm.serviceType,
|
||||
requestKey: addRequestKey,
|
||||
});
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
|
||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id,
|
||||
requestKey: addRequestKey,
|
||||
isHot: addForm.isHot,
|
||||
serviceType: addForm.serviceType,
|
||||
deadline: addForm.deadline,
|
||||
description: addForm.description,
|
||||
submittedBy: requester.id,
|
||||
submittedByName: requester.name.replace(' (You)', ''),
|
||||
});
|
||||
if (!sub) throw new Error('Failed to create submission.');
|
||||
|
||||
// Refresh list
|
||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('*'),
|
||||
]);
|
||||
setSubmissions(newSubs || []);
|
||||
setTasks(newTasks || []);
|
||||
|
||||
setShowAddForm(false);
|
||||
setAddForm(EMPTY_FORM());
|
||||
setAddRequestKey(crypto.randomUUID());
|
||||
setCustomProjectNames([]);
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
} catch (err) {
|
||||
setAddError(err.message);
|
||||
} finally {
|
||||
setAddSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveSelected = async () => {
|
||||
if (!selectedTaskIds.length) {
|
||||
alert('Select at least one completed job to archive.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Download an archive for ${selectedTaskIds.length} selected completed job${selectedTaskIds.length === 1 ? '' : 's'}?`)) return;
|
||||
|
||||
setArchivingSelected(true);
|
||||
try {
|
||||
const archive = await archiveCompletedJobsToLocalZip(selectedTaskIds, { onProgress: setArchiveStatus });
|
||||
const shouldDelete = window.confirm(
|
||||
`Archive downloaded as "${archive.filename}".\n\nRemove those completed jobs from Supabase now?`
|
||||
);
|
||||
|
||||
if (shouldDelete) {
|
||||
await cleanupTaskStorage(selectedTaskIds);
|
||||
await supabase.from('tasks').delete().in('id', selectedTaskIds);
|
||||
setSelectedTaskIds([]);
|
||||
await load();
|
||||
setArchiveStatus('');
|
||||
alert(`Archived and removed ${archive.taskCount} completed job${archive.taskCount === 1 ? '' : 's'}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Archive failed: ${error.message}`);
|
||||
} finally {
|
||||
setArchivingSelected(false);
|
||||
setArchiveStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreArchive = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setRestoringArchive(true);
|
||||
try {
|
||||
const result = await restoreCompletedJobsArchive(file, { onProgress: setRestoreStatus });
|
||||
await load();
|
||||
const notes = [];
|
||||
if (result.clearedAssignments) notes.push(`${result.clearedAssignments} assignments cleared`);
|
||||
if (result.clearedSubmissionUsers) notes.push(`${result.clearedSubmissionUsers} submission user links cleared`);
|
||||
alert(
|
||||
notes.length
|
||||
? `Restored ${result.taskCount} completed job${result.taskCount === 1 ? '' : 's'}.\n\nNote: ${notes.join('; ')}.`
|
||||
: `Restored ${result.taskCount} completed job${result.taskCount === 1 ? '' : 's'}.`
|
||||
);
|
||||
} catch (error) {
|
||||
alert(`Restore failed: ${error.message}`);
|
||||
} finally {
|
||||
setRestoringArchive(false);
|
||||
setRestoreStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||
const paidInvoiceIds = new Set(invoices.filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
||||
const paidTaskIds = new Set(
|
||||
invoiceItems
|
||||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
||||
.map(item => item.task_id)
|
||||
);
|
||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && paidTaskIds.has(task.id);
|
||||
const latestTaskGroups = tasks.map(task => {
|
||||
const taskSubs = submissions.filter(sub => sub.task_id === task.id);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
if (!deadlineSource) return null;
|
||||
|
||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||
const latestGroup = taskSubs.filter(sub => sub.version_number === currentVersion);
|
||||
return { task, primary: deadlineSource, group: latestGroup };
|
||||
}).filter(Boolean);
|
||||
|
||||
const completedTaskIds = latestTaskGroups
|
||||
.filter(({ task }) => task.status === 'client_approved' && !isFullyClosedTask(task))
|
||||
.map(({ task }) => task.id);
|
||||
|
||||
const allCompletedSelected = completedTaskIds.length > 0 && selectedTaskIds.length === completedTaskIds.length;
|
||||
const toggleSelectedTask = (taskId) => {
|
||||
setSelectedTaskIds(prev => prev.includes(taskId)
|
||||
? prev.filter(id => id !== taskId)
|
||||
: [...prev, taskId]
|
||||
);
|
||||
};
|
||||
const toggleSelectAllCompleted = () => {
|
||||
setSelectedTaskIds(allCompletedSelected ? [] : completedTaskIds);
|
||||
};
|
||||
|
||||
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
if (filterCompany && project?.company_id !== filterCompany) return false;
|
||||
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
const aLatest = Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()));
|
||||
const bLatest = Math.max(...b.group.map(s => new Date(s.submitted_at).getTime()));
|
||||
return bLatest - aLatest;
|
||||
});
|
||||
|
||||
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
||||
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
||||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
||||
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
||||
const renderRow = ({ task, primary }, showCheckbox = false) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
const isCompleted = task?.status === 'client_approved';
|
||||
const isFullyClosed = isFullyClosedTask(task);
|
||||
const revisionLabel = `R${String(primary.version_number ?? 0).padStart(2, '0')}`;
|
||||
const deadline = formatDateOnly(primary.deadline, 'Not specified');
|
||||
|
||||
return (
|
||||
<tr key={task.id} onClick={() => task && navigate(`/tasks/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
|
||||
{showCheckbox && (
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTaskIds.includes(task.id)}
|
||||
disabled={!isCompleted}
|
||||
onChange={() => toggleSelectedTask(task.id)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task?.title || primary.service_type}</span>
|
||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{revisionLabel}</td>
|
||||
<td>{primary.service_type || 'Request'}</td>
|
||||
<td>
|
||||
{company
|
||||
? <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link>
|
||||
: 'No client'}
|
||||
</td>
|
||||
<td>{deadline}</td>
|
||||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
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 className="page-title">Requests</div>
|
||||
<div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||||
{showAddForm ? 'Cancel' : '+ Add Request'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={restoreInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleRestoreArchive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{companies.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
|
||||
{companies.map(co => (
|
||||
<button
|
||||
key={co.id}
|
||||
className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}
|
||||
>
|
||||
{co.name}
|
||||
</button>
|
||||
))}
|
||||
{showAddForm && (
|
||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||||
<div className="card-title">Add Request</div>
|
||||
<form onSubmit={handleAddRequest}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select value={addForm.companyId} onChange={setAdd('companyId')} required>
|
||||
<option value="">Select company...</option>
|
||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<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(); handleAddProjectName(); } }}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProjectName} disabled={!newProjectName.trim()}>Add</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={addForm.project}
|
||||
onChange={e => {
|
||||
if (e.target.value === '__new__') { setIsTypingProject(true); setAddForm(f => ({ ...f, project: '' })); }
|
||||
else { setAddForm(f => ({ ...f, project: e.target.value })); }
|
||||
}}
|
||||
required
|
||||
disabled={!addForm.companyId}
|
||||
>
|
||||
<option value="">{addForm.companyId ? 'Select project...' : 'Select company first'}</option>
|
||||
{[...formProjects.map(p => p.name), ...customProjectNames.filter(n => !formProjects.some(p => p.name === n))].map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
{addForm.companyId && <option value="__new__">+ Create new project...</option>}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={addForm.serviceType} onChange={setAdd('serviceType')} required>
|
||||
<option value="">Select service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="date" value={addForm.deadline} onChange={setAdd('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addForm.isHot}
|
||||
onChange={e => setAddForm(f => ({ ...f, isHot: e.target.checked }))}
|
||||
/>
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select value={addForm.requestedBy} onChange={setAdd('requestedBy')} disabled={!addForm.companyId} required>
|
||||
<option value="">{addForm.companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||
{requesterOptions.map(user => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}{user.email ? ` (${user.email})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Title <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional — defaults to service type)</span></label>
|
||||
<input type="text" placeholder={addForm.serviceType || 'e.g. Brand Book — 2026'} value={addForm.title} onChange={setAdd('title')} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description *</label>
|
||||
<textarea placeholder="Notes on the request..." value={addForm.description} onChange={setAdd('description')} style={{ minHeight: 100 }} required />
|
||||
</div>
|
||||
|
||||
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {addError}</div>}
|
||||
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Adding...' : 'Add Request'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm(EMPTY_FORM()); setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName(''); setAddError(''); }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{[...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort().length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All Users</button>
|
||||
{[...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort().map(name => (
|
||||
|
||||
<div className="card request-toolbar-card">
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Archive</div>
|
||||
<div className="request-toolbar-actions">
|
||||
{completedTaskIds.length > 0 && (
|
||||
<>
|
||||
<label className="request-select-all-label">
|
||||
<input type="checkbox" checked={allCompletedSelected} onChange={toggleSelectAllCompleted} />
|
||||
Select All Completed
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={handleArchiveSelected}
|
||||
disabled={archivingSelected || !selectedTaskIds.length}
|
||||
>
|
||||
{archivingSelected ? 'Archiving...' : `Archive Selected (${selectedTaskIds.length})`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
key={name}
|
||||
className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterUser(f => f === name ? '' : name)}
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => restoreInputRef.current?.click()}
|
||||
disabled={restoringArchive}
|
||||
>
|
||||
{name}
|
||||
{restoringArchive ? 'Restoring...' : 'Restore Archive'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{archiveStatus && <div className="request-toolbar-status">{archiveStatus}</div>}
|
||||
{restoreStatus && <div className="request-toolbar-status">{restoreStatus}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(companies.length > 0 || requesterNames.length > 0) && (
|
||||
<div className="request-toolbar-grid">
|
||||
{companies.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
|
||||
{companies.map(co => (
|
||||
<button
|
||||
key={co.id}
|
||||
className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}
|
||||
>
|
||||
{co.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requesterNames.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
|
||||
{requesterNames.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterUser(f => f === name ? '' : name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No requests yet</h3>
|
||||
<p>Client requests will appear here.</p>
|
||||
</div>
|
||||
) : (() => {
|
||||
// Group by task_id + version_number
|
||||
const groupMap = {};
|
||||
submissions.forEach(sub => {
|
||||
const key = `${sub.task_id}-${sub.version_number}`;
|
||||
if (!groupMap[key]) groupMap[key] = [];
|
||||
groupMap[key].push(sub);
|
||||
});
|
||||
|
||||
// Sort groups by latest submitted_at descending, then apply filters
|
||||
const groups = Object.values(groupMap).filter(group => {
|
||||
const primary = group.find(s => s.type !== 'amendment') || group[0];
|
||||
const task = tasks.find(t => t.id === primary.task_id);
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
if (filterCompany && project?.company_id !== filterCompany) return false;
|
||||
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
const aMax = Math.max(...a.map(s => new Date(s.submitted_at)));
|
||||
const bMax = Math.max(...b.map(s => new Date(s.submitted_at)));
|
||||
return bMax - aMax;
|
||||
});
|
||||
|
||||
// Group by company
|
||||
const byCompany = {};
|
||||
groups.forEach(group => {
|
||||
const primary = group.find(s => s.type !== 'amendment') || group[0];
|
||||
const task = tasks.find(t => t.id === primary.task_id);
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
const key = company?.id || '__none__';
|
||||
if (!byCompany[key]) byCompany[key] = { company, groups: [] };
|
||||
byCompany[key].groups.push(group);
|
||||
});
|
||||
|
||||
const renderCard = (group) => {
|
||||
const primary = group.find(s => s.type !== 'amendment') || group[0];
|
||||
const amendments = group.filter(s => s.type === 'amendment');
|
||||
const task = tasks.find(t => t.id === primary.task_id);
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
const isCompleted = task?.status === 'client_approved';
|
||||
|
||||
return (
|
||||
<div key={primary.id} className="request-card" style={{ cursor: task ? 'pointer' : 'default' }} onClick={() => task && navigate(`/tasks/${task.id}`)}>
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
{task?.title || primary.service_type}
|
||||
<StatusBadge status={primary.type} />
|
||||
</div>
|
||||
<div className="request-card-meta">
|
||||
From <strong>{primary.submitted_by_name}</strong>
|
||||
{company && (
|
||||
<> · <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link></>
|
||||
)}
|
||||
{' · '}{new Date(primary.submitted_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isCompleted
|
||||
? <span className="badge badge-client_approved">Completed</span>
|
||||
: <StatusBadge status={task?.status || 'not_started'} />
|
||||
}
|
||||
</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 }}>{primary.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" onClick={e => e.stopPropagation()}>{project.name}</Link> : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: amendments.length > 0 ? 12 : 0 }}>{primary.description}</p>
|
||||
|
||||
{amendments.map(amendment => (
|
||||
<div key={amendment.id} style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', marginTop: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 6, letterSpacing: 0.5 }}>
|
||||
Amended Request
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
|
||||
{amendment.submitted_by_name} · {new Date(amendment.submitted_at).toLocaleDateString()}
|
||||
</div>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No matching requests</h3>
|
||||
<p>Try clearing the current company or requester filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'active', label: 'Active', count: activeGroups.length },
|
||||
{ id: 'client-review', label: 'Client Review', count: clientReviewGroups.length },
|
||||
{ id: 'completed', label: 'Completed', count: completedGroups.length },
|
||||
{ id: 'closed', label: 'Fully Closed', count: closedGroups.length },
|
||||
].map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="request-company-count" style={{ marginLeft: 6 }}>{tab.count}</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return Object.values(byCompany).map(({ company, groups: companyGroups }) => (
|
||||
<div key={company?.id || '__none__'} style={{ marginBottom: 32 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.6px', color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
{company
|
||||
? <Link to={`/companies/${company.id}`} style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>{company.name}</Link>
|
||||
: 'No Company'
|
||||
}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{companyGroups.map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
|
||||
{activeTab === 'active' && (
|
||||
<div>
|
||||
{activeGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No active requests</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Client</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'client-review' && (
|
||||
<div>
|
||||
{clientReviewGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No requests in client review</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Client</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clientReviewGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'completed' && (
|
||||
<div>
|
||||
{completedGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No completed requests</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Client</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{completedGroups.map(group => renderRow(group, true))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'closed' && (
|
||||
<div>
|
||||
{closedGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No fully closed requests</h3>
|
||||
<p>Requests move here once they are completed, invoiced, and paid.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Client</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{closedGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
none: { bg: 'rgba(34,197,94,0.15)', border: 'rgba(34,197,94,0.3)', color: '#4ade80', label: 'Operational' },
|
||||
minor: { bg: 'rgba(245,165,35,0.15)', border: 'rgba(245,165,35,0.3)', color: '#f5a523', label: 'Minor Issues' },
|
||||
major: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)', color: '#f87171', label: 'Major Outage' },
|
||||
critical: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)', color: '#f87171', label: 'Critical Outage' },
|
||||
maintenance: { bg: 'rgba(96,165,250,0.15)', border: 'rgba(96,165,250,0.3)', color: '#93c5fd', label: 'Maintenance' },
|
||||
};
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return 'Unavailable';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / 1024 ** index;
|
||||
return `${value >= 100 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatValue(value, unit) {
|
||||
if (value === null || value === undefined) return 'Unavailable';
|
||||
if (unit === 'bytes') return formatBytes(value);
|
||||
if (unit === 'hours') return `${Number(value).toFixed(Number(value) >= 10 ? 0 : 1)} h`;
|
||||
return Number(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unavailable';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function StatusPill({ indicator, description }) {
|
||||
const style = STATUS_STYLE[indicator] || STATUS_STYLE.major;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 999,
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
color: style.color,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span>{style.label}</span>
|
||||
{description ? <span style={{ opacity: 0.9, fontWeight: 500 }}>· {description}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageBar({ quota }) {
|
||||
const canMeasure = quota.used !== null && quota.used !== undefined && quota.limit;
|
||||
const percent = canMeasure ? Math.min((quota.used / quota.limit) * 100, 100) : 0;
|
||||
const remaining = canMeasure ? Math.max(quota.limit - quota.used, 0) : null;
|
||||
const overLimit = canMeasure && quota.used > quota.limit;
|
||||
const tone = overLimit ? 'var(--danger)' : percent >= 85 ? '#f5a523' : 'var(--accent)';
|
||||
|
||||
return (
|
||||
<div className="status-meter">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{quota.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{quota.note}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{formatValue(quota.used, quota.unit)} / {formatValue(quota.limit, quota.unit)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overLimit ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||||
{canMeasure ? `${percent.toFixed(1)}% used` : quota.source === 'manual' ? 'Manual reading' : 'Not available yet'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-meter-track">
|
||||
<div className="status-meter-fill" style={{ width: `${percent}%`, background: tone }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{canMeasure
|
||||
? overLimit
|
||||
? `${formatValue(quota.used - quota.limit, quota.unit)} over the free-tier reference.`
|
||||
: `${formatValue(remaining, quota.unit)} left before the free-tier reference.`
|
||||
: 'No current reading is available for this metric.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsGrid({ stats }) {
|
||||
if (!stats?.length) return null;
|
||||
return (
|
||||
<div className="status-stats-grid">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="status-stat-card">
|
||||
<div className="status-stat-value">{Number(stat.value).toLocaleString()}</div>
|
||||
<div className="status-stat-label">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ service }) {
|
||||
const topComponents = (service.status.components || []).slice(0, 6);
|
||||
const visibleQuotas = service.usage.quotas || [];
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 22 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div className="card-title" style={{ marginBottom: 8 }}>{service.name}</div>
|
||||
<StatusPill indicator={service.status.status?.indicator} description={service.status.status?.description} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>
|
||||
Last checked {formatDate(service.status.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 220, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{service.usage.message ? (
|
||||
<div style={{ padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
||||
{service.usage.message}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
||||
Free-tier bars show current usage where the server can measure it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topComponents.length > 0 && (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
Components
|
||||
</div>
|
||||
<div className="status-components-grid">
|
||||
{topComponents.map((component) => (
|
||||
<div key={component.id} className="status-component">
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{component.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4, textTransform: 'capitalize' }}>
|
||||
{component.status.replaceAll('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatsGrid stats={service.usage.stats} />
|
||||
|
||||
<div style={{ display: 'grid', gap: 14, marginTop: service.usage.stats?.length ? 18 : 22 }}>
|
||||
{visibleQuotas.map((quota) => (
|
||||
<UsageBar key={quota.id} quota={quota} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{service.usage.buckets?.length > 0 && (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
Biggest Supabase Buckets
|
||||
</div>
|
||||
<div className="status-components-grid">
|
||||
{service.usage.buckets.map((bucket) => (
|
||||
<div key={bucket.bucketId} className="status-component">
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{bucket.bucketId}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{formatBytes(bucket.bytes)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerStatus() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [payload, setPayload] = useState(null);
|
||||
|
||||
const load = async (cancelledRef) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const { data: sessionData, error: sessionError } = await supabase.auth.getSession();
|
||||
if (sessionError) throw sessionError;
|
||||
|
||||
const accessToken = sessionData.session?.access_token;
|
||||
if (!accessToken) throw new Error('You must be signed in to view server status.');
|
||||
|
||||
const response = await fetch('/api/server-status', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(json.error || 'Unable to load server status.');
|
||||
}
|
||||
|
||||
if (!cancelledRef.current) {
|
||||
setPayload(json);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelledRef.current) {
|
||||
setError(err.message || 'Unable to load server status.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelledRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const cancelledRef = { current: false };
|
||||
load(cancelledRef);
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Server Status</div>
|
||||
<div className="page-subtitle">Live service health plus free-tier usage references for Supabase and Vercel.</div>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => load({ current: false })} disabled={loading}>
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="card" style={{ padding: 28, color: 'var(--text-muted)' }}>Loading current service status…</div>
|
||||
) : error ? (
|
||||
<div className="card" style={{ padding: 28, borderColor: 'rgba(239,68,68,0.4)', color: '#fca5a5' }}>{error}</div>
|
||||
) : (
|
||||
<div className="server-status-grid">
|
||||
<ServiceCard service={payload.services.supabase} />
|
||||
<ServiceCard service={payload.services.vercel} />
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,802 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateBrandBookEditorPDF } from '../../lib/signsurvey';
|
||||
|
||||
const BUCKET = 'brand-books';
|
||||
|
||||
const EMPTY_SIGN = () => ({
|
||||
_key: Math.random().toString(36).slice(2),
|
||||
signNumber: '',
|
||||
type: '',
|
||||
location: '',
|
||||
width: '',
|
||||
height: '',
|
||||
material: '',
|
||||
illumination: '',
|
||||
condition: '',
|
||||
mountType: '',
|
||||
notes: '',
|
||||
photo: null,
|
||||
photoPath: '',
|
||||
_photoPreview: '',
|
||||
});
|
||||
|
||||
const EMPTY_BOOK_INFO = {
|
||||
clientId: '',
|
||||
clientName: '',
|
||||
projectName: '',
|
||||
siteAddress: '',
|
||||
bookDate: new Date().toISOString().split('T')[0],
|
||||
preparedBy: '',
|
||||
revision: '01',
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const getSignedUrl = async (path) => {
|
||||
if (!path) return null;
|
||||
const { data } = await supabase.storage.from(BUCKET).createSignedUrl(path, 86400 * 7);
|
||||
return data?.signedUrl || null;
|
||||
};
|
||||
|
||||
const uploadFile = async (file, path) => {
|
||||
const { error } = await supabase.storage.from(BUCKET).upload(path, file, { upsert: true });
|
||||
if (error) throw error;
|
||||
return path;
|
||||
};
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
export default function BrandBook() {
|
||||
const [view, setView] = useState('list');
|
||||
const [savedBooks, setSavedBooks] = useState([]);
|
||||
const [loadingBooks, setLoadingBooks] = useState(true);
|
||||
const [currentId, setCurrentId] = useState(null);
|
||||
|
||||
const [clients, setClients] = useState([]);
|
||||
const [bookInfo, setBookInfo] = useState(EMPTY_BOOK_INFO);
|
||||
const [siteMapFile, setSiteMapFile] = useState(null);
|
||||
const [siteMapPath, setSiteMapPath] = useState('');
|
||||
const [siteMapPreview, setSiteMapPreview] = useState(null);
|
||||
const [signs, setSigns] = useState([EMPTY_SIGN()]);
|
||||
const [photoItems, setPhotoItems] = useState([]);
|
||||
const [expandedSign, setExpandedSign] = useState(0);
|
||||
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [notification, setNotification] = useState(null);
|
||||
|
||||
const siteMapRef = useRef();
|
||||
const sitePhotosRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
|
||||
fetchBooks();
|
||||
}, []);
|
||||
|
||||
const fetchBooks = async () => {
|
||||
setLoadingBooks(true);
|
||||
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
|
||||
setSavedBooks(data || []);
|
||||
setLoadingBooks(false);
|
||||
};
|
||||
|
||||
const set = (field) => (e) => setBookInfo(b => ({ ...b, [field]: e.target.value }));
|
||||
|
||||
const handleClientChange = (e) => {
|
||||
const id = e.target.value;
|
||||
const client = clients.find(c => c.id === id);
|
||||
setBookInfo(b => ({ ...b, clientId: id, clientName: client ? client.name : '' }));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setBookInfo(EMPTY_BOOK_INFO);
|
||||
setSiteMapFile(null);
|
||||
setSiteMapPath('');
|
||||
setSiteMapPreview(null);
|
||||
setSigns([EMPTY_SIGN()]);
|
||||
setPhotoItems([]);
|
||||
setCurrentId(null);
|
||||
setExpandedSign(0);
|
||||
setNotification(null);
|
||||
};
|
||||
|
||||
const handleNew = () => { resetForm(); setView('form'); };
|
||||
|
||||
const buildLoadedState = async (book) => {
|
||||
const siteMapSignedUrl = await getSignedUrl(book.site_map_path);
|
||||
const signsWithPreviews = await Promise.all((book.signs || []).map(async (sign) => {
|
||||
const preview = await getSignedUrl(sign.photoPath);
|
||||
return { ...sign, photo: null, _photoPreview: preview || '' };
|
||||
}));
|
||||
const surveyItems = await Promise.all((book.survey_photo_paths || []).map(async (path) => {
|
||||
const preview = await getSignedUrl(path);
|
||||
return { file: null, path, preview: preview || '' };
|
||||
}));
|
||||
return { siteMapSignedUrl, signsWithPreviews, surveyItems };
|
||||
};
|
||||
|
||||
const handleLoad = async (book) => {
|
||||
setNotification(null);
|
||||
const { siteMapSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
|
||||
setBookInfo({
|
||||
clientId: book.client_id || '',
|
||||
clientName: book.client_name || '',
|
||||
projectName: book.project_name || '',
|
||||
siteAddress: book.site_address || '',
|
||||
bookDate: book.book_date || new Date().toISOString().split('T')[0],
|
||||
preparedBy: book.prepared_by || '',
|
||||
revision: book.revision || '01',
|
||||
});
|
||||
setSiteMapFile(null);
|
||||
setSiteMapPath(book.site_map_path || '');
|
||||
setSiteMapPreview(siteMapSignedUrl);
|
||||
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
|
||||
setPhotoItems(surveyItems);
|
||||
setCurrentId(book.id);
|
||||
setExpandedSign(null);
|
||||
setView('form');
|
||||
};
|
||||
|
||||
const handleAddRevision = async (book) => {
|
||||
setNotification(null);
|
||||
const { siteMapSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
|
||||
const nextRev = String((parseInt(book.revision || '1', 10) + 1)).padStart(2, '0');
|
||||
setBookInfo({
|
||||
clientId: book.client_id || '',
|
||||
clientName: book.client_name || '',
|
||||
projectName: book.project_name || '',
|
||||
siteAddress: book.site_address || '',
|
||||
bookDate: new Date().toISOString().split('T')[0],
|
||||
preparedBy: book.prepared_by || '',
|
||||
revision: nextRev,
|
||||
});
|
||||
setSiteMapFile(null);
|
||||
setSiteMapPath(book.site_map_path || '');
|
||||
setSiteMapPreview(siteMapSignedUrl);
|
||||
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
|
||||
setPhotoItems(surveyItems);
|
||||
setCurrentId(null); // new entry on save
|
||||
setExpandedSign(null);
|
||||
setView('form');
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Delete this brand book? This cannot be undone.')) return;
|
||||
await supabase.from('brand_books').delete().eq('id', id);
|
||||
fetchBooks();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!bookInfo.clientName.trim()) {
|
||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setNotification(null);
|
||||
try {
|
||||
const bookId = currentId || crypto.randomUUID();
|
||||
|
||||
// Upload site map if new file
|
||||
let finalSiteMapPath = siteMapPath;
|
||||
if (siteMapFile) {
|
||||
const ext = siteMapFile.name.split('.').pop().toLowerCase();
|
||||
finalSiteMapPath = `${bookId}/site-map.${ext}`;
|
||||
await uploadFile(siteMapFile, finalSiteMapPath);
|
||||
}
|
||||
|
||||
// Upload sign photos if new file
|
||||
const finalSigns = await Promise.all(signs.map(async (sign) => {
|
||||
let photoPath = sign.photoPath || '';
|
||||
if (sign.photo) {
|
||||
const ext = sign.photo.name.split('.').pop().toLowerCase();
|
||||
photoPath = `${bookId}/sign-${sign._key}.${ext}`;
|
||||
await uploadFile(sign.photo, photoPath);
|
||||
}
|
||||
const { photo, _photoPreview, ...rest } = sign;
|
||||
return { ...rest, photoPath };
|
||||
}));
|
||||
|
||||
// Upload survey photos if new file
|
||||
const finalSurveyPaths = await Promise.all(photoItems.map(async (item, i) => {
|
||||
if (item.file) {
|
||||
const ext = item.file.name.split('.').pop().toLowerCase();
|
||||
const path = `${bookId}/survey-${i}-${Date.now()}.${ext}`;
|
||||
await uploadFile(item.file, path);
|
||||
return path;
|
||||
}
|
||||
return item.path;
|
||||
}));
|
||||
|
||||
const dbData = {
|
||||
id: bookId,
|
||||
client_id: bookInfo.clientId || null,
|
||||
client_name: bookInfo.clientName,
|
||||
project_name: bookInfo.projectName,
|
||||
site_address: bookInfo.siteAddress,
|
||||
book_date: bookInfo.bookDate || null,
|
||||
prepared_by: bookInfo.preparedBy,
|
||||
revision: bookInfo.revision,
|
||||
site_map_path: finalSiteMapPath,
|
||||
signs: finalSigns,
|
||||
survey_photo_paths: finalSurveyPaths.filter(Boolean),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await supabase.from('brand_books').upsert(dbData);
|
||||
setCurrentId(bookId);
|
||||
setSiteMapPath(finalSiteMapPath);
|
||||
setSiteMapFile(null);
|
||||
setSigns(finalSigns.map(s => ({ ...s, photo: null })));
|
||||
setPhotoItems(finalSurveyPaths.filter(Boolean).map((path, i) => ({
|
||||
file: null,
|
||||
path,
|
||||
preview: photoItems[i]?.preview || '',
|
||||
})));
|
||||
await fetchBooks();
|
||||
setNotification({ type: 'success', msg: '✓ Brand book saved!' });
|
||||
} catch (err) {
|
||||
setNotification({ type: 'error', msg: `Save failed: ${err.message}` });
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!bookInfo.clientName.trim()) {
|
||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
setNotification(null);
|
||||
try {
|
||||
const siteMapSource = siteMapFile || (siteMapPath ? await getSignedUrl(siteMapPath) : null);
|
||||
const signsWithSource = await Promise.all(signs.map(async s => ({
|
||||
...s,
|
||||
photoSource: s.photo || (s.photoPath ? await getSignedUrl(s.photoPath) : null),
|
||||
})));
|
||||
const sitePhotoSources = await Promise.all(photoItems.map(async item =>
|
||||
item.file || (item.path ? await getSignedUrl(item.path) : null)
|
||||
));
|
||||
|
||||
await generateBrandBookEditorPDF({
|
||||
clientName: bookInfo.clientName,
|
||||
projectName: bookInfo.projectName,
|
||||
siteAddress: bookInfo.siteAddress,
|
||||
bookDate: bookInfo.bookDate,
|
||||
preparedBy: bookInfo.preparedBy,
|
||||
revision: bookInfo.revision,
|
||||
siteMapSource,
|
||||
signs: signsWithSource,
|
||||
sitePhotoSources,
|
||||
});
|
||||
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
|
||||
} catch (err) {
|
||||
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
// ── Sign helpers ─────────────────────────────────────────────────────────────
|
||||
const updateSign = (key, field, value) =>
|
||||
setSigns(prev => prev.map(s => s._key === key ? { ...s, [field]: value } : s));
|
||||
const addSign = () => { setSigns(prev => [...prev, EMPTY_SIGN()]); setExpandedSign(signs.length); };
|
||||
const removeSign = (key) => setSigns(prev => prev.filter(s => s._key !== key));
|
||||
const handleSignPhoto = (key, file) => {
|
||||
updateSign(key, 'photo', file);
|
||||
updateSign(key, '_photoPreview', URL.createObjectURL(file));
|
||||
updateSign(key, 'photoPath', ''); // clear old path when new file selected
|
||||
};
|
||||
|
||||
// ── Site map helpers ──────────────────────────────────────────────────────────
|
||||
const handleSiteMapFile = (file) => {
|
||||
setSiteMapFile(file);
|
||||
setSiteMapPath('');
|
||||
setSiteMapPreview(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
// ── Survey photo helpers ──────────────────────────────────────────────────────
|
||||
const handleSitePhotos = (files) => {
|
||||
const newItems = Array.from(files)
|
||||
.filter(f => f.type.startsWith('image/'))
|
||||
.map(file => ({ file, path: '', preview: URL.createObjectURL(file) }));
|
||||
setPhotoItems(prev => [...prev, ...newItems]);
|
||||
};
|
||||
const removeSitePhoto = (i) => setPhotoItems(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
// ── Unsaved changes indicator ─────────────────────────────────────────────────
|
||||
const hasUnsavedPhotos = siteMapFile || signs.some(s => s.photo) || photoItems.some(i => i.file);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// LIST VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
if (view === 'list') {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">
|
||||
Brand Book
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--accent)', marginLeft: 8, padding: '2px 8px', border: '1px solid var(--accent)', borderRadius: 4 }}>beta</span>
|
||||
</div>
|
||||
<div className="page-subtitle">Saved brand books — click to edit or add a revision.</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleNew}>+ New Brand Book</button>
|
||||
</div>
|
||||
|
||||
{loadingBooks ? (
|
||||
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : savedBooks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No brand books yet</h3>
|
||||
<p>Create your first brand book to get started.</p>
|
||||
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{savedBooks.map(book => (
|
||||
<BookListItem
|
||||
key={book.id}
|
||||
book={book}
|
||||
onEdit={() => handleLoad(book)}
|
||||
onRevision={() => handleAddRevision(book)}
|
||||
onDelete={() => handleDelete(book.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FORM VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => setView('list')}
|
||||
style={{ fontSize: 12 }}
|
||||
>← All Brand Books</button>
|
||||
<div className="page-title" style={{ margin: 0 }}>
|
||||
{currentId
|
||||
? `${bookInfo.clientName || 'Brand Book'} — ${bookInfo.projectName || ''} R${String(bookInfo.revision).padStart(2, '0')}`
|
||||
: 'New Brand Book'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-subtitle" style={{ marginTop: 4 }}>
|
||||
{currentId ? 'Editing saved brand book' : 'Unsaved — fill in details and save'}
|
||||
{hasUnsavedPhotos && <span style={{ color: 'var(--accent)', marginLeft: 8 }}>· Unsaved photos</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { if (window.confirm('Discard changes?')) { resetForm(); setView('list'); } }}>Discard</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : '💾 Save'}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleGenerate} disabled={generating}>
|
||||
{generating ? 'Generating...' : '⬇ Generate PDF'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notification && (
|
||||
<div className={`notification ${notification.type === 'error' ? 'notification-error' : 'notification-success'}`} style={{ marginBottom: 24 }}>
|
||||
{notification.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* ── BRAND BOOK INFO ──────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Brand Book Info</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Client *</label>
|
||||
<select value={bookInfo.clientId} onChange={handleClientChange}>
|
||||
<option value="">— Select client —</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Project Name</label>
|
||||
<input type="text" placeholder="e.g. Bolchoz Sign Solutions 2025" value={bookInfo.projectName} onChange={set('projectName')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Site Address</label>
|
||||
<input type="text" placeholder="e.g. 123 Main St, City, State" value={bookInfo.siteAddress} onChange={set('siteAddress')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Date</label>
|
||||
<input type="date" value={bookInfo.bookDate} onChange={set('bookDate')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Prepared By</label>
|
||||
<input type="text" placeholder="e.g. John Smith" value={bookInfo.preparedBy} onChange={set('preparedBy')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Revision #</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
value={parseInt(bookInfo.revision, 10) || 1}
|
||||
onChange={e => setBookInfo(b => ({ ...b, revision: String(e.target.value).padStart(2, '0') }))}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SITE MAP ──────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Site Map</div>
|
||||
<div className="form-group">
|
||||
<label>Upload Site Map Image <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional — shown on page 2 beside the sign inventory)</span></label>
|
||||
<SiteMapDropZone
|
||||
preview={siteMapPreview}
|
||||
onFile={handleSiteMapFile}
|
||||
onClear={() => { setSiteMapFile(null); setSiteMapPath(''); setSiteMapPreview(null); }}
|
||||
inputRef={siteMapRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SIGNS ─────────────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>Signs</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={addSign}>+ Add Sign</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{signs.map((sign, i) => (
|
||||
<SignCard
|
||||
key={sign._key}
|
||||
sign={sign}
|
||||
index={i}
|
||||
expanded={expandedSign === i}
|
||||
onToggle={() => setExpandedSign(expandedSign === i ? null : i)}
|
||||
onChange={(field, val) => updateSign(sign._key, field, val)}
|
||||
onPhotoChange={(file) => handleSignPhoto(sign._key, file)}
|
||||
onRemove={() => removeSign(sign._key)}
|
||||
canRemove={signs.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={addSign} style={{ marginTop: 12 }}>+ Add Sign</button>
|
||||
</div>
|
||||
|
||||
{/* ── SITE PHOTOS ───────────────────────────────────────────────────── */}
|
||||
<div className="card">
|
||||
<div className="card-title">Site Photos</div>
|
||||
<div className="form-group">
|
||||
<label>General site photos <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(displayed as thumbnails on the last page — up to 12 shown)</span></label>
|
||||
<SitePhotosDropZone
|
||||
photoItems={photoItems}
|
||||
onFiles={handleSitePhotos}
|
||||
onRemove={removeSitePhoto}
|
||||
inputRef={sitePhotosRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
|
||||
<button className="btn btn-outline" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : '💾 Save Brand Book'}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-lg" onClick={handleGenerate} disabled={generating}>
|
||||
{generating ? 'Generating...' : '⬇ Generate Brand Book PDF'}
|
||||
</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Book List Item ───────────────────────────────────────────────────────────
|
||||
function BookListItem({ book, onEdit, onRevision, onDelete }) {
|
||||
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
|
||||
const date = book.book_date
|
||||
? new Date(book.book_date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
const updated = book.updated_at
|
||||
? new Date(book.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--card-bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)' }}>{book.client_name}</span>
|
||||
{book.project_name && (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>— {book.project_name}</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, padding: '1px 7px',
|
||||
background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4,
|
||||
}}>R{String(book.revision || '01').padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{book.site_address && <span>📍 {book.site_address}</span>}
|
||||
{signCount > 0 && <span>🪧 {signCount} sign{signCount !== 1 ? 's' : ''}</span>}
|
||||
{date && <span>📅 {date}</span>}
|
||||
{updated && <span style={{ color: 'var(--text-muted)', opacity: 0.7 }}>Saved {updated}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={onEdit}>Edit</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={onRevision}
|
||||
title="Create a new revision based on this one"
|
||||
style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }}
|
||||
>+ Revision</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sign Card ────────────────────────────────────────────────────────────────
|
||||
function SignCard({ sign, index, expanded, onToggle, onChange, onPhotoChange, onRemove, canRemove }) {
|
||||
const photoInputRef = useRef();
|
||||
const dragCounter = useRef(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
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 file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) onPhotoChange(file);
|
||||
};
|
||||
|
||||
const summary = [sign.type, sign.location].filter(Boolean).join(' — ') || 'New Sign';
|
||||
const hasPhoto = sign._photoPreview;
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 14px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', borderBottom: expanded ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
|
||||
#{sign.signNumber || (index + 1)}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{summary}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{hasPhoto && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📷</span>}
|
||||
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
|
||||
{canRemove && (
|
||||
<span role="button" onClick={e => { e.stopPropagation(); onRemove(); }}
|
||||
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}>✕</span>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{expanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Sign # / ID</label>
|
||||
<input type="text" placeholder={`e.g. ${index + 1}`} value={sign.signNumber} onChange={e => onChange('signNumber', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Sign Type</label>
|
||||
<input type="text" placeholder="e.g. Monument, Channel Letter, Pylon…" value={sign.type} onChange={e => onChange('type', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Location</label>
|
||||
<input type="text" placeholder="e.g. Main entrance, North parking lot" value={sign.location} onChange={e => onChange('location', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Dimensions (inches)</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input type="text" placeholder='Width"' value={sign.width} onChange={e => onChange('width', e.target.value)} style={{ flex: 1 }} />
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>×</span>
|
||||
<input type="text" placeholder='Height"' value={sign.height} onChange={e => onChange('height', e.target.value)} style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Material <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
|
||||
<input type="text" placeholder="e.g. Aluminum, Acrylic" value={sign.material} onChange={e => onChange('material', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Illumination <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
|
||||
<input type="text" placeholder="e.g. LED, None, Neon" value={sign.illumination} onChange={e => onChange('illumination', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Condition <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
|
||||
<input type="text" placeholder="e.g. Good, Fair, Poor" value={sign.condition} onChange={e => onChange('condition', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12, marginBottom: 12 }}>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Mount Type <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>placeholder</span></label>
|
||||
<input type="text" placeholder="e.g. Wall, Post, Ground" value={sign.mountType} onChange={e => onChange('mountType', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Notes</label>
|
||||
<input type="text" placeholder="Any additional observations…" value={sign.notes} onChange={e => onChange('notes', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label>Sign Photo</label>
|
||||
<div
|
||||
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver} onDrop={handleDrop}
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12,
|
||||
transition: 'border-color 0.15s, background 0.15s', minHeight: 60,
|
||||
}}
|
||||
>
|
||||
{sign._photoPreview ? (
|
||||
<>
|
||||
<img src={sign._photoPreview} alt="sign" style={{ height: 60, width: 80, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
|
||||
{sign.photo ? sign.photo.name : 'Saved photo'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Click to replace · drag a new photo</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13 }}>
|
||||
{dragging ? '📂 Drop photo here' : '📷 Click to upload or drag & drop a photo'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input ref={photoInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={e => { const f = e.target.files[0]; if (f) onPhotoChange(f); e.target.value = ''; }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Site Map Drop Zone ───────────────────────────────────────────────────────
|
||||
function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
|
||||
const dragCounter = useRef(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
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 file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) onFile(file);
|
||||
};
|
||||
|
||||
if (preview) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 6, border: '1px solid var(--border)', objectFit: 'contain' }} />
|
||||
<div>
|
||||
<button className="btn btn-outline btn-sm" onClick={onClear}>Remove</button>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>Click to replace</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver} onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
|
||||
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{dragging ? '📂 Drop site map here' : '🗺 Click to upload or drag & drop a site map image'}
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={e => { const f = e.target.files[0]; if (f) onFile(f); e.target.value = ''; }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Survey Photos Drop Zone ──────────────────────────────────────────────────
|
||||
function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
|
||||
const dragCounter = useRef(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
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); onFiles(e.dataTransfer.files); };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver} onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
|
||||
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
|
||||
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{dragging ? '📂 Drop photos here' : '📷 Click to upload or drag & drop photos (select multiple)'}
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||||
onChange={e => { if (e.target.files.length) { onFiles(e.target.files); e.target.value = ''; } }} />
|
||||
{photoItems.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{photoItems.map((item, i) => (
|
||||
<div key={i} style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.file?.name || `photo ${i + 1}`}
|
||||
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
|
||||
/>
|
||||
{item.file && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 700 }}>NEW</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(i)}
|
||||
style={{
|
||||
position: 'absolute', top: -6, right: -6,
|
||||
background: '#dc2626', border: 'none', borderRadius: '50%',
|
||||
width: 18, height: 18, fontSize: 10, color: '#fff',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
|
||||
}}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', alignSelf: 'flex-end', marginLeft: 4 }}>
|
||||
{photoItems.length} photo{photoItems.length !== 1 ? 's' : ''}
|
||||
{photoItems.length > 12 && <span style={{ color: 'var(--accent)' }}> (first 12 shown in PDF)</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
|
||||
const poStatusColor = {
|
||||
draft: 'not_started',
|
||||
sent: 'in_progress',
|
||||
approved: 'client_approved',
|
||||
ready_to_pay: 'in_progress',
|
||||
paid: 'client_approved',
|
||||
cancelled: 'needs_revision',
|
||||
};
|
||||
|
||||
const poStatusLabel = {
|
||||
draft: 'Draft',
|
||||
sent: 'Sent',
|
||||
approved: 'Approved',
|
||||
ready_to_pay: 'Ready to Pay',
|
||||
paid: 'Paid',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
const poSelect = '*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))';
|
||||
|
||||
export default function SubcontractorPODetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [po, setPO] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('subcontractor_payments')
|
||||
.select(poSelect)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
setPO(data || null);
|
||||
} catch (error) {
|
||||
console.error('Subcontractor PO load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const updatePO = async (updates, errorLabel = 'Failed to update PO') => {
|
||||
setSaving(true);
|
||||
const { data, error } = await supabase
|
||||
.from('subcontractor_payments')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select(poSelect)
|
||||
.single();
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
alert(`${errorLabel}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
setPO(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
const sendPOEmail = async (sentPO) => {
|
||||
if (!sentPO?.profile?.email) {
|
||||
alert('This subcontractor has no email on file.');
|
||||
return;
|
||||
}
|
||||
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
|
||||
poNumber: sentPO.po_number || 'Purchase Order',
|
||||
subcontractorName: sentPO.profile?.name || 'there',
|
||||
projectName: sentPO.project?.name || 'Subcontractor Work',
|
||||
companyName: sentPO.project?.company?.name || 'Fourge Branding',
|
||||
amount: `$${Number(sentPO.amount).toFixed(2)}`,
|
||||
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
|
||||
terms: sentPO.terms || 'Net 15',
|
||||
scope: sentPO.items?.length
|
||||
? sentPO.items.map(item => `${item.description} - $${Number(item.amount).toFixed(2)}`).join('\n')
|
||||
: sentPO.description,
|
||||
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
|
||||
});
|
||||
};
|
||||
|
||||
const handleFinalizeSend = async () => {
|
||||
if (!window.confirm(`Finalize and send ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
|
||||
const sentPO = await updatePO({ status: 'sent', sent_at: new Date().toISOString() }, 'Failed to finalize PO');
|
||||
if (!sentPO) return;
|
||||
try {
|
||||
await sendPOEmail(sentPO);
|
||||
alert('PO email sent successfully.');
|
||||
} catch (error) {
|
||||
alert(`PO was finalized, but the email failed: ${error.message || 'unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!window.confirm(`Resend ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await sendPOEmail(po);
|
||||
alert('PO email sent successfully.');
|
||||
} catch (error) {
|
||||
alert(`Failed to send PO: ${error.message || 'unknown error'}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay');
|
||||
const handleMarkPaid = () => updatePO({ status: 'paid', paid_at: new Date().toISOString().slice(0, 10) }, 'Failed to mark paid');
|
||||
const handleReopen = () => updatePO({ status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
|
||||
await updatePO({ status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Delete ${po.po_number || 'this PO'}? This cannot be undone.`)) return;
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from('subcontractor_payments').delete().eq('id', id);
|
||||
if (error) {
|
||||
alert(`Failed to delete PO: ${error.message}`);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (generating) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
await generateSubcontractorPOPDF(po);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!po) return <Layout><p>Purchase order not found.</p></Layout>;
|
||||
|
||||
const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}>← Back to Subcontractor PO</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{po.po_number || 'Purchase Order'}</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`} style={{ fontSize: 13, padding: '6px 14px' }}>
|
||||
{poStatusLabel[po.status] || po.status}
|
||||
</span>
|
||||
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
|
||||
{po.status !== 'draft' && po.status !== 'cancelled' && (
|
||||
<button className="btn btn-outline" onClick={handleResend} disabled={saving}>Resend PO</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Subcontractor</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{po.profile?.name || 'External Team Member'}</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 4 }}>{po.profile?.email || 'No email on file'}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">PO Details</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>PO Date</label><p>{new Date(po.date).toLocaleDateString()}</p></div>
|
||||
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
|
||||
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
|
||||
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
|
||||
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
|
||||
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</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>Project</th>
|
||||
<th>Task</th>
|
||||
<th>Description</th>
|
||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>{po.project?.name || 'No project'}</td>
|
||||
<td>{item.task?.title || '—'}</td>
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(item.amount).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(po.amount).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(po.description || po.notes?.trim()) && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Notes</div>
|
||||
{po.description && <p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{po.description}</p>}
|
||||
{po.notes?.trim() && <p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{po.notes}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Actions</div>
|
||||
<div className="action-buttons">
|
||||
{po.status === 'draft' && <button className="btn btn-primary" onClick={handleFinalizeSend} disabled={saving}>Finalize & Send</button>}
|
||||
{po.status !== 'draft' && po.status !== 'cancelled' && <button className="btn btn-outline" onClick={handleResend} disabled={saving}>Resend PO</button>}
|
||||
{['sent', 'approved'].includes(po.status) && <button className="btn btn-outline" onClick={handleReadyToPay} disabled={saving}>Mark Ready</button>}
|
||||
{po.status === 'ready_to_pay' && <button className="btn btn-success" onClick={handleMarkPaid} disabled={saving}>Mark Paid</button>}
|
||||
{po.status === 'paid' && <button className="btn btn-outline" onClick={handleReopen} disabled={saving}>Reopen</button>}
|
||||
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
|
||||
{!['paid', 'cancelled'].includes(po.status) && <button className="btn btn-outline" onClick={handleCancel} disabled={saving}>Cancel PO</button>}
|
||||
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete PO</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { generateSurveyMakerPdf } from '../../lib/surveyMakerPdf';
|
||||
|
||||
const PHOTO_FILE_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
|
||||
const PHOTO_FILE_EXTENSIONS = new Set(['heic', 'heif', 'avif', 'tif', 'tiff', 'bmp', 'webp', 'jpeg', 'jpg', 'png', 'gif']);
|
||||
|
||||
const EMPTY_SIGN = () => ({
|
||||
id: Math.random().toString(36).slice(2),
|
||||
signName: '',
|
||||
measurements: '',
|
||||
notes: '',
|
||||
mainPhoto: null,
|
||||
mainPreview: '',
|
||||
contextPhoto1: null,
|
||||
contextPreview1: '',
|
||||
contextPhoto2: null,
|
||||
contextPreview2: '',
|
||||
contextPhoto3: null,
|
||||
contextPreview3: '',
|
||||
});
|
||||
|
||||
function isPhotoFile(file) {
|
||||
if (!file) return false;
|
||||
if (file.type?.startsWith('image/')) return true;
|
||||
const extension = file.name?.split('.').pop()?.toLowerCase();
|
||||
return extension ? PHOTO_FILE_EXTENSIONS.has(extension) : false;
|
||||
}
|
||||
|
||||
export default function SurveyMaker() {
|
||||
const [surveyInfo, setSurveyInfo] = useState({
|
||||
clientName: '',
|
||||
projectName: '',
|
||||
siteAddress: '',
|
||||
surveyDate: new Date().toISOString().slice(0, 10),
|
||||
preparedBy: '',
|
||||
});
|
||||
const [signs, setSigns] = useState([EMPTY_SIGN()]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [notification, setNotification] = useState(null);
|
||||
|
||||
const setField = (field) => (event) => {
|
||||
setSurveyInfo(current => ({ ...current, [field]: event.target.value }));
|
||||
};
|
||||
|
||||
const updateSign = (id, field, value) => {
|
||||
setSigns(current => current.map(sign => (sign.id === id ? { ...sign, [field]: value } : sign)));
|
||||
};
|
||||
|
||||
const handlePhoto = (id, field, previewField, file) => {
|
||||
if (!isPhotoFile(file)) return;
|
||||
updateSign(id, field, file);
|
||||
updateSign(id, previewField, URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const addSign = () => {
|
||||
setSigns(current => [...current, EMPTY_SIGN()]);
|
||||
};
|
||||
|
||||
const removeSign = (id) => {
|
||||
setSigns(current => current.length === 1 ? current : current.filter(sign => sign.id !== id));
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setNotification(null);
|
||||
|
||||
try {
|
||||
await generateSurveyMakerPdf({
|
||||
...surveyInfo,
|
||||
signs,
|
||||
});
|
||||
setNotification({ type: 'success', msg: '✓ Survey PDF downloaded!' });
|
||||
} catch (error) {
|
||||
setNotification({ type: 'error', msg: `Failed to generate PDF: ${error.message}` });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Survey Maker</div>
|
||||
<div className="page-subtitle">Create a survey PDF with sign photos, measurements, and notes.</div>
|
||||
</div>
|
||||
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
||||
</div>
|
||||
|
||||
{notification && (
|
||||
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, #16a34a)', fontSize: 13 }}>
|
||||
{notification.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Survey Info</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Client Name</label>
|
||||
<input type="text" value={surveyInfo.clientName} onChange={setField('clientName')} placeholder="e.g. Bolchoz Sign Solutions" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Project Name</label>
|
||||
<input type="text" value={surveyInfo.projectName} onChange={setField('projectName')} placeholder="e.g. Main Street Sign Survey" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Survey Date</label>
|
||||
<input type="date" value={surveyInfo.surveyDate} onChange={setField('surveyDate')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Prepared By</label>
|
||||
<input type="text" value={surveyInfo.preparedBy} onChange={setField('preparedBy')} placeholder="Your name" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Site Address</label>
|
||||
<input type="text" value={surveyInfo.siteAddress} onChange={setField('siteAddress')} placeholder="e.g. 123 Main St, City, State" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>Signs</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={addSign}>+ Add Sign</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
{signs.map((sign, index) => (
|
||||
<SignCard
|
||||
key={sign.id}
|
||||
sign={sign}
|
||||
index={index}
|
||||
onChange={updateSign}
|
||||
onPhoto={handlePhoto}
|
||||
onRemove={removeSign}
|
||||
canRemove={signs.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function SignCard({ sign, index, onChange, onPhoto, onRemove, canRemove }) {
|
||||
const mainInputRef = useRef(null);
|
||||
const contextInputRef1 = useRef(null);
|
||||
const contextInputRef2 = useRef(null);
|
||||
const contextInputRef3 = useRef(null);
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, display: 'grid', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{sign.signName || `Sign ${index + 1}`}
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => onRemove(sign.id)}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Sign Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sign.signName}
|
||||
onChange={(event) => onChange(sign.id, 'signName', event.target.value)}
|
||||
placeholder={`e.g. Sign ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Main Photo</label>
|
||||
<PhotoPicker
|
||||
inputRef={mainInputRef}
|
||||
preview={sign.mainPreview}
|
||||
label="Click to upload main sign photo"
|
||||
onPick={(file) => onPhoto(sign.id, 'mainPhoto', 'mainPreview', file)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Context Photos</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||
<PhotoPicker
|
||||
inputRef={contextInputRef1}
|
||||
preview={sign.contextPreview1}
|
||||
label="Context photo 1"
|
||||
small
|
||||
onPick={(file) => onPhoto(sign.id, 'contextPhoto1', 'contextPreview1', file)}
|
||||
/>
|
||||
<PhotoPicker
|
||||
inputRef={contextInputRef2}
|
||||
preview={sign.contextPreview2}
|
||||
label="Context photo 2"
|
||||
small
|
||||
onPick={(file) => onPhoto(sign.id, 'contextPhoto2', 'contextPreview2', file)}
|
||||
/>
|
||||
<PhotoPicker
|
||||
inputRef={contextInputRef3}
|
||||
preview={sign.contextPreview3}
|
||||
label="Context photo 3"
|
||||
small
|
||||
onPick={(file) => onPhoto(sign.id, 'contextPhoto3', 'contextPreview3', file)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Measurements / Info</label>
|
||||
<textarea
|
||||
value={sign.measurements}
|
||||
onChange={(event) => onChange(sign.id, 'measurements', event.target.value)}
|
||||
placeholder={'e.g. 48" W x 24" H\nAluminum panel\nMounted to brick facade'}
|
||||
style={{ minHeight: 110 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Notes</label>
|
||||
<textarea
|
||||
value={sign.notes}
|
||||
onChange={(event) => onChange(sign.id, 'notes', event.target.value)}
|
||||
placeholder="Additional survey notes..."
|
||||
style={{ minHeight: 110 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
|
||||
const dragCounter = useRef(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const handleDragEnter = (event) => {
|
||||
event.preventDefault();
|
||||
dragCounter.current += 1;
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event) => {
|
||||
event.preventDefault();
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current === 0) setDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
event.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setDragging(false);
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (isPhotoFile(file)) onPick(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
minHeight: small ? 110 : 120,
|
||||
cursor: 'pointer',
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--card-bg-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
padding: 10,
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{preview ? (
|
||||
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 6 }} />
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
{dragging ? 'Drop photo here' : label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={PHOTO_FILE_ACCEPT}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (isPhotoFile(file)) onPick(file);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+629
-130
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user