diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
new file mode 100644
index 0000000..509119b
--- /dev/null
+++ b/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+ "name": "local-plugins",
+ "interface": {
+ "displayName": "Local Plugins"
+ },
+ "plugins": [
+ {
+ "name": "caveman",
+ "source": {
+ "source": "local",
+ "path": "./.codex-plugins/caveman"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..f58066a
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,13 @@
+{
+ "extraKnownMarketplaces": {
+ "ecc": {
+ "source": {
+ "source": "github",
+ "repo": "affaan-m/ecc"
+ }
+ }
+ },
+ "enabledPlugins": {
+ "ecc@ecc": true
+ }
+}
diff --git a/.codex-plugins/caveman/.codex-plugin/plugin.json b/.codex-plugins/caveman/.codex-plugin/plugin.json
new file mode 100644
index 0000000..7822753
--- /dev/null
+++ b/.codex-plugins/caveman/.codex-plugin/plugin.json
@@ -0,0 +1,39 @@
+{
+ "name": "caveman",
+ "version": "0.1.0",
+ "description": "Ultra-compressed communication mode. Cut filler. Keep technical accuracy.",
+ "author": {
+ "name": "Julius Brussee",
+ "url": "https://github.com/JuliusBrussee"
+ },
+ "homepage": "https://github.com/JuliusBrussee/caveman",
+ "repository": "https://github.com/JuliusBrussee/caveman",
+ "license": "MIT",
+ "keywords": [
+ "productivity",
+ "communication",
+ "brevity",
+ "writing"
+ ],
+ "skills": "./skills/",
+ "interface": {
+ "displayName": "Caveman",
+ "shortDescription": "Talk like caveman. Cut filler. Keep technical accuracy.",
+ "longDescription": "Ultra-compressed communication mode for Codex. Use fewer words. Keep exact technical substance.",
+ "developerName": "Julius Brussee",
+ "category": "Productivity",
+ "capabilities": [
+ "Write"
+ ],
+ "websiteURL": "https://github.com/JuliusBrussee/caveman",
+ "privacyPolicyURL": "https://github.com/JuliusBrussee/caveman/blob/main/README.md",
+ "termsOfServiceURL": "https://github.com/JuliusBrussee/caveman/blob/main/LICENSE",
+ "defaultPrompt": [
+ "Use caveman mode. Cut filler. Keep technical accuracy."
+ ],
+ "composerIcon": "./assets/caveman-small.svg",
+ "logo": "./assets/caveman.svg",
+ "screenshots": [],
+ "brandColor": "#6B7280"
+ }
+}
diff --git a/.codex-plugins/caveman/skills/caveman/SKILL.md b/.codex-plugins/caveman/skills/caveman/SKILL.md
new file mode 100644
index 0000000..3be1ab4
--- /dev/null
+++ b/.codex-plugins/caveman/skills/caveman/SKILL.md
@@ -0,0 +1,63 @@
+---
+name: caveman
+description: >
+ Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
+ while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
+ wenyan-lite, wenyan-full, wenyan-ultra.
+ Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
+ "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
+---
+
+Respond terse like smart caveman. All technical substance stay. Only fluff die.
+
+Default: **full**. Switch: `/caveman lite|full|ultra`.
+
+## Rules
+
+Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
+
+Pattern: `[thing] [action] [reason]. [next step].`
+
+Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
+Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
+
+## Intensity
+
+| Level | What change |
+|-------|------------|
+| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
+| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
+| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
+| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
+| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
+| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
+
+Example — "Why React component re-render?"
+- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
+- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
+- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
+- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
+- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
+- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
+
+Example — "Explain database connection pooling."
+- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
+- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
+- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
+- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
+- wenyan-ultra: "池reuse conn。skip handshake → fast。"
+
+## Auto-Clarity
+
+Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user confused. Resume caveman after clear part done.
+
+Example — destructive op:
+> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
+> ```sql
+> DROP TABLE users;
+> ```
+> Caveman resume. Verify backup exist first.
+
+## Boundaries
+
+Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
diff --git a/.env.backfill b/.env.backfill
new file mode 100644
index 0000000..1229cdb
--- /dev/null
+++ b/.env.backfill
@@ -0,0 +1,35 @@
+# Created by Vercel CLI
+FILEBROWSER_ADMIN_PASS="ChloeH092524#"
+FILEBROWSER_ADMIN_USER="admin"
+FILEBROWSER_SUBS_ROOT="/fourgebranding/team"
+FILEBROWSER_TEAM_ROOT="/fourgebranding"
+FILEBROWSER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjo2NTc1NDk4NDMzLCJpYXQiOjE3NzkwNzAxMjYsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOnRydWUsImRlbGV0ZSI6dHJ1ZSwiY3JlYXRlIjp0cnVlLCJkb3dubG9hZCI6dHJ1ZX19.sQzImZQMlvbKpDWdnN9ksehmkNG8wy6SpnjgZ1uFC2c"
+FILEBROWSER_URL="https://fourgebranding.krao.us"
+NX_DAEMON="false"
+PASSWORD_VAULT_KEY=""
+SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZxZmx4eHF2ZW5uaHZvZXl3cmR3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NDA0NzEzNCwiZXhwIjoyMDg5NjIzMTM0fQ.OPI-XtZDI0x83Lu5HaUl-YZx2EFAjFtHDivKx1_DXxA"
+SUPABASE_WEBHOOK_SECRET="c76769359f53e7fb920776dd895f38b243cfdab4e2f07740"
+TURBO_CACHE="remote:rw"
+TURBO_DOWNLOAD_LOCAL_ENABLED="true"
+TURBO_REMOTE_ONLY="true"
+TURBO_RUN_SUMMARY="true"
+VERCEL="1"
+VERCEL_ENV="production"
+VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
+VERCEL_GIT_COMMIT_AUTHOR_NAME=""
+VERCEL_GIT_COMMIT_MESSAGE=""
+VERCEL_GIT_COMMIT_REF=""
+VERCEL_GIT_COMMIT_SHA=""
+VERCEL_GIT_PREVIOUS_SHA=""
+VERCEL_GIT_PROVIDER=""
+VERCEL_GIT_PULL_REQUEST_ID=""
+VERCEL_GIT_REPO_ID=""
+VERCEL_GIT_REPO_OWNER=""
+VERCEL_GIT_REPO_SLUG=""
+VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9rcmFvIiwic3ViIjoib3duZXI6a3Jhbzpwcm9qZWN0OmZvdXJnZS1wb3J0YWw6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJzY29wZSI6Im93bmVyOmtyYW86cHJvamVjdDpmb3VyZ2UtcG9ydGFsOmVudmlyb25tZW50OmRldmVsb3BtZW50IiwiYXVkIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tyYW8iLCJvd25lciI6ImtyYW8iLCJvd25lcl9pZCI6InRlYW1fR01WaUJ3N2xWYVpjVG9ST21RelFaeExyIiwicHJvamVjdCI6ImZvdXJnZS1wb3J0YWwiLCJwcm9qZWN0X2lkIjoicHJqX0RtUjdoZXhHdWoydTFiaHUzUlRpaGNVQWFwb0giLCJlbnZpcm9ubWVudCI6ImRldmVsb3BtZW50IiwicGxhbiI6ImhvYmJ5IiwidXNlcl9pZCI6InR3Q3dFWkI2MHZFelpma1pjWnREQ0VFbSIsImNsaWVudF9pZCI6ImNsX0hZeU9QQk50Rk1mSGhhVW45TDRRUGZUWno2VFA0N2JwIiwibmJmIjoxNzc5MDc0MTYyLCJpYXQiOjE3NzkwNzQxNjIsImV4cCI6MTc3OTExNzM2Mn0.Exb_7yIDR4iFQUEjRm2EpGRNHYug3Ixb7kcrga2Wj4DMsVY9Cm7AnC7wwe87e5SkA_qaKzVw6jR0w_obBAXPcZYQdnGhE4uAwz5LX4fVDTys-jBAzCp6AMNLmjnCjFBMfsB1UkO7g7OX7z9rhkvhLa8HVrt47Ulg5f5BN6E571ob23hkJJFP9fz5NzSzq-jXb-cyXfkcjQAWdU0Lw-NpuaoeUNr-VZhIc17rfQ6gwT-57UWi47Yikvn4bElVHAYT_UVtqPcph3LN9UNPRCtOwh0VQxzi-RiamW3maEmCdxsDv88DwY9Xj_jn79I_MIcmOUBvQGLjArkj-cxvFEwgqg"
+VERCEL_TARGET_ENV="production"
+VERCEL_TOKEN=""
+VERCEL_URL=""
+VITE_MAPBOX_TOKEN="pk.eyJ1Ijoia3Jhb2ZvdXJnZSIsImEiOiJjbW9hdHlyM2EwYW84MnBwemx1ZjRqYzY2In0.afcwBOBqUBnJqn9zIvZShQ"
+VITE_SUPABASE_ANON_KEY="sb_publishable_qNNIKtnu1dUIVKelq9aYYQ_TfHgzhyR"
+VITE_SUPABASE_URL="https://fqflxxqvennhvoeywrdw.supabase.co"
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..0a94065
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,109 @@
+# Fourge Portal — Claude Instructions
+
+## Project
+React + Vite SPA. Supabase backend. Deployed on Vercel. Local dev on port 5173 (or next available). Remote: `gitea` remote at `gitea.krao.us`.
+
+## Dev workflow
+- Dev server: `npm run dev` (Vite, HMR active — no restart needed for code changes)
+- Deploy: `vercel --prod` after every change, no need to ask
+- Push: `git push gitea main`
+
+## Design system — follow layout.md as single source of truth
+
+### Card shell (all new widgets)
+```
+background: var(--card-bg)
+border: 1px solid var(--border)
+border-radius: 8px
+padding: 18px 21px
+backdrop-filter: blur(12px)
+-webkit-backdrop-filter: blur(12px)
+```
+
+### Tokens
+- Accent: `#F5A523`
+- Card bg: `var(--card-bg)` = `rgba(255,255,255,0.02)` dark / `rgba(0,0,0,0.02)` light
+- Secondary card: `var(--card-bg-2)`
+- Border: `var(--border)`
+- Text: `var(--text-primary)`, `var(--text-secondary)`, `var(--text-muted)`
+
+### Widget title (card section headers)
+```
+font-size: 11px
+font-weight: 500
+text-transform: uppercase
+letter-spacing: 0.8px
+color: var(--text-secondary)
+margin-bottom: 14px
+```
+
+### Tables
+- `table-layout: fixed`, `border-collapse: collapse`
+- Headers: `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`, `padding-bottom: 12px`, `border: none`, `background: transparent`
+- Use `SortTh` component for all sortable columns — every data column must be sortable
+- Body cells: primary `13px`, secondary `12px`, `padding: 5px 0`, `border: none`, `background: transparent`
+- Scrollable tables: container gets `className="scrollbar-thin-theme table-scroll-hidden"`, table gets `className="table-sticky-head"`
+- Do NOT add `background: transparent` inline on TD — it blocks the `tr:hover td` CSS rule
+
+### Row hover
+- Standard table rows: `tr:hover td { background: rgba(255,255,255,0.02) }` via global CSS — do not add inline bg to TD
+- Clickable text inside rows: use `className="table-link"` — turns gold on hover, no full-row fill
+- Do NOT use whole-row click + cursor pointer as the primary interaction pattern for new tables
+
+### Section control bar (tabs + actions)
+```
+display: flex, align-items: center, gap: 4, margin-bottom: 10, flex-shrink: 0
+```
+- Tabs on the left
+- Action buttons on the right via `margin-left: auto` — do NOT use hardcoded spacer divs for alignment
+- Reference: Tasks.jsx tabs + `+ Task` button pattern
+
+### Buttons
+- All buttons use `.btn` class with CSS vars (`--btn-height: 22px`, `--btn-radius: 8px`, etc.)
+- Do not hardcode button geometry inline — use `btn btn-outline`, `btn btn-primary`, `btn btn-danger`
+- Height controlled by `--h-control` = `--btn-height`
+
+### Modal forms
+- Surface: `background: var(--card-bg)`, use `popupSurfaceStyle` + `popupOverlayStyle` from `../../lib/popupStyles`
+- Field labels: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, `color: var(--text-secondary)`
+- Inputs: `font-size: 13px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)`, `border-radius: 6px`, `padding: 6px 10px`
+- Form gap: `14px` between fields
+- Action buttons: right-aligned, `btn btn-outline`, gap `8px`
+- Header: single `11px` uppercase secondary label — no large heading
+
+### Status badges
+- Use `` — do not hand-roll status tags
+- Badge height: `20px`, `border-radius: 4px`, `font-size: 10px`
+
+### Spacing rhythm
+- Page/grid/section gaps: `24px`
+- Tabs-to-card gap: `margin-bottom: 10` on tabs bar
+
+### Height-filling layouts
+For pages where cards should fill the window:
+```jsx
+
+
+ {/* fixed sections: flexShrink: 0 */}
+ {/* filling section: flex: 1, minHeight: 0 */}
+
+
+```
+Cards that scroll internally: `display: flex, flexDirection: column, minHeight: 0` + inner `overflowY: auto, flex: 1, minHeight: 0`
+
+### File browser paths (per role)
+- `team`: `/Clients/{Co}/Projects/{Proj}/{Task}` (real path)
+- `client`: `/{Co}/Projects/{Proj}/{Task}` (virtual — no `/Clients/` prefix)
+- `external`: `/Projects/{Proj}/{Task}` (virtual)
+
+## Empty states
+Use `.card-empty-center` class for all "No …" messages inside cards.
+
+## Key files
+- `layout.md` — visual spec, always check before building new UI
+- `src/index.css` — global tokens and shared classes
+- `src/components/SortTh.jsx` — sortable table header
+- `src/components/StatusBadge.jsx` — status tags
+- `src/lib/popupStyles.js` — modal overlay/surface styles
+- `api/filebrowser.js` — file browser API (Vercel serverless)
+- `supabase/functions/fbq-proxy/` — Supabase edge function for FileBrowser Quantum
diff --git a/src/index.css b/src/index.css
index db0c01b..6edcaf8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1138,6 +1138,9 @@ th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 400; te
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
tr:hover td { background: rgba(255,255,255,0.02); }
+.table-no-row-hover tr:hover td { background: transparent !important; }
+[data-theme="light"] .table-no-row-hover tr:hover td { background: transparent !important; }
+.table-no-row-hover tbody tr:hover .table-link:not(:hover) { color: inherit !important; }
.table-link {
color: inherit;
text-decoration: none;
diff --git a/src/pages/TaskDetail.jsx b/src/pages/TaskDetail.jsx
index 4f10029..d7233d6 100644
--- a/src/pages/TaskDetail.jsx
+++ b/src/pages/TaskDetail.jsx
@@ -229,6 +229,7 @@ export default function TaskDetail() {
const [comments, setComments] = useState([]);
const [commentBody, setCommentBody] = useState('');
const [commentSaving, setCommentSaving] = useState(false);
+ const [deliveries, setDeliveries] = useState([]);
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
useEffect(() => {
@@ -240,7 +241,7 @@ export default function TaskDetail() {
.single(),
supabase
.from('submissions')
- .select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
+ .select('id, type, version_number, request_key, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
.eq('task_id', id)
.order('submitted_at', { ascending: true }),
supabase
@@ -254,11 +255,20 @@ export default function TaskDetail() {
.select('id, author_id, author_name, body, created_at')
.eq('task_id', id)
.order('created_at', { ascending: true }),
- ]).then(([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
+ ]).then(async ([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
setComments(commentData || []);
+ const subIds = (subRows || []).map(s => s.id);
+ if (subIds.length > 0) {
+ const { data: delRows } = await supabase
+ .from('deliveries')
+ .select('*, files:delivery_files(id, name, storage_path, size)')
+ .in('submission_id', subIds)
+ .order('sent_at', { ascending: false });
+ setDeliveries(delRows || []);
+ }
setLoading(false);
});
}, [id, refreshKey]);
@@ -269,7 +279,7 @@ export default function TaskDetail() {
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
const amendments = submissions.filter(s => s.type === 'amendment');
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
- const reviewSubmissions = submissions.filter(s => s.type === 'initial').sort((a, b) => new Date(b.submitted_at) - new Date(a.submitted_at));
+ const reviewSubmissions = deliveries;
const assignee = task.assignee;
const assigneeName = assignee?.name || task.assigned_name || null;
@@ -304,19 +314,20 @@ export default function TaskDetail() {
const handleConfirmReview = async () => {
setReviewSaving(true);
try {
- const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, type: 'initial', version_number: task.current_version || 0, service_type: task.title, description: reviewNotes.trim() || null, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
- if (newSub && reviewFiles.length > 0) {
+ const briefId = submissions.find(s => s.type === 'initial')?.id || submissions[0]?.id;
+ const { data: newDel } = await supabase.from('deliveries').insert({ submission_id: briefId, sent_by: currentUser.name, message: reviewNotes.trim() || '', version_number: task.current_version || 0 }).select().single();
+ if (newDel && reviewFiles.length > 0) {
const uploaded = [];
for (const file of reviewFiles) {
const path = `${id}/${Date.now()}_${file.name}`;
- const { data: up } = await supabase.storage.from('submissions').upload(path, file);
- if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, submission_id: newSub.id });
+ const { data: up } = await supabase.storage.from('deliveries').upload(path, file);
+ if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size, delivery_id: newDel.id });
}
- if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded);
- if (project?.name && task?.title) uploadFilesToRequestInfo(reviewFiles, company?.name, project.name, task.title, task.current_version || 0).catch(() => {});
+ if (uploaded.length > 0) await supabase.from('delivery_files').insert(uploaded);
}
- const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
- setSubmissions(subRows || []);
+ const subIds = submissions.map(s => s.id);
+ const { data: delRows } = await supabase.from('deliveries').select('*, files:delivery_files(id, name, storage_path, size)').in('submission_id', subIds).order('sent_at', { ascending: false });
+ setDeliveries(delRows || []);
await updateStatus('client_review', {}, 'task_submitted');
setReviewModal(false);
setReviewFiles([]);
@@ -655,25 +666,29 @@ export default function TaskDetail() {
?
No submissions yet.
:
{reviewSubmissions.map((sub, i, arr) => {
- const fmtDate = sub.submitted_at
- ? new Date(sub.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ const fmtDate = sub.sent_at
+ ? new Date(sub.sent_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
Submitted By
-
{sub.submitted_by_name || '—'}
+
{sub.sent_by || '—'}
+
+
Version
+
{`R${String(sub.version_number ?? 0).padStart(2, '0')}`}
+
- {sub.description
+ {sub.message
?
Notes
-
{sub.description}
+
{sub.message}
:
No notes provided.
}
@@ -817,8 +832,8 @@ export default function TaskDetail() {
)}
{revisionModal && (
-
-
+
{ if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
+
e.stopPropagation()}>
Request Revision
diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx
index 057fcc1..605e2a9 100644
--- a/src/pages/Tasks.jsx
+++ b/src/pages/Tasks.jsx
@@ -521,7 +521,7 @@ export default function RequestsPage() {
return (
|
- {`R${String(row.version).padStart(2, '0')}`}
+ {`R${String(row.version).padStart(2, '0')}`}
|
{row.title || '—'}
@@ -541,14 +541,14 @@ export default function RequestsPage() {
return ;
})()}
|
-
- {row.isHot ? 'HOT' : 'NO'}
+ |
+ {row.isHot ? 'HOT' : 'NO'}
|
- {row.serviceType}
+ {row.serviceType}
|
- {fmtShortDate(row.deadline, 'Not specified')}
+ {fmtShortDate(row.deadline, 'Not specified')}
|
);
@@ -697,7 +697,7 @@ export default function RequestsPage() {
No tasks.
) : (
-
+
diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx
index 81f8594..28c560c 100644
--- a/src/pages/team/TeamInvoices.jsx
+++ b/src/pages/team/TeamInvoices.jsx
@@ -8,9 +8,12 @@ import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { readPageCache, writePageCache } from '../../lib/pageCache';
-import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
+import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
+import { withTimeout } from '../../lib/withTimeout';
+import LoadingButton from '../../components/LoadingButton';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
+import FileAttachment from '../../components/FileAttachment';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -38,6 +41,11 @@ const RECEIPT_BUCKET = 'expense-receipts';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
+const INV_TODAY = new Date().toISOString().split('T')[0];
+const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
+const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
+ ({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
+
const blankExpense = () => ({
date: new Date().toISOString().slice(0, 10),
description: '',
@@ -120,6 +128,116 @@ export default function Invoices() {
const [markingPaid, setMarkingPaid] = useState('');
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
+ // ── New Invoice form ──────────────────────────────────────────────────────
+ const [showInvoiceForm, setShowInvoiceForm] = useState(false);
+ const [invCompanies, setInvCompanies] = useState([]);
+ const [invCompanyId, setInvCompanyId] = useState('');
+ const [invBillTo, setInvBillTo] = useState('');
+ const [invEmail, setInvEmail] = useState('');
+ const [invRecipients, setInvRecipients] = useState([]);
+ const [invUnbilledTasks, setInvUnbilledTasks] = useState([]);
+ const [invUnbilledRevisions, setInvUnbilledRevisions] = useState([]);
+ const [invPriceList, setInvPriceList] = useState([]);
+ const [invItems, setInvItems] = useState([invNewItem()]);
+ const [invNotes, setInvNotes] = useState('');
+ const [invSaving, setInvSaving] = useState(false);
+ const [invLoadingTasks, setInvLoadingTasks] = useState(false);
+ const invDragItem = useRef(null);
+
+ useEffect(() => {
+ supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
+ }, []);
+
+ useEffect(() => {
+ if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
+ const co = invCompanies.find(c => c.id === invCompanyId);
+ setInvBillTo(co?.name || '');
+ setInvLoadingTasks(true);
+ Promise.all([
+ supabase.from('projects').select('id').eq('company_id', invCompanyId),
+ supabase.from('company_prices').select('*').eq('company_id', invCompanyId),
+ supabase.from('profiles').select('id, name, email, role').eq('company_id', invCompanyId).in('role', ['client', 'external']).order('name'),
+ ]).then(async ([{ data: projects }, { data: prices }, { data: users }]) => {
+ setInvPriceList(prices || []);
+ setInvRecipients(users || []);
+ setInvEmail((users || [])[0]?.email || co?.contact_email || '');
+ if (projects?.length > 0) {
+ const pids = projects.map(p => p.id);
+ const { data: tasks } = await supabase.from('tasks').select('*, project:projects(name), submissions(service_type, type, version_number)').in('project_id', pids).eq('invoiced', false).eq('status', 'client_approved');
+ const tids = (tasks || []).map(t => t.id);
+ const { data: revisions } = tids.length > 0 ? await supabase.from('submissions').select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))').eq('type', 'revision').or('revision_type.eq.client_revision,revision_type.is.null').eq('invoiced', false).in('task_id', tids) : { data: [] };
+ setInvUnbilledTasks((tasks || []).map(t => { const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0]; return { ...t, service_type: ini?.service_type || t.title }; }));
+ const revMap = new Map();
+ for (const r of (revisions || [])) { const k = `${r.task_id}:${r.version_number}`; if (!revMap.has(k)) revMap.set(k, r); }
+ setInvUnbilledRevisions([...revMap.values()]);
+ } else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); }
+ setInvLoadingTasks(false);
+ });
+ }, [invCompanyId, invCompanies]);
+
+ const invAddTask = (task) => {
+ const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
+ const desc = `${task.project?.name || 'No Project'} • ${task.title || task.service_type || 'Untitled'}`;
+ setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]);
+ };
+ const invAddRevision = (rev) => {
+ const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
+ const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision';
+ const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
+ const version = Number(rev.version_number || 0);
+ const qty = version >= 2 ? 1 : 1;
+ const unitPrice = version >= 2 ? (price?.price || '') : 0;
+ const vLabel = 'R' + String(version).padStart(2, '0');
+ const desc = `${rev.task?.project?.name || 'No Project'} • ${rev.task?.title || 'Revision'} • Revision ${vLabel}`;
+ setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]);
+ };
+ const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it));
+ const invRemoveItem = (id) => setInvItems(prev => prev.filter(it => it.id !== id));
+ const invHandleDrop = (targetIdx) => {
+ if (invDragItem.current === null || invDragItem.current === targetIdx) { invDragItem.current = null; return; }
+ setInvItems(prev => { const next = [...prev]; const [moved] = next.splice(invDragItem.current, 1); next.splice(targetIdx, 0, moved); return next; });
+ invDragItem.current = null;
+ };
+ const invTotal = invItems.reduce((s, it) => s + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0), 0);
+ const invSelectedCompany = invCompanies.find(c => c.id === invCompanyId);
+
+ const invClose = () => { setShowInvoiceForm(false); setInvCompanyId(''); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvNotes(''); };
+
+ const invHandleSave = async (status) => {
+ if (!invCompanyId) return alert('Select a company.');
+ if (invItems.every(i => !i.description)) return alert('Add at least one line item.');
+ if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.');
+ setInvSaving(true);
+ try {
+ 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 { data: invoice, error } = await supabase.from('invoices').insert({ company_id: invCompanyId, invoice_number: invoiceNumber, invoice_date: INV_TODAY, due_date: INV_NET30, status: status === 'sent' ? 'draft' : status, bill_to: invBillTo || null, invoice_email: invEmail.trim() || null, notes: invNotes || null, total: invTotal, created_by: currentUser?.id }).select().single();
+ if (error || !invoice) throw error || new Error('Invoice not created.');
+ const validItems = invItems.filter(i => i.description);
+ if (validItems.length > 0) await supabase.from('invoice_items').insert(validItems.map(it => ({ invoice_id: invoice.id, task_id: it.task_id || null, submission_id: it.submission_id || null, description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 })));
+ const taskIds = [...new Set(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, status: 'invoiced' }).in('id', taskIds);
+ const subIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
+ if (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds);
+ if (status === 'sent') {
+ try {
+ const dueDate = new Date(INV_NET30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
+ const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
+ let attachments = [];
+ try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch {}
+ await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
+ await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
+ } catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
+ }
+ setInvoices(prev => [{ ...invoice, company: { name: invSelectedCompany?.name || '' } }, ...prev]);
+ invClose();
+ navigate(`/invoices/${invoice.id}`);
+ } catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
+ setInvSaving(false);
+ };
+
useEffect(() => {
async function load() {
const { data } = await supabase
@@ -199,22 +317,6 @@ export default function Invoices() {
loadSubInvoices();
}, []);
- 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('');
- setShowExpenseForm(true);
- };
const cancelExpenseEdit = () => {
setEditingExpenseId('');
@@ -621,7 +723,7 @@ export default function Invoices() {
)}
{financeTab === 'invoices' && (
-
+
)}
{activeTab === 'expenses' && (
@@ -1284,7 +1386,7 @@ export default function Invoices() {
if (key === 'date') return exp.date || '';
return exp[key] || '';
}).map(exp => (
-
startEditExpense(exp)}>
+
setViewingExpense(exp)}>
| {new Date(exp.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} |
{exp.description} |
{exp.category} |
@@ -1390,142 +1492,270 @@ export default function Invoices() {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
+ const isDirty = expenseDetailEditing && (
+ newExpense.amount !== String(viewingExpense.amount ?? '') ||
+ newExpense.description !== (viewingExpense.description || '') ||
+ newExpense.date !== (viewingExpense.date || '') ||
+ newExpense.category !== (viewingExpense.category || 'Other') ||
+ newExpense.notes !== (viewingExpense.notes || '') ||
+ newExpense.receipt != null ||
+ newExpense.removeReceipt === true
+ );
return (
- { if (!expenseDetailEditing) setViewingExpense(null); }}>
-
e.stopPropagation()}>
- {/* Left: details / edit */}
-
-
Expense Detail
- {expenseDetailEditing ? (
-
setNewExpense(p => ({ ...p, amount: e.target.value }))} />
- ) : (
-
- ${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
- )}
-
-
Description
- {expenseDetailEditing
- ?
setNewExpense(p => ({ ...p, description: e.target.value }))} />
- :
{viewingExpense.description || '—'}
}
-
-
-
Date
- {expenseDetailEditing
- ?
setNewExpense(p => ({ ...p, date: e.target.value }))} />
- :
{viewingExpense.date ? new Date(viewingExpense.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '—'}
}
-
-
-
Category
- {expenseDetailEditing
- ?
- :
{viewingExpense.category || '—'}
}
-
-
-
Notes
- {expenseDetailEditing
- ?
setNewExpense(p => ({ ...p, notes: e.target.value }))} />
- :
{viewingExpense.notes || '—'}
}
-
- {expenseDetailEditing && (
+
{ setViewingExpense(null); setExpenseDetailEditing(false); }}>
+
e.stopPropagation()}>
+ {/* Main content row */}
+
+ {/* Left: details (editable in-place) */}
+
+
Expense Detail
+ {expenseDetailEditing ? (
+
setNewExpense(p => ({ ...p, amount: e.target.value }))} />
+ ) : (
+
+ ${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+ )}
-
Receipt / Photo
-
setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} />
- {!newExpense.receipt && newExpense.receipt_path && (
-
- {newExpense.receipt_name || 'Existing receipt'}
- setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove
-
+
Description
+ {expenseDetailEditing
+ ?
setNewExpense(p => ({ ...p, description: e.target.value }))} />
+ :
{viewingExpense.description || '—'}
}
+
+
+
Date
+ {expenseDetailEditing
+ ?
setNewExpense(p => ({ ...p, date: e.target.value }))} />
+ :
{viewingExpense.date ? new Date(viewingExpense.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '—'}
}
+
+
+
Category
+ {expenseDetailEditing
+ ?
+ :
{viewingExpense.category || '—'}
}
+
+
+
Notes
+ {expenseDetailEditing
+ ?
setNewExpense(p => ({ ...p, notes: e.target.value }))} />
+ :
{viewingExpense.notes || '—'}
}
+
+ {expenseDetailEditing && (
+
+
Receipt / Photo
+
setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} />
+ {!newExpense.receipt && newExpense.receipt_path && (
+
+ {newExpense.receipt_name || 'Existing receipt'}
+ setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove
+
+ )}
+
+ )}
+
+ {/* Right: receipt preview — fixed to left column height, item scales inside */}
+ {viewingExpense.receipt_path && (
+
+
Receipt
+ {expensePreviewUrl ? (
+ isPdf
+ ?
+ :

+ ) : (
+
Loading preview…
)}
)}
-
- {expenseDetailEditing ? (
- <>
- { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}
- setExpenseDetailEditing(false)}>Cancel
- >
- ) : (
- <>
- { setEditingExpenseId(viewingExpense.id); setNewExpense({ date: viewingExpense.date, description: viewingExpense.description || '', category: viewingExpense.category || 'Other', amount: String(viewingExpense.amount ?? ''), notes: viewingExpense.notes || '', receipt: null, receipt_path: viewingExpense.receipt_path || null, receipt_name: viewingExpense.receipt_name || null }); setExpensesError(''); setExpenseDetailEditing(true); }}>Edit
- setViewingExpense(null)}>Close
- >
- )}
-
- {/* Right: receipt preview — same height as left column */}
- {viewingExpense.receipt_path && (
-
-
Receipt
- {expensePreviewUrl ? (
- isPdf
- ?
- :

- ) : (
-
Loading preview…
- )}
- {expensePreviewUrl && (
-
Download Receipt
- )}
-
- )}
+ {/* Footer: buttons bottom-right */}
+
+ {expenseDetailEditing ? (
+ <>
+
{ await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}
+
setExpenseDetailEditing(false)}>Cancel
+ >
+ ) : (
+ <>
+ {expensePreviewUrl && (
+
Download Receipt
+ )}
+
{ setEditingExpenseId(viewingExpense.id); setNewExpense({ date: viewingExpense.date, description: viewingExpense.description || '', category: viewingExpense.category || 'Other', amount: String(viewingExpense.amount ?? ''), notes: viewingExpense.notes || '', receipt: null, receipt_path: viewingExpense.receipt_path || null, receipt_name: viewingExpense.receipt_name || null }); setExpensesError(''); setExpenseDetailEditing(true); }}>Edit
+
setViewingExpense(null)}>Cancel
+ >
+ )}
+
);
})()}
- {showExpenseForm && (
-
-
e.stopPropagation()}>
-
{editingExpenseId ? 'Edit Expense' : 'New Expense'}
-
{/* end flex column wrapper */}
+
+ {showInvoiceForm && (() => {
+ const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
+ const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
+ return (
+
{ if (!invSaving) invClose(); }}>
+
e.stopPropagation()}>
+
+
+ {/* Left: meta fields */}
+
+
New Invoice
+
+
Company *
+
+
+ {invSelectedCompany && <>
+
+
Bill To
+
setInvBillTo(e.target.value)} placeholder={invSelectedCompany.name} />
+
+
+
Email To
+
setInvEmail(e.target.value)} placeholder="client@example.com" />
+
+
+ >}
+
+
Notes
+
+
+
Total
+
${invTotal.toFixed(2)}
+
+
+
+ {/* Right: unbilled tasks + line items */}
+
+ {/* Unbilled tasks */}
+ {invSelectedCompany && (
+
+
+
Unbilled Requests
+ {invUnbilledTasks.length > 0 &&
invUnbilledTasks.forEach(t => { if (!invItems.some(i => i.task_id === t.id && !i.submission_id)) invAddTask(t); })}>+ All}
+
+ {invLoadingTasks ?
Loading…
+ : invUnbilledTasks.length === 0 ?
None
+ : invUnbilledTasks.map(t => {
+ const price = invPriceList.find(p => p.service_type === t.service_type && p.price_type === 'new');
+ const added = invItems.some(i => i.task_id === t.id && !i.submission_id);
+ return (
+
+
+
{t.project?.name} • {t.title || t.service_type}
+
{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}
+
+
invAddTask(t)}>{added ? 'Added' : '+ Add'}
+
+ );
+ })}
+
+ )}
+ {/* Unbilled revisions */}
+ {invSelectedCompany && invUnbilledRevisions.length > 0 && (
+
+
+
Unbilled Revisions
+
invUnbilledRevisions.forEach(r => { if (!invItems.some(i => i.submission_id === r.id)) invAddRevision(r); })}>+ All
+
+ {invUnbilledRevisions.map(r => {
+ const added = invItems.some(i => i.submission_id === r.id);
+ return (
+
+
{r.task?.project?.name} • {r.task?.title} • R{String(r.version_number || 0).padStart(2, '0')}
+
invAddRevision(r)}>{added ? 'Added' : '+ Add'}
+
+ );
+ })}
+
+ )}
+ {/* Line items */}
+
+
Line Items
+
+ {['', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) =>
2 ? 'right' : 'left' }}>{h}
)}
+
+ {invItems.map((item, idx) => (
+
e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
+
{ invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}>⠿
+
invUpdateItem(item.id, 'description', e.target.value)} />
+
invUpdateItem(item.id, 'quantity', e.target.value)} />
+
invUpdateItem(item.id, 'unit_price', e.target.value)} />
+
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
+
invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}>✕
+
+ ))}
+
setInvItems(prev => [...prev, invNewItem()])}>+ Line Item
+
+
+
+
+ {/* Footer */}
+
+ invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}
+ invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send
+ Cancel
+
+
+
+ );
+ })()}
+
);
}
diff --git a/vite.config.js b/vite.config.js
index fbfcbed..21795b4 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ server: { port: 4173 },
build: {
rollupOptions: {
output: {