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 || '—'}
Date
{fmtDate}
+
+
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)}> @@ -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'} - -
+
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'} + +
+ )} +
+ )} +
+ {/* Right: receipt preview — fixed to left column height, item scales inside */} + {viewingExpense.receipt_path && ( +
+
Receipt
+ {expensePreviewUrl ? ( + isPdf + ? + : Receipt + ) : ( +
Loading preview…
)}
)} -
- {expenseDetailEditing ? ( - <> - - - - ) : ( - <> - - - - )} -
- {/* Right: receipt preview — same height as left column */} - {viewingExpense.receipt_path && ( -
-
Receipt
- {expensePreviewUrl ? ( - isPdf - ? - : Receipt - ) : ( -
Loading preview…
- )} - {expensePreviewUrl && ( - Download Receipt - )} -
- )} + {/* Footer: buttons bottom-right */} +
+ {expenseDetailEditing ? ( + <> + + + + ) : ( + <> + {expensePreviewUrl && ( + Download Receipt + )} + + + + )} +
); })()} - {showExpenseForm && ( -
-
e.stopPropagation()}> -
{editingExpenseId ? 'Edit Expense' : 'New Expense'}
-
-
-
-
Date *
- setNewExpense(p => ({ ...p, date: e.target.value }))} style={{ 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' }} /> -
-
-
Amount (USD) *
- setNewExpense(p => ({ ...p, amount: e.target.value }))} style={{ 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' }} /> -
-
-
-
Description *
- setNewExpense(p => ({ ...p, description: e.target.value }))} style={{ 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' }} /> -
-
-
Category
- -
-
-
Notes
- setNewExpense(p => ({ ...p, notes: e.target.value }))} style={{ 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' }} /> -
-
-
Receipt / Photo
- setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} style={{ 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' }} /> - {!newExpense.receipt && newExpense.receipt_path && ( -
- {newExpense.receipt_name || 'Existing receipt attached'} - - + {showExpenseForm && (() => { + 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 ( +
+
e.stopPropagation()}> + + {/* Main content row */} +
+ {/* Left: fields */} +
+
New Expense
+ setNewExpense(p => ({ ...p, amount: e.target.value }))} /> +
+
Description
+ setNewExpense(p => ({ ...p, description: e.target.value }))} /> +
+
+
Date
+ setNewExpense(p => ({ ...p, date: e.target.value }))} /> +
+
+
Category
+ +
+
+
Notes
+ setNewExpense(p => ({ ...p, notes: e.target.value }))} /> +
+ {expensesError &&
{expensesError}
}
- )} - {newExpense.receipt &&
{newExpense.receipt.name}
} -
- {expensesError &&
{expensesError}
} -
- - -
- + {/* Right: file attach area */} +
+ setNewExpense(p => ({ ...p, receipt: files.length > 0 ? files[files.length - 1] : null }))} + /> +
+
+ {/* Footer */} +
+ + +
+ +
-
- )} + ); + })()}
{/* 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" /> + {invRecipients.map(r => )} +
+ } +
+
Notes
+
{new Date(exp.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} {exp.description} {exp.category}