c91e292066
Design system (Layout2): - Solid token-driven theme (no animated grid); single-source tokens for bg, cards (bg/border/radius), popups, fonts (Inter), and full semantic + data-viz color palette. Swept ~168 hardcoded colors to tokens. Dashboard: - Reflow: To Do + Calendar row (fixed right rail) over Activity / In-Progress / Team Performance; CSS height-match (To Do = Calendar); responsive stacking. - 'Tasks Not Started' -> 'To Do' card with R#/Assigned columns; compact money fmt. Tasks page: - Combined tabs into Tasks + Completed with a Status column. - Column-header sort + filter (Status, R#/new-vs-revision, Project, Task Type, Company) via portaled, scrollable FilterDropdown; removed toolbar filters and the projects side panel; headers stay visible when empty; renamed to 'Tasks'. Perf: - FK/filter index migration; removed redundant client/external scope waterfall (rely on RLS); parallel follow-up queries. Mobile: - Responsive grids; mobile topbar = hamburger only, blends with bg, proper offset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 lines
4.8 KiB
React
129 lines
4.8 KiB
React
import { useId, useRef, useState } from 'react';
|
|
|
|
const MAX_FILES = 20;
|
|
const MAX_SIZE_MB = 250;
|
|
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
|
|
|
|
const formatSize = (bytes) => {
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
};
|
|
|
|
export default function FileAttachment({ files, onChange }) {
|
|
const inputId = useId();
|
|
const [errors, setErrors] = useState([]);
|
|
const [dragging, setDragging] = useState(false);
|
|
const dragCounter = useRef(0);
|
|
|
|
const processFiles = (incoming) => {
|
|
const combined = [...files, ...incoming];
|
|
const errs = [];
|
|
if (combined.length > MAX_FILES) errs.push(`Maximum ${MAX_FILES} files allowed.`);
|
|
incoming.filter(f => f.size > MAX_SIZE_BYTES).forEach(f => errs.push(`"${f.name}" exceeds ${MAX_SIZE_MB} MB limit.`));
|
|
if (errs.length > 0) { setErrors(errs); return; }
|
|
setErrors([]);
|
|
onChange(combined);
|
|
};
|
|
|
|
const handleChange = (e) => {
|
|
processFiles(Array.from(e.target.files));
|
|
e.target.value = '';
|
|
};
|
|
|
|
const handleDragEnter = (e) => {
|
|
e.preventDefault();
|
|
dragCounter.current++;
|
|
setDragging(true);
|
|
};
|
|
|
|
const handleDragLeave = (e) => {
|
|
e.preventDefault();
|
|
dragCounter.current--;
|
|
if (dragCounter.current === 0) setDragging(false);
|
|
};
|
|
|
|
const handleDragOver = (e) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleDrop = (e) => {
|
|
e.preventDefault();
|
|
dragCounter.current = 0;
|
|
setDragging(false);
|
|
const dropped = Array.from(e.dataTransfer.files);
|
|
if (dropped.length > 0) processFiles(dropped);
|
|
};
|
|
|
|
const remove = (index) => {
|
|
setErrors([]);
|
|
onChange(files.filter((_, i) => i !== index));
|
|
};
|
|
|
|
return (
|
|
<div className="form-group">
|
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>
|
|
Attach Files
|
|
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6, textTransform: 'none', letterSpacing: 0 }}>
|
|
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each · Any file type
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
style={{
|
|
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
|
borderRadius: 8, padding: '16px', textAlign: 'center',
|
|
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent',
|
|
transition: 'all 160ms', cursor: 'pointer',
|
|
}}
|
|
>
|
|
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id={inputId} />
|
|
<label htmlFor={inputId} style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
|
|
<div style={{ fontSize: 20, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
|
{dragging
|
|
? 'Drop files here'
|
|
: files.length > 0
|
|
? `${files.length} file${files.length !== 1 ? 's' : ''} attached — click or drag to add more`
|
|
: 'Click or drag files here'}
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
|
|
</label>
|
|
</div>
|
|
|
|
{errors.length > 0 && errors.map((err, i) => (
|
|
<div key={i} style={{ fontSize: 12, color: 'var(--danger)', marginTop: 6 }}>⚠ {err}</div>
|
|
))}
|
|
|
|
{files.length > 0 && (
|
|
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{files.map((file, i) => (
|
|
<div key={i} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-border)',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span>📄</span>
|
|
<div>
|
|
<div style={{ fontSize: 13, fontWeight: 400 }}>{file.name}</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={() => remove(i)}
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: '0 4px' }}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', textAlign: 'right' }}>
|
|
{files.length}/{MAX_FILES} files
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|