Compare commits

..

75 Commits

Author SHA1 Message Date
Krao Hasanee 0d6ac3666e fix: revert leftover Project→Client mislabels, drop tracked .env.backfill
Rename in 91045a1 was only partially reverted by later commits, leaving
~20 UI spots calling the project entity "Client" while table headers
already said "Project". Reverted all of them plus the /clients/:id
route (now /projects/:id canonical again, nothing was live yet).

Also: stop tracking .env.backfill (had a live admin password/token
committed - rotate those credentials), remove stale pre-redesign
layout.md superseded by REDESIGN-LAYOUT2.md.
2026-07-15 15:14:57 -04:00
Krao Hasanee 567c941151 rename: task column "Project Name" → "Project"; request form field → "Project / Location"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:54:05 -04:00
Krao Hasanee 6d8aa6bda4 rename: task "Name" column → "Project Name"; request form field → "Project Name / Location / Title"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:51:05 -04:00
Krao Hasanee bcdb177244 fix: report billing counts only sent/paid invoices as Invoiced
Draft invoices and orphaned invoice line items (invoice deleted → null)
no longer count as Invoiced on the billing report, matching what the
Finances page shows as issued. Not-invoiced/invoiced/paid buckets still
sum to total version rows. No-op on current data (no drafts/orphans);
guards future drafts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:17:19 -04:00
Krao Hasanee 91045a1897 redesign: rename Project→Client, finance single-card + filters, mobile tasks, sticky headers
- Rename "Project" → "Client" across UI (labels + /projects→/clients routes with redirect); normalize company-meaning labels to "Company"
- Finance tabs: merge dual cards into one per tab, add header column filters (expenses/subs/invoices), overview as 3-section card, move +Expense/+Invoice to card right edge
- Tasks: responsive mobile stats grid, progressive column hiding (min Status+Name+Assigned), sidebar mobile footer (profile/theme/signout)
- Hide Company column for single-company users; +Task shows disabled Company field instead of hiding
- Sticky table headers site-wide with opaque card background
- Pin dev server to port 5173

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:39:54 -04:00
Krao Hasanee c91e292066 redesign: Layout2 design-system + dashboard/tasks rework + perf
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>
2026-06-26 10:08:40 -04:00
Krao Hasanee c5f020cf44 fix: project auto-complete trigger + reject reason must persist before flipping status
- Add sync_project_status trigger so projects flip active<->completed with their tasks; backfill existing rows.
- handleSubmitReject now aborts (and alerts) if the rejection-reason submission fails to save, instead of silently flipping status to in_progress and logging a reason-less rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:04:01 -04:00
Krao Hasanee 8eacd86b04 fix: invoice integrity — atomic numbering, safe delete, single create path
- Invoice numbers now come from DB function next_invoice_number()
  (max+1 per year under advisory lock) with a unique index; the old
  row-count method reused numbers after deletes and raced concurrent
  creates, which broke public pay links
- Remove dead standalone TeamCreateInvoice page; the TeamInvoices
  modal is the single create path (page had already drifted)
- Invoice delete now asks for confirmation and only un-bills tasks/
  submissions not billed on another invoice
- Created invoice shows correct "sent" status in list without reload
- Invoice/due dates computed at save time, not module load
- Reopening a paid invoice clears stale stripe_fee
- Stripe webhook markPaid is idempotent (no double receipts)
- subcontractor_invoice_items.version_number column stores billing
  version explicitly; description parsing kept as legacy fallback
- Drop unused buildInvoiceStatusByKey/deriveVersionStatus

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:02:28 -04:00
Krao Hasanee aff3d98929 fix: fourge_error revisions always bill $0 to client
Fourge's own error revisions should never be charged to the client.
getRevisionChargeQuantity now returns 0 for revision_type=fourge_error
regardless of version number. Applied at all 4 invoice picker call sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:09:53 -04:00
Krao Hasanee 1784af909d fix: invoice picker skips review-shadow rows for service_type/pricing
Review-shadow submissions are type=initial with blank service_type; the
picker grabbed the first initial found, hitting a shadow -> null service ->
$0 price. New pickInitialServiceType() skips shadows and prefers a real
service_type. Fixes Harlingen sites showing $0 on invoice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:29:42 -04:00
Krao Hasanee 4ef73d193d fix: invoice delete un-bills only, never overwrites task work status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:35:45 -05:00
Krao Hasanee 6b9f008fff fix: report Version Status collapses Approved into Completed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:33:34 -05:00
Krao Hasanee c891f53d2a feat: add Sub Billing column to billing report (sub invoiced/paid per version)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:32:42 -05:00
Krao Hasanee 12c9a1a093 fix: report Version Status shows work state only, billing leak collapsed to approved
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:24:37 -05:00
Krao Hasanee 1cbf06a6c0 fix: revert per-version rows; reset stuck revision tasks in DB
Tasks/projects page: reverted back to one row per task (work status only,
no billing state on that page). Per-version R## tracking lives in invoicing
only (invoice_items, sub invoice form).

DB fix: 7 Public Storage tasks had pending revision versions (R01/R02)
with no delivery but were stuck as 'paid' from old invoice lifecycle sync.
Reset to 'not_started' so they appear in the Revisions tab for work:
  68664, 68666, 68721 (R02), 68808, 68809 (R02), 68810 (R02), 68811 (R02)

Removed unused invoiceItems state/fetch from Tasks + ProjectDetail.
invoiceVersionRules helpers retained for invoicing use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:47:21 -05:00
Krao Hasanee 5b2d5b65a4 feat: per-version R## tracking and billing separation
Each revision (R00, R01, R02) is now an independent trackable unit:

invoiceVersionRules: add deriveVersionStatus, buildInvoiceStatusByKey,
  parseVersionFromItemDescription for per-version status derivation

Tasks.jsx + ProjectDetail.jsx:
  - Fetch invoice_items joined with invoice.status (team only)
  - allRows/rows now flatMap per version (one row per R##)
  - Status derived from: invoice_items > past version > task.status
  - R00 paid shows as paid; R01 in_progress shows separately
  - tr keys use rowKey (taskId:version) to avoid duplicate key warnings

Invoice lifecycle (TeamInvoiceDetail, TeamInvoices, TeamCreateInvoice):
  - Remove task.status sync on invoice paid/sent/created
  - Keep invoiced:true flag for double-billing prevention
  - Task work status (not_started → client_approved) stays work-flow only

ProjectDetail completion bar uses task.status === client_approved only
  (no longer polluted by invoiced/paid status from invoice sync)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:36:36 -05:00
Krao Hasanee da0e5d5c39 fix: guard task status updates on invoice lifecycle transitions
Prevent overwriting task status when task has moved to a new revision
cycle after being invoiced. Now uses .eq('status', guardStatus) to only
update tasks still at the expected prior state:
- invoice creation: only update tasks at client_approved → invoiced
- mark sent: only client_approved → invoiced
- mark paid: only invoiced → paid
- reopen: only paid → client_approved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:22:18 -05:00
Krao Hasanee 8b0cb31bc6 fix: hide counter on All tab in projects table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:48:16 -04:00
Krao Hasanee 4faf0e80b6 fix: hide counter on All tabs; reduce card-bg alpha ~20%
- Tasks + ProjectDetail: skip count badge when tab.id === 'all'
- card-bg 0.07 → 0.055, card-bg-2 0.12 → 0.09

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:46:02 -04:00
Krao Hasanee 623dcb7983 fix: subcontractor PDF quality, footer on all pages, header gap
- Canvas 3x scale + JPEG 0.97 (matches main invoice quality)
- Footer drawn on every page canvas
- Continuation header gap increased (headerH+28 → +50 with label)
- Pagination pre-calculated before rendering (no mid-render addPage)
- 'PO Date' label → 'Invoice Date'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:17:20 -04:00
Krao Hasanee 48c8fbb7ff style: white-based card-bg 0.07, blur 12px→20px for less transparency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:06:56 -04:00
Krao Hasanee 1dd7ca1922 style: card-bg dark-based alpha to match Safari panel look
rgba(255,255,255,0.06) with backdrop blur renders lighter in Chrome.
rgba(0,0,0,0.35) gives consistent dark panel across all browsers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:01:23 -04:00
Krao Hasanee fa8ade83d6 fix: subcontractor PDF title + multi-page support
- 'PURCHASE ORDER' → 'SUBCONTRACTOR INVOICE'
- Items loop now calls addPage() when hitting safeBottom (792-80pt)
- Continuation pages get header with invoice number + '(continued)'
- Summary/Notes sections also page-break if needed
- Footer text updated to match invoice context
- Footer line position dynamic (tracks y), not hardcoded at 704

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:58:42 -04:00
Krao Hasanee e450f1b0d1 style: increase card-bg alpha to match Safari rendering
0.02 → 0.06 dark theme, card-bg-2 0.08 → 0.10

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:54:59 -04:00
Krao Hasanee e3b2df6e2c fix: restore popupOverlayStyle import in ExternalMyInvoices
Removed during popup refactor but still used by +Invoice form popup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:16:11 -04:00
Krao Hasanee e8513125b9 fix: lock company/email fields on sent/paid invoices
Company dropdown and Email To input only editable on draft invoices.
Sent and paid invoices show read-only text values instead.
Applied to both popup (embedded) and standalone page views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:03:58 -04:00
Krao Hasanee 0945b548c1 fix: remove cards from invoice popups, match original flat layout
Replace SubcontractorInvoiceDetailView (card-based) with flat layout
matching the original sub-invoice popup: meta strip + raw table + notes.

- InvoiceDetailPopup: add metaContent/metaActions/metaCols props + export POPUP_FIELD_LABEL
- New InvoicePopupTable: flat sortable 5-col table, no card wrapper
- All 4 popups (client, team invoice, team sub-invoice, external): use
  flat meta strip (4-col grid, borderBottom) and InvoicePopupTable
- Sub-invoice + external: Submitted | Paid/Created | Items | Total
- Client invoice: Date | Due | Status | Total
- Team invoice: Date | Due | Company | Email | Total (+ stripe if paid)
  with Edit Dates button in metaActions slot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:01:59 -04:00
Krao Hasanee 6e4e99c33c feat: unify all invoice popups to shared InvoiceDetailPopup shell
All invoice popup modals (team invoice, sub-invoice team, client invoice,
external sub-invoice) now share InvoiceDetailPopup + SubcontractorInvoiceDetailView.
Same overlay, header, scroll body, footer action row — just different data per role.

- New InvoiceDetailPopup component (overlay shell, header, scrollable body, footer)
- SubcontractorInvoiceDetailView: respects item._inferredType if pre-set
- ClientInvoiceModal: uses shared components, adds sortable headers
- TeamInvoiceDetailPanel embedded: uses shared components, keeps edit-dates/email/company
- TeamInvoices sub-invoice popup: uses shared components, cleans descriptions
- ExternalMyInvoices popup: uses shared components, fixes detailSort vars (was unused)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:46:08 -04:00
Krao Hasanee 04e0911e9f fix: crash bugs in TeamInvoices + faster load
Bug fixes:
- TeamInvoices: add useAuth import/currentUser (invoice-create crash)
- TeamInvoices: setChartYear -> setExportYear (year-dropdown crash)
- TeamDashboard: drop redundant setState-in-effect (cascading renders)
- Companies: delete dead _UnusedClientCompanies (illegal hook calls)
- annotate intentional empty PDF-fallback catches

Load speed:
- preconnect/dns-prefetch to Supabase origin
- lazy-load heic-to in Converters: page chunk 2737KB -> 9KB
- split recharts into its own 'charts' vendor chunk

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:59:11 -04:00
Krao Hasanee 85625f4d95 fix: delivery zip downloads from correct deliveries storage bucket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:59:34 -04:00
Krao Hasanee 71a8ed1b43 docs: document team performance calculation logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:44:44 -04:00
Krao Hasanee 2c387c074f feat: performance dashboard counts per delivery, submission tab shows deliveries, task row hover fix
- Team performance: count each delivery record individually (sent_by + version_number), not per task — R00/R01/R02 by different people all count separately
- Performance uses deliveries table directly, EST timezone bucketing, month-gated tabs
- TaskDetail Submissions tab: reads from deliveries table, shows R## version, sent_by, sent_at
- Submit for Review: writes to deliveries + delivery_files (deliveries bucket), stores version_number
- Revision request popup: uses FileAttachment drag-drop component
- Task table: only Name and Project columns highlight on hover
- deliveries.version_number column added and backfilled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:21:58 -04:00
Krao Hasanee 96e90561b9 feat: expense/invoice popups, task row hover fix, delivery submissions
- Expense detail popup: 80vw×80vh, inline editing, save gated on dirty state, Download Receipt in footer
- Add expense form: same layout with FileAttachment drag-drop on right
- New invoice popup: 80vw×80vh inline form replacing navigate-away page
- Tasks table: suppress row hover background, only Name/Project table-links highlight
- TaskDetail Submissions tab: reads from deliveries table, shows R## version, sent_by, message
- Submit for Review: writes to deliveries + delivery_files (deliveries bucket)
- deliveries.version_number column added and backfilled from revision history
- Port 4173 for dev server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 21:41:02 -04:00
Krao Hasanee 65f10036d8 fix: divider line under breadcrumbs on folder tab and filesharing 2026-06-02 15:18:03 -04:00
Krao Hasanee aae5214cde fix: context menu via createPortal, renders at viewport coords 2026-06-02 15:15:54 -04:00
Krao Hasanee 36f1179c04 fix: remove toolbar divider border 2026-06-02 14:48:56 -04:00
Krao Hasanee 8b6e2739ae fix: remove up-folder row, context menu flips up when near bottom 2026-06-02 14:48:24 -04:00
Krao Hasanee 144bda426b feat: FileBrowser matches FileSharing — no inline row buttons, context menu copy/cut/paste/rename/delete, selection Download+Delete panel, drag hint 2026-06-02 14:43:05 -04:00
Krao Hasanee 959dd571d2 fix: submissions tab row gap matches revisions (20px) 2026-06-02 14:36:13 -04:00
Krao Hasanee e912f82520 feat: review popup drag-drop attachments, .md styling 2026-06-02 14:33:50 -04:00
Krao Hasanee 894ec323f2 fix: use type=initial for all submissions, single unified type 2026-06-02 14:30:53 -04:00
Krao Hasanee 585fc7500c fix: Submissions tab includes legacy initial-type submissions 2026-06-02 14:29:37 -04:00
Krao Hasanee d222d8b573 feat: review popup notes+attachments, Submissions tab 2026-06-02 14:28:59 -04:00
Krao Hasanee 0d4b1f8096 feat: place-in-review attachment popup, remove-from-review button 2026-06-02 14:25:32 -04:00
Krao Hasanee ae093a9b1c fix: proxy file uploads server-side via action=upload, fixes CORS for all roles 2026-06-02 14:19:59 -04:00
Krao Hasanee e11ef45140 fix: FileBrowser upload uses auth query param, no CORS preflight for external users 2026-06-02 14:12:24 -04:00
Krao Hasanee 770bb1c05b fix: upload source=srv, expense detail popup, invoice/expense filters, pie charts, date timezone fixes 2026-06-02 14:08:11 -04:00
Krao Hasanee 1f09ce0a1d feat: expense year filter, newest-first default sort for expenses + invoices 2026-06-02 12:13:10 -04:00
Krao Hasanee 490e4f4bbf fix: stat cards use comma-formatted numbers 2026-06-02 11:11:08 -04:00
Krao Hasanee 4fce77abb9 fix: net profit deducts stripe fees from chart calculation 2026-06-02 10:49:11 -04:00
Krao Hasanee 573c4a69fa fix: expense date timezone, receipt popup blocker 2026-06-02 10:47:58 -04:00
Krao Hasanee 30838e8da3 fix: stat cards show full dollar amounts under $100k, k-suffix only at $100k+ 2026-06-02 10:41:05 -04:00
Krao Hasanee 958f451836 fix: client file sharing access denied + task/project default tabs
- FileSharing: rootPath for clients strips /Clients/ prefix — API resolveClientPath expects /{company} not /Clients/{company}
- Tasks: default to To Do tab (not_started) instead of All Tasks
- Projects: default to Active tab instead of All Projects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:35:13 -04:00
Krao Hasanee 986d831186 feat: finance page overhaul — pie charts, month nav, tab improvements
- Overview tab: 3 donut pie charts (expenses by category, sub payments, invoiced) each with independent month navigation arrows
- Expenses tab: big red total in category sidebar, left-aligned category column, white text
- Subcontractors tab: big gold total in sidebar, pending/paid breakdown with gold highlight
- Invoices tab: big green total in companies sidebar, dashboard-inline-link for invoice# and company, StatusBadge with Invoiced/Paid labels, column widths tuned
- StatusBadge: add optional label prop for custom display text
- Layout/style polish across tabs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:14:14 -04:00
Krao Hasanee ac3f08122d feat: finance page overhaul, file access fix for clients and subcontractors
- Finance (TeamInvoices): chart + stat cards, Overview/Expenses/Subcontractors/Invoices/Legacy tabs
- Expenses tab: 70/30 card split with expense list + category breakdown, + Expense button
- Overview tab: 3 cards dynamic height filling window, .md-compliant table headers and sticky heads
- TaskDetail FileBrowser: role-based virtual paths so clients and subcontractors no longer get 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:48:40 -04:00
Krao Hasanee 70ad8d0cef feat: project detail overhaul, realtime updates, avatar RLS fix
- ProjectDetail: full redesign — icon meta row, completion progress bar, main contact card, activity feed, subcontractor management modal with multi-select
- ProjectDetail: task table matches Tasks.jsx (R#, assigned avatar, priority, type, deadline, status) with status tabs + counts
- Realtime: useRefetchOnFocus + useRealtimeSubscription hooks wired to Tasks, TaskDetail, ProjectDetail, TeamDashboard
- AuthContext: live profile updates via Supabase realtime channel
- Tasks/ProjectDetail: assignee avatar join added for all roles (client, external, team)
- RLS: allow all authenticated users to read profiles (fixes avatar display across roles)
- RequestForm: lockedFields prop for pre-filled read-only company/project fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 16:55:58 -04:00
Krao Hasanee 35d92d2176 fix: allow service role auth on migrate-old-books endpoint 2026-05-31 11:19:43 -04:00
Krao Hasanee ab7a6b5a57 fix: migrate Old Book → Old Books via Vercel API endpoint
- api/migrate-old-books.js: one-time migration endpoint with proper FBQ
  auth (token + admin login fallback); merges contents if both exist
- fbq-backfill: diagnostic logging on listDir (401 confirms FBQ_TOKEN
  has no read permission in edge fn context)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:17:47 -04:00
Krao Hasanee 94d9c3904b fix: standardize Old Book → Old Books folder name
- filebrowserFolders.js: createTaskFolder now creates Old Books
- fbq-backfill: creates Old Books; migrateOldBook() merges/renames
  existing Old Book folders into Old Books on each backfill run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:07:59 -04:00
Krao Hasanee 5b3060e190 Session 2026-05-31: tasks table overhaul, FileBrowser redesign, folder path fix
- Tasks table: added R#, Assigned To (avatar), Priority (HOT/NO) columns; removed Status column; sortable columns; adjustable widths
- FileBrowser: full visual rewrite to match FileSharing page (table layout, colored ext badges, SVG folder icon, sortable columns, multi-select, context menu, drag-drop)
- TaskDetail folder tab: fix path with safeName(), rootPath set to project folder level
- filebrowserFolders: export safeName for reuse

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:02:40 -04:00
Krao Hasanee 0b4705311b Session 2026-05-30: task detail page overhaul
- New TaskDetail replaces /tasks/:id (was v2 at /requests/:id/v2)
- Overview tab: R00 request info, Requested By/Date/Sign Count inline row, Notes, Sign Family, files, amendments
- Revisions tab: R01+ from submissions, newest first, same layout as overview, per-entry Amend button
- Comments tab: single-line input, post on Enter, delete own comments, Supabase task_comments table
- Folder tab: placeholder for file sharing
- Tab badges showing revision/comment counts
- Amend Request modal: drag-drop file zone, popupOverlayStyle/Surface, Save+Cancel buttons
- Request Revision modal for clients on approved/invoiced/paid tasks
- JSZip download-all with progress popup for submission files
- Upload progress popup for Add Task and amend file uploads
- FileAttachment drop zone: 8px radius, transparent bg, label style fix (no all-caps override)
- PageLoader on task detail load
- task_comments migration with RLS policies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 22:44:57 -04:00
Krao Hasanee 13ef1f4ded Session 2026-05-30: tasks/projects unification, code consolidation, file renames
- Unified Tasks page: 3 role render blocks → 1, normalized row shape, single renderRow/sort/tabs
- Fixed role scoping: external = all tasks in member projects, client = all tasks in company projects
- Fixed client bug: was scoped by submitted_by, now company→project→tasks
- Aligned dashboard client scope to match tasks page
- Hot tasks dashboard: now filters to active statuses only (not completed/invoiced/paid)
- Removed Projects.jsx (dead), fixed /project/ → /projects/ links
- Renamed all page files to match folder path (Team*, External*, Client* prefixes)
- Renamed: RequestsPage→Tasks, Settings→Profile, CompaniesPage→Companies, ProjectDetailPage→ProjectDetail
- Dropped dead fetches from Tasks page (invoices, invoice_items, subcontractor_invoice_items)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:19:23 -04:00
Krao Hasanee 6fe7ab1059 Session 2026-05-29: tasks page redesign, projects sidebar, invoicing fix
- Tasks page: tabs (All/To Do/In Progress/In Review/Completed) in header, removed grid view, 70/30 split with projects card
- Projects card: sortable table, dynamic height fills viewport
- Removed Projects page and nav links; redirects → /tasks
- Invoice dedup: prevent double-charge when multiple submissions per revision version
- Dashboard table style applied to tasks table (10px headers, 5px cell padding, transparent cells)
- stat cards: removed "Tasks" header, moved Total Tasks to far right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:40:10 -04:00
Krao Hasanee 8d0a461e7a Session 2026-05-29: shared dashboard, team tasks page, requests card style, copy/paste files, loading modals, avatar cache bust
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:23:20 -04:00
Krao Hasanee c9998d4a8d Session 2026-05-29: profile layout 2-col, file browser copy/paste, fbq-proxy/backfill fns, migrations, UI updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:49:21 -04:00
Krao Hasanee 283511bf3a Session 2026-05-28: profile page overhaul, nav fixes, dashboard activity links
- Fix nav links not working from profile page (useEffect infinite re-render via unstable profile object ref)
- Fix nav hover/active: gold icon highlight, no background change; active links non-clickable
- Fix hover layout shift: add border: 1px solid transparent to all interactive elements
- Header icon buttons (search, theme toggle) now highlight gold on hover
- Profile page: replace calendar with activity feed (60/40 grid), add stat cards (tasks completed, active projects, revision requests, submissions)
- Profile card: title field, icon rows for location/email/linkedin, member since + role bottom-right, edit button top-right
- Profile portrait: remove wrapper column, fix left-gap alignment
- Add profiles.title migration
- Dashboard recent activity: name → /profile/{id}, task → /requests/{id} (clickable links)
- Icon-only sidebar with gold active/hover state, pointer-events: none on active links
- layout.md updated with profile page geometry rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:32:46 -04:00
Krao Hasanee 565d2ed4bc Session 2026-05-20: UI fixes, invoice filtering, file browser, request approvals, sub invoice task scope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:32:55 -04:00
Krao Hasanee ff159c5937 Move multi-role pages out of team/ to pages/ root
CompanyDetail (team+client), BrandBook, SurveyMaker, Converters (team+external)
all serve more than one role — belong at pages/ not pages/team/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:14:49 -04:00
Krao Hasanee 66baa2869e Move FileSharing from team/ to pages/ — serves all 3 roles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:13:11 -04:00
Krao Hasanee b9a4c4a353 Merge all role-dispatcher pages into single files; add FileBrowser with file-type icons
- DashboardPage, Projects, RequestsPage, ProjectDetailPage, RequestDetail: each now handles team/external/client in one file via role flags — removed 10 old role-specific sub-files
- Layout: client Company nav link goes directly to /company/:id when user has a single company
- FileBrowser: replace emoji icons with colored extension-text badges (square); folder icon stays 📁; Adobe/Figma/design-tool colors for design files
- CompaniesPage: merged team Companies + client company routing (single-company redirect, multi-company list)
- FileSharing: integrated FileBrowser component
- Removed: seafile API + lib, old ServerStatus, TaskDetail, role-split page files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:11:34 -04:00
Krao Hasanee f9e66dfced Rename Jobs to Tasks, add Project Files section header on project detail 2026-05-14 19:46:17 -04:00
Krao Hasanee 6e7e7d7130 Include paid sub invoices in expenses/profit/year totals 2026-05-14 19:40:50 -04:00
Krao Hasanee 6b5f5df547 Add sub invoice detail page with delete/mark paid/receipt; fix payable stat 2026-05-14 19:35:44 -04:00
Krao Hasanee 13bb0f7914 Apply pill shape (border-radius 20px) to all buttons and sidebar nav items 2026-05-14 15:48:23 -04:00
Krao Hasanee 53b591697a Apply pill tab style across all portal pages (team, client, external) 2026-05-14 15:26:48 -04:00
167 changed files with 20894 additions and 11292 deletions
+20
View File
@@ -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"
}
]
}
+30 -59
View File
@@ -1,69 +1,40 @@
{
"permissions": {
"allow": [
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)",
"Bash(vercel --version)",
"Bash(vercel --prod)",
"Bash(supabase --version)",
"Bash(brew install:*)",
"Read(//usr/local/bin/**)",
"Read(//Users/kraohasanee/.local/bin/**)",
"Read(//usr/**)",
"Bash(command -v supabase)",
"Read(//Users/kraohasanee/Library/**)",
"Bash(npx supabase:*)",
"Bash(echo $PATH)",
"Bash(supabase functions:*)",
"Bash(npm install:*)",
"Bash(export PATH=\"/opt/homebrew/bin:$PATH\")",
"Read(//opt/homebrew/bin/**)",
"Read(//Users/kraohasanee/.npm/bin/**)",
"Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")",
"Read(//Users/kraohasanee/.supabase/**)",
"Bash(supabase status:*)",
"Bash(supabase orgs:*)",
"Bash(supabase projects:*)",
"Bash(supabase db:*)",
"Bash(npm run:*)",
"Bash(npx vercel:*)",
"Bash(curl -vI https://portal.fourgebranding.com)",
"Bash(dig portal.fourgebranding.com A +short)",
"Bash(dig portal.fourgebranding.com CNAME +short)",
"Bash(curl:*)",
"Bash(supabase migration:*)",
"Bash(ls \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal\"/.env*)",
"Bash(supabase secrets:*)",
"Bash(stripe version:*)",
"Bash(stripe config:*)",
"Bash(stripe checkout:*)",
"Bash(stripe payment_intents list --limit 10)",
"Bash(stripe charges:*)",
"Bash(vercel ls:*)",
"Bash(vercel promote:*)",
"Bash(vercel inspect:*)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('gitSource', d.get\\('meta', ''\\)\\)\\)\")",
"Bash(npx vite:*)",
"Bash(wait)",
"Bash(stripe webhook_endpoints list)",
"Bash(grep VITE_SUPABASE_ANON_KEY .env.local)",
"Bash(grep VITE_SUPABASE_ANON_KEY .env.production)",
"Bash(vercel whoami *)",
"Bash(vercel deploy *)",
"Bash(vercel build *)",
"Bash(vercel pull *)",
"Bash(sudo npm *)",
"Bash(npx vercel@latest --prod)",
"Bash(npm run *)",
"Bash(awk 'NR>=547 && /^}/ {print NR\": \"$0; c++; if\\(c>=1\\) exit}' src/pages/Companies.jsx)",
"Bash(sed -n '545,548p' src/pages/Companies.jsx)",
"Bash(sed -i '' '545,715d' src/pages/Companies.jsx)",
"Bash(sed -n '542,548p' src/pages/Companies.jsx)",
"Bash(sed -n '76,80p' src/pages/team/TeamSubInvoiceDetail.jsx)",
"Bash(sed -n '147p' src/pages/Profile.jsx)",
"Bash(awk '{print $5, $9}')",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git commit -q -m ' *)",
"Bash(git push *)",
"Bash(cat .env.local)",
"Bash(cat .env)",
"Bash(vercel env *)",
"Bash(python3 -m json.tool)",
"Bash(xargs -0 '-I{}' bash -c 'name=$\\(basename \"{}\" .jsx\\); grep -q \"$name\" \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal/src/App.jsx\" || echo \"UNLINKED: {}\"')",
"Bash(vercel --prod --yes)",
"Bash(sed -n '1,15p' src/pages/external/ExternalMyInvoices.jsx)",
"Bash(sed -n '1,15p' src/pages/client/ClientMyInvoices.jsx)",
"Bash(git commit -q -m 'fix: restore popupOverlayStyle import in ExternalMyInvoices *)",
"Bash(git commit -q -m 'style: increase card-bg alpha to match Safari rendering *)",
"Bash(git commit -q -m 'fix: subcontractor PDF title + multi-page support *)",
"Bash(git commit -q -m 'style: card-bg dark-based alpha to match Safari panel look *)",
"Bash(git commit -q -m 'style: white-based card-bg 0.07, blur 12px→20px for less transparency *)",
"Bash(git commit -q -m 'fix: subcontractor PDF quality, footer on all pages, header gap *)",
"mcp__plugin_supabase_supabase__execute_sql",
"Bash(sed -n '88,100p' src/pages/Tasks.jsx)",
"Bash(sed -n '570,600p' src/pages/Tasks.jsx)",
"Bash(git commit -q -m 'fix: hide counter on All tab in projects table *)",
"Bash(git commit -q -m 'fix: guard task status updates on invoice lifecycle transitions *)",
"Bash(vercel ls *)",
"Bash(vercel --prod)",
"Bash(git commit -q -m 'feat: add Sub Billing column to billing report \\(sub invoiced/paid per version\\) *)",
"Bash(git commit -q -m 'fix: report Version Status collapses Approved into Completed *)",
"mcp__plugin_supabase_supabase__list_projects",
"Bash(grep -rni \"task_number\\\\|task #\\\\|task#\\\\|#\\\\${\\\\|taskNumber\\\\|seq\" src/pages/Tasks.jsx src/pages/TaskDetail.jsx src/pages/ProjectDetail.jsx)",
"mcp__plugin_supabase_supabase__apply_migration",
"Bash(git commit *)"
"Bash(git commit -q -m 'fix: invoice picker skips review-shadow rows for service_type/pricing *)",
"Bash(git commit -q -m 'fix: fourge_error revisions always bill $0 to client *)"
]
}
}
@@ -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"
}
}
@@ -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.
Executable → Regular
+1
View File
@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env.backfill
# Editor directories and files
.vscode/*
-16
View File
@@ -1,16 +0,0 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+194
View File
@@ -0,0 +1,194 @@
# Layout2 — Redesign Tracker
> Visual redesign only. **Functions stay identical.** No data flow, query, route, or
> behavior changes. If a change touches logic, it does not belong in Layout2.
- **Branch:** `redesign`
- **Dev:** http://localhost:5173/ (Vite HMR, no rebuild)
- **Live:** untouched — nothing deploys until explicitly approved. No `vercel --prod` during Layout2.
- **Started:** 2026-06-23
---
## Rules
1. Redesign = layout, spacing, hierarchy, components' visual form. No prop/state/handler edits.
2. Keep every element that exists today (buttons, fields, badges) — reposition/restyle, don't remove function.
3. Preserve all routes, role gates (`team`/`external`/`client`), and conditional rendering as-is.
4. Reuse design tokens (below). Add new tokens rather than hard-coding values.
5. Commit per page/section with `redesign:` prefix so it's revertible.
---
## Direction
- **Background:** solid 95% black `#0d0d0d`, no gradient, no animation.
- **Cards:** solid gray `#1a1a1a` (5% lighter than bg); elevated/hover `#242424`.
- **Font:** **Inter** (Google Fonts, weights 300700) — modern, readable, app-wide. Brand `Fourge` font retired from UI text; logo is an image, unaffected.
---
## Control panel (single source of truth — `src/index.css :root`)
Change one line, every card/page across **all roles** updates.
| Knob | Token / line | Scope |
|---|---|---|
| Background | `--bg` | whole app |
| Card fill | `--card-bg` (+ `--card-bg-2` elevated/hover) | every card |
| Card border | `--card-border` (`1px solid transparent` = off) | every card |
| Card radius | `--card-radius` | every card |
| Font | body `font-family` (Inter) | whole app |
| Accent / gold | `--accent` (+ `--accent-hover`) | all gold UI |
| Status: error | `--danger` (+ `--danger-strong`) | all red |
| Status: success | `--success` (+ `--success-strong`) | success greens |
| Money/positive | `--positive` | dashboard/positive green |
| Info | `--info` | blue |
| Violet | `--violet` | purple |
| Warning | `--warning` | amber |
**Tints** pull from the same token: `color-mix(in srgb, var(--accent) 15%, transparent)`.
All semantic colors are theme-independent (badge light palette stays in `index.css`).
~168 hardcoded hex/rgba across 15 JSX files were swept to these tokens (zero visual change).
Cards = elements with `var(--card-bg)`; their frame uses `var(--card-border)`. The **menu/sidebar**
also uses `--card-border` + `--card-radius`. Inputs/tables/toolbar dividers keep their own `--border`
and are intentionally NOT card-controlled.
**Theming:** tokens live in `:root` (dark defaults); `[data-theme="light"]` overrides values.
Same token name site-wide, one value per theme. `--card-border` is set in both themes
(dark `#262626`, light `rgba(0,0,0,0.08)`); `--card-radius` is shared (color-free).
## Full token chart (canonical — `src/index.css`)
All in `:root` (dark); `[data-theme="light"]` overrides where noted. Same token name site-wide,
one value per theme. Solids = `var(--x)`; tints = `color-mix(in srgb, var(--x) N%, transparent)`.
### Surfaces
| Token | Dark | Light |
|---|---|---|
| `--bg` | `#1a1a1a` | `#ffffff` |
| `--card-bg` | `#1f1f1f` | `rgba(0,0,0,.02)` |
| `--card-bg-2` | `#262626` | `rgba(0,0,0,.08)` |
| `--surface-sunken` | `#141414` | `#f5f5f5` |
| `--popup-bg` | `var(--card-bg)` (= `#1f1f1f`) | `rgba(255,255,255,.92)` (opaque; light card-bg is translucent) |
### Borders
| Token | Dark | Light |
|---|---|---|
| `--border` | `rgba(245,165,35,.15)` | `rgba(0,0,0,.1)` |
| `--card-border` | `1px solid #262626` | `1px solid rgba(0,0,0,.08)` |
| `--card-radius` | `8px` | shared |
| `--interactive-hover-border` | `rgba(245,165,35,.3)` | `rgba(0,0,0,.2)` |
### Text
| Token | Dark | Light |
|---|---|---|
| `--text-primary` | `#ffffff` | `#0d0d0d` |
| `--text-secondary` | `#a8a8a8` | `rgba(0,0,0,.6)` |
| `--text-muted` | `#666666` | `rgba(0,0,0,.38)` |
| `--text-on-accent` | `#000000` | shared |
### Brand & status (theme-independent)
| Token | Value |
|---|---|
| `--accent` / `--accent-hover` | `#F5A523` / `#e09510` |
| `--danger` / `--danger-strong` | `#ef4444` / `#dc2626` |
| `--success` / `--success-strong` | `#22c55e` / `#16a34a` |
| `--positive` | `#4ade80` |
| `--info` | `#60a5fa` |
| `--violet` | `#a78bfa` |
| `--warning` | `#f59e0b` |
### Data / categorical (charts, file-type chips — by hue)
| Token | Value | Token | Value |
|---|---|---|---|
| `--data-blue` | `#3b82f6` | `--data-indigo` | `#2563eb` |
| `--data-purple` | `#8b5cf6` | `--data-purple-soft` | `#c084fc` |
| `--data-pink` | `#ec4899` | `--data-pink-soft` | `#f472b6` |
| `--data-orange` | `#f97316` | `--data-orange-soft` | `#fb923c` |
| `--data-emerald` | `#10b981` | `--data-emerald-soft` | `#34d399` |
| `--data-gray` | `#6b7280` | | |
### Utility / misc
| Token | Dark | Light |
|---|---|---|
| `--overlay-scrim` | `rgba(0,0,0,.58)` | `rgba(255,255,255,.72)` |
| `--popup-shadow` | `0 24px 64px rgba(0,0,0,.5)` | lighter |
| `--avatar-inner-ring` | `#111111` | `#ffffff` |
| `--sidebar-*` | bg `#0d0d0d`, text `#888`, active `#fff`/`#1a1a1a` | inverted |
| motion | `--motion-fast 160ms`, `--motion-base 220ms`, ease `cubic-bezier(0.22,1,0.36,1)` | |
| buttons | h `22px`, radius `8px`, font `11px/500`, tracking `0.8px` | |
**Intentionally NOT tokenized (must stay literal):**
- Canvas/PDF export colors (`BrandBook.jsx`, `Converters.jsx``ctx.fillStyle`), white logo backgrounds.
- `PayInvoice.jsx` — public printable invoice, standalone light styling, must not follow app theme.
**Established patterns (from memory):**
- Card label color `rgba(255,255,255,0.8)`.
- Filter/tab layout: dropdowns *outside* card, tab buttons *inside* card above table. Canonical: `Tasks.jsx` (Requests).
---
## Shared shell / components
| Item | File | Status |
|---|---|---|
| App shell / sidebar / nav | `src/components/Layout.jsx` | ⬜ |
| Global tokens & base CSS | `src/index.css` | ⬜ |
| StatusBadge | `src/components/StatusBadge.jsx` | ⬜ |
| FilterDropdown | `src/components/FilterDropdown.jsx` | ⬜ |
| SortTh (table headers) | `src/components/SortTh.jsx` | ⬜ |
| ProfileAvatar | `src/components/ProfileAvatar.jsx` | ⬜ |
| LoadingButton / PageLoader | `src/components/{LoadingButton,PageLoader}.jsx` | ⬜ |
| RequestForm | `src/components/RequestForm.jsx` | ⬜ |
| Invoice popups / tables | `InvoiceDetailPopup`, `InvoicePopupTable` | ⬜ |
| Subcontractor invoice form/view | `SubcontractorInvoice{Form,DetailView}.jsx` | ⬜ |
| FileAttachment | `src/components/FileAttachment.jsx` | ⬜ |
## Pages
| Route | Page | Roles | Status |
|---|---|---|---|
| `/` | Login | all | ⬜ |
| `/dashboard` | `team/TeamDashboard.jsx` | team/external/client | ⬜ |
| `/tasks` | `Tasks.jsx` (Requests) | team/external/client | ⬜ |
| `/tasks/:id` | `TaskDetail.jsx` | team/external/client | ⬜ |
| `/projects/:id` | `ProjectDetail.jsx` | team/external/client | ⬜ |
| `/company` | `Companies.jsx` | team/client | ⬜ |
| `/company/:id` | `CompanyDetail.jsx` | team/client | ⬜ |
| `/finances` | `team/TeamInvoices.jsx` | team | ⬜ |
| `/finances/:id` | `team/TeamInvoiceDetail.jsx` | team | ⬜ |
| `/subcontractor-pos/new` | `team/TeamCreateSubcontractorPO.jsx` | team | ⬜ |
| `/subcontractor-pos/:id` | `team/TeamSubcontractorPODetail.jsx` | team | ⬜ |
| `/sub-invoices/:id` | `team/TeamSubInvoiceDetail.jsx` | team | ⬜ |
| `/reports` | `team/TeamReports.jsx` | team | ⬜ |
| `/fourge-passwords` | `team/TeamFourgePasswords.jsx` | team | ⬜ |
| `/survey-maker` | `SurveyMaker.jsx` | team/external | ⬜ |
| `/brand-book` | `BrandBook.jsx` | team/external | ⬜ |
| `/converters` | `Converters.jsx` | team/external | ⬜ |
| `/my-purchase-orders` | `external/ExternalMyPurchaseOrders.jsx` | external | ⬜ |
| `/subs-invoices` | `external/ExternalMyInvoices.jsx` | external | ⬜ |
| `/subs-invoices/new` | `external/ExternalMyInvoiceCreate.jsx` | external | ⬜ |
| `/subs-invoices/:id` | `external/ExternalMyInvoiceDetail.jsx` | external | ⬜ |
| `/client-invoices` | `client/ClientMyInvoices.jsx` | client | ⬜ |
| `/profile`, `/profile/:id` | `Profile.jsx` | all | ⬜ |
| `/pay/:id` | `PayInvoice.jsx` | public | ⬜ |
Legend: ⬜ todo · 🔄 in progress · ✅ done
---
## Log
- 2026-06-23 — Doc created, `redesign` branch cut, dev on 5173.
- 2026-06-23 — Base tokens: solid `#0d0d0d` bg, killed animated grid; cards `#1a1a1a`/`#242424`. Font → Inter.
- 2026-06-23 — bg → `#1a1a1a` (90% K), cards `#1f1f1f`/`#262626` (88% K), solid/no transparency.
- 2026-06-23 — Tokenized cards: added `--card-border` (off) + `--card-radius`; swept 28 inline card frames + `.card` class to the tokens. All cards now controllable from one line.
- 2026-06-23 — Card border on at `#262626` (85% K); light-mode `--card-border` = `rgba(0,0,0,0.08)`; menu/sidebar routed to `--card-border`/`--card-radius`.
- 2026-06-23 — Color system: added `--positive/--info/--violet/--warning/--danger-strong/--success-strong`; swept 168 hardcoded colors across 15 JSX files to tokens + `color-mix` tints. Site/role/page colors now single-source.
- 2026-06-23 — Completed palette: added `--data-*` (hue-named) categorical set, `--surface-sunken`, `--text-on-accent`. Swept file-type chips + chart colors + surface neutrals to tokens. Remaining literals are canvas/PDF exports + PayInvoice (intentional). Full chart documented above.
- 2026-06-23 — Popups single-source: `--popup-bg` now = `var(--card-bg)` (dark); stripped 8 inline `background: var(--card-bg)` overrides on `popupSurfaceStyle` so loader/modals/menus all use the token. Light stays opaque white.
- 2026-06-23 — Dashboard reflow: row2 = To Do (fluid) + Calendar (280px); row3 = Activity / In-Progress / Team Perf. New `.dash-row-todo` / `.dash-row-trio` classes; responsive (trio 2-up ≤1200, all stack ≤768). Retired bottomCardsHeight viewport-fill hack. Mobile-friendly is now a standing requirement for Layout2.
- 2026-06-23 — Perf sweep: root cause = ~400770ms/query server cold-start (compute tier, infra). Code fixes: added FK/filter index migration (pushed to DB); removed redundant client/external scope waterfall in Tasks + Dashboard (rely on RLS, verified `has_company_access`/`project_members` parity); embedded submitter-profiles + deliveries into the Tasks submissions query (2 round trips all roles). Test client/external dashboards on 5173.
- 2026-07-15 — Naming cleanup: the 91045a1 Project→Client rename was only partially walked back by later commits, leaving the project entity mislabeled "Client" in ~20 spots (Add Project modal, filters, PO/report/dashboard columns, etc.) while table headers already said "Project". Confirmed with user: **Company** = the customer org (e.g. "Public Storage"), **Project** = a job within that company — "Client" was a leftover label, not a third entity. Reverted all mislabeled text to "Project"; reverted the `/clients/:id` route back to canonical `/projects/:id` (nothing deployed to prod yet, so no live URLs broke). Also: removed stale pre-redesign `layout.md` (superseded by this doc), stopped tracking `.env.backfill` (had a live admin password/token committed — rotate those credentials separately).
+61
View File
@@ -0,0 +1,61 @@
import { createClient } from '@supabase/supabase-js';
export default async function handler(req, res) {
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
const authHeader = req.headers.authorization || '';
if (!authHeader.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const anonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const callerClient = createClient(supabaseUrl, anonKey, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: authHeader } },
});
const { data: userData } = await callerClient.auth.getUser();
if (!userData?.user) return res.status(401).json({ error: 'Unauthorized' });
const { data: profile } = await callerClient.from('profiles').select('id, role').eq('id', userData.user.id).single();
if (!profile || !['team', 'client'].includes(profile.role)) return res.status(403).json({ error: 'Forbidden' });
const projectId = req.query.id;
if (!projectId) return res.status(400).json({ error: 'Project ID required' });
const admin = createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
if (profile.role === 'client') {
const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single();
if (projErr || !proj) return res.status(404).json({ error: 'Project not found or access denied' });
}
const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId);
const taskIds = (tasks || []).map(t => t.id);
if (taskIds.length) {
const { data: subs } = await admin.from('submissions').select('id').in('task_id', taskIds);
const subIds = (subs || []).map(s => s.id);
if (subIds.length) {
const { data: subFiles } = await admin.from('submission_files').select('storage_path').in('submission_id', subIds);
const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean);
if (subPaths.length) await admin.storage.from('submissions').remove(subPaths);
const { data: deliveries } = await admin.from('deliveries').select('id').in('submission_id', subIds);
const delIds = (deliveries || []).map(d => d.id);
if (delIds.length) {
const { data: delFiles } = await admin.from('delivery_files').select('storage_path').in('delivery_id', delIds);
const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean);
if (delPaths.length) await admin.storage.from('deliveries').remove(delPaths);
}
}
}
const { error } = await admin.from('projects').delete().eq('id', projectId);
if (error) return res.status(500).json({ error: error.message });
return res.status(200).json({ ok: true });
}
+54
View File
@@ -0,0 +1,54 @@
import { createClient } from '@supabase/supabase-js';
export default async function handler(req, res) {
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
const authHeader = req.headers.authorization || '';
if (!authHeader.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const anonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const callerClient = createClient(supabaseUrl, anonKey, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: authHeader } },
});
const { data: userData } = await callerClient.auth.getUser();
if (!userData?.user) return res.status(401).json({ error: 'Unauthorized' });
const { data: profile } = await callerClient.from('profiles').select('id, role').eq('id', userData.user.id).single();
if (!profile || !['team', 'client'].includes(profile.role)) return res.status(403).json({ error: 'Forbidden' });
const taskId = req.query.id;
if (!taskId) return res.status(400).json({ error: 'Task ID required' });
const admin = createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: taskRecord } = await admin.from('tasks').select('id').eq('id', taskId).single();
if (!taskRecord) return res.status(404).json({ error: 'Task not found' });
const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId);
const subIds = (subs || []).map(s => s.id);
if (subIds.length) {
const { data: subFiles } = await admin.from('submission_files').select('storage_path').in('submission_id', subIds);
const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean);
if (subPaths.length) await admin.storage.from('submissions').remove(subPaths);
const { data: deliveries } = await admin.from('deliveries').select('id').in('submission_id', subIds);
const delIds = (deliveries || []).map(d => d.id);
if (delIds.length) {
const { data: delFiles } = await admin.from('delivery_files').select('storage_path').in('delivery_id', delIds);
const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean);
if (delPaths.length) await admin.storage.from('deliveries').remove(delPaths);
}
}
const { error } = await admin.from('tasks').delete().eq('id', taskId);
if (error) return res.status(500).json({ error: error.message });
return res.status(200).json({ ok: true });
}
+411
View File
@@ -0,0 +1,411 @@
import { createClient } from '@supabase/supabase-js';
const FB_SOURCE = 'srv';
const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
export const config = { api: { bodyParser: false, sizeLimit: '50mb' } };
function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(body));
}
function normalizePath(path) {
const raw = String(path || '/').trim();
const parts = raw.split('/').filter(Boolean);
const clean = [];
for (const part of parts) {
if (part === '.') continue;
if (part === '..') throw new Error('Invalid path: path traversal not allowed');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function safeName(value, fallback = '') {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
function parseBody(body) {
if (!body) return {};
if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch {
return {};
}
}
return body;
}
async function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function getConfig() {
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
return {
url,
token: process.env.FILEBROWSER_TOKEN || '',
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
configured: Boolean(url),
};
}
function getToken(config) {
const token = String(config.token || '').trim();
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
return token;
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const url = `${config.url}${endpoint}?${qs}`;
const token = getToken(config);
const response = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, ...headers },
body,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
const error = new Error(text || `FileBrowser ${response.status}`);
error.status = response.status;
throw error;
}
const text = await response.text();
try {
return text ? JSON.parse(text) : null;
} catch {
return text;
}
}
async function createCallerClient(authHeader) {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) throw new Error('Supabase env not configured');
return createClient(supabaseUrl, supabaseAnonKey, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: authHeader } },
});
}
async function requirePortalUser(authHeader) {
const callerClient = await createCallerClient(authHeader);
const { data: userData, error: userError } = await callerClient.auth.getUser();
if (userError || !userData?.user) return { ok: false, status: 401, message: 'Unauthorized' };
const { data: profile, error: profileError } = await callerClient
.from('profiles')
.select('id, role, company:companies(id, name)')
.eq('id', userData.user.id)
.single();
if (profileError) return { ok: false, status: 500, message: profileError.message };
if (!['team', 'client'].includes(profile?.role)) {
return { ok: false, status: 403, message: 'Forbidden' };
}
const clientCompanies = [];
if (profile.role === 'client') {
const seen = new Set();
if (profile.company?.id) {
clientCompanies.push(profile.company);
seen.add(profile.company.id);
}
const { data: memberships } = await callerClient
.from('company_members')
.select('company:companies(id, name)')
.eq('profile_id', userData.user.id);
for (const row of memberships || []) {
if (row.company?.id && !seen.has(row.company.id)) {
seen.add(row.company.id);
clientCompanies.push(row.company);
}
}
}
return {
ok: true,
profile: { ...profile, clientCompanies },
};
}
function createAdminClient() {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
return createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
function isAlreadyExistsError(error) {
const message = String(error?.message || '').toLowerCase();
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
}
async function ensureDirectory(config, fullPath) {
const parts = normalizePath(fullPath).split('/').filter(Boolean);
let current = '/';
for (const part of parts) {
current = joinPath(current, part);
try {
await fbFetch(config, 'POST', '/api/resources', {
params: { path: current, isDir: 'true' },
});
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
}
async function renameDirectory(config, fromPath, toPath) {
const sourcePath = normalizePath(fromPath);
const targetPath = normalizePath(toPath);
if (sourcePath === targetPath) return { renamed: false, skipped: true, path: targetPath };
try {
await fbFetch(config, 'PATCH', '/api/resources', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'rename',
items: [{ fromSource: FB_SOURCE, fromPath: sourcePath, toSource: FB_SOURCE, toPath: targetPath }],
overwrite: false,
}),
});
return { renamed: true, path: targetPath };
} catch (error) {
if (error?.status === 404) {
await ensureDirectory(config, targetPath);
return { renamed: false, ensured: true, path: targetPath };
}
throw error;
}
}
export default async function handler(req, res) {
const action = String(req.query?.action || '').trim();
if (req.method !== 'POST' || ![
'ensure-company-folder',
'ensure-project-folder',
'ensure-request-folder',
'ensure-profile-folder',
'upload-request-file',
'rename-company-folder',
'rename-project-folder',
'rename-profile-folder',
].includes(action)) {
return json(res, 405, { error: 'Method not allowed' });
}
const authHeader = req.headers.authorization || '';
if (!authHeader.startsWith('Bearer ')) {
return json(res, 401, { error: 'Unauthorized' });
}
const config = getConfig();
if (!config.configured) {
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
}
try {
const rawBody = await readBody(req);
const body = (() => {
if (!rawBody.length) return {};
const contentType = String(req.headers['content-type'] || '').split(';')[0].trim();
if (contentType === 'application/json') {
try {
return JSON.parse(rawBody.toString());
} catch {
return {};
}
}
return {};
})();
const auth = await requirePortalUser(authHeader);
if (!auth.ok) return json(res, auth.status, { error: auth.message });
const admin = createAdminClient();
if (action === 'ensure-company-folder') {
if (auth.profile.role !== 'team') {
return json(res, 403, { error: 'Forbidden' });
}
const companyId = String(body.companyId || '').trim();
if (!companyId) return json(res, 400, { error: 'companyId is required.' });
const { data: company, error: companyError } = await admin
.from('companies')
.select('id, name')
.eq('id', companyId)
.single();
if (companyError || !company) return json(res, 404, { error: 'Company not found.' });
const fullPath = joinPath(config.clientRoot, safeName(company.name, company.id));
await ensureDirectory(config, fullPath);
return json(res, 200, { ok: true, configured: true, path: fullPath });
}
if (action === 'rename-company-folder') {
if (auth.profile.role !== 'team') {
return json(res, 403, { error: 'Forbidden' });
}
const companyId = String(body.companyId || '').trim();
const oldName = safeName(body.oldName, '');
const newName = safeName(body.newName, '');
if (!companyId || !oldName || !newName) return json(res, 400, { error: 'companyId, oldName, and newName are required.' });
const { data: company, error: companyError } = await admin
.from('companies')
.select('id')
.eq('id', companyId)
.single();
if (companyError || !company) return json(res, 404, { error: 'Company not found.' });
const result = await renameDirectory(config, joinPath(config.clientRoot, oldName), joinPath(config.clientRoot, newName));
return json(res, 200, { ok: true, configured: true, ...result });
}
if (action === 'ensure-profile-folder') {
if (auth.profile.role !== 'team') {
return json(res, 403, { error: 'Forbidden' });
}
const profileId = String(body.profileId || '').trim();
if (!profileId) return json(res, 400, { error: 'profileId is required.' });
const { data: profile, error: profileError } = await admin
.from('profiles')
.select('id, name, role')
.eq('id', profileId)
.single();
if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' });
if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' });
const fullPath = joinPath(config.subsRoot, safeName(profile.name, profile.id));
await ensureDirectory(config, fullPath);
return json(res, 200, { ok: true, configured: true, path: fullPath });
}
if (action === 'rename-profile-folder') {
if (auth.profile.role !== 'team') {
return json(res, 403, { error: 'Forbidden' });
}
const profileId = String(body.profileId || '').trim();
const oldName = safeName(body.oldName, '');
const newName = safeName(body.newName, '');
if (!profileId || !oldName || !newName) return json(res, 400, { error: 'profileId, oldName, and newName are required.' });
const { data: profile, error: profileError } = await admin
.from('profiles')
.select('id, role')
.eq('id', profileId)
.single();
if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' });
if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' });
const result = await renameDirectory(config, joinPath(config.subsRoot, oldName), joinPath(config.subsRoot, newName));
return json(res, 200, { ok: true, configured: true, ...result });
}
const projectId = String(req.query?.projectId || body.projectId || '').trim();
if (!projectId) return json(res, 400, { error: 'projectId is required.' });
const { data: project, error: projectError } = await admin
.from('projects')
.select('id, name, company_id, company:companies(id, name)')
.eq('id', projectId)
.single();
if (projectError || !project) return json(res, 404, { error: 'Project not found.' });
if (auth.profile.role === 'client') {
const allowedCompanyIds = new Set((auth.profile.clientCompanies || []).map(company => company.id));
if (!allowedCompanyIds.has(project.company_id)) return json(res, 403, { error: 'Forbidden' });
}
const companyName = safeName(project.company?.name, String(project.company_id || 'company'));
const projectName = safeName(project.name, projectId);
if (action === 'rename-project-folder') {
const oldName = safeName(body.oldName, '');
const newName = safeName(body.newName, '');
if (!oldName || !newName) return json(res, 400, { error: 'oldName and newName are required.' });
const result = await renameDirectory(
config,
joinPath(config.clientRoot, companyName, 'Projects', oldName),
joinPath(config.clientRoot, companyName, 'Projects', newName)
);
return json(res, 200, { ok: true, configured: true, ...result });
}
if (action === 'ensure-project-folder') {
const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName);
await ensureDirectory(config, fullPath);
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
await ensureDirectory(config, joinPath(fullPath, folderName));
}
return json(res, 200, { ok: true, configured: true, path: fullPath });
}
const taskTitle = safeName(req.query?.taskTitle || body.taskTitle, '');
if (!taskTitle) return json(res, 400, { error: 'taskTitle is required.' });
const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName, taskTitle);
if (action === 'upload-request-file') {
const bucket = String(body.bucket || 'submissions').trim();
const storagePath = String(body.storagePath || '').trim();
const fileName = safeName(body.fileName || req.query?.fileName || req.headers['x-file-name'], '');
if (!storagePath) return json(res, 400, { error: 'storagePath is required.' });
if (!fileName) return json(res, 400, { error: 'fileName is required.' });
const surveyPath = joinPath(fullPath, 'Survey');
await ensureDirectory(config, surveyPath);
const { data: downloadedFile, error: downloadError } = await admin.storage.from(bucket).download(storagePath);
if (downloadError || !downloadedFile) {
return json(res, 500, { error: downloadError?.message || 'Failed to download request file from storage.' });
}
const fileBuffer = Buffer.from(await downloadedFile.arrayBuffer());
const uploadUrl = `${config.url}/api/resources?source=${FB_SOURCE}&path=${encodeURIComponent(joinPath(surveyPath, fileName))}&override=true`;
const contentType = String(downloadedFile.type || '').trim() || 'application/octet-stream';
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${getToken(config)}`,
'Content-Type': contentType,
},
body: fileBuffer,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return json(res, response.status, { error: text || `Upload failed (${response.status})` });
}
return json(res, 200, { ok: true, configured: true, path: joinPath(surveyPath, fileName) });
}
await ensureDirectory(config, fullPath);
for (const folderName of REQUEST_DEFAULT_SUBFOLDERS) {
await ensureDirectory(config, joinPath(fullPath, folderName));
}
return json(res, 200, { ok: true, configured: true, path: fullPath });
} catch (error) {
return json(res, error?.status || 500, { error: error?.message || 'Failed to ensure request folder.' });
}
}
-515
View File
@@ -1,515 +0,0 @@
import { createClient } from '@supabase/supabase-js';
const DIRECTORY_USAGE_CACHE_TTL_MS = 2 * 60 * 1000;
const directoryUsageCache = new Map();
function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(body));
}
function normalizeBaseUrl(url) {
return String(url || '').trim().replace(/\/+$/, '');
}
function normalizePath(path) {
const raw = String(path || '/').trim();
const parts = raw.split('/').filter(Boolean);
const clean = [];
for (const part of parts) {
if (part === '.' || part === '') continue;
if (part === '..') throw new Error('Invalid path');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function safeName(value, fallback) {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
function fillTemplate(template, profile) {
const name = safeName(profile.name, profile.id);
const companyName = safeName(profile.company?.name, name);
const email = safeName(profile.email, profile.id);
const emailName = safeName(String(profile.email || '').split('@')[0], profile.id);
return String(template || '')
.replaceAll('{id}', profile.id)
.replaceAll('{name}', name)
.replaceAll('{companyName}', companyName)
.replaceAll('{email}', email)
.replaceAll('{emailName}', emailName);
}
function getConfig() {
const serverUrl = normalizeBaseUrl(process.env.SEAFILE_SERVER_URL);
const apiToken = process.env.SEAFILE_API_TOKEN;
const repoId = process.env.SEAFILE_REPO_ID;
return {
serverUrl,
webUrl: normalizeBaseUrl(process.env.SEAFILE_WEB_URL || serverUrl),
apiToken,
repoId,
teamRoot: normalizePath(process.env.SEAFILE_TEAM_ROOT_PATH || '/'),
externalRoot: normalizePath(process.env.SEAFILE_EXTERNAL_ROOT_PATH || '/Subcontractors'),
externalTemplate: process.env.SEAFILE_EXTERNAL_FOLDER_TEMPLATE || '{name}',
clientRoot: normalizePath(process.env.SEAFILE_CLIENT_ROOT_PATH || '/Clients'),
clientTemplate: process.env.SEAFILE_CLIENT_FOLDER_TEMPLATE || '{companyName}',
configured: Boolean(serverUrl && apiToken && repoId),
};
}
async function createCallerClient(authHeader) {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase auth env is not configured on Vercel.');
}
return createClient(supabaseUrl, supabaseAnonKey, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: authHeader } },
});
}
async function requirePortalUser(authHeader) {
const callerClient = await createCallerClient(authHeader);
const { data: userData, error: userError } = await callerClient.auth.getUser();
if (userError || !userData?.user) {
return { ok: false, status: 401, message: 'Unauthorized' };
}
const { data: profile, error: profileError } = await callerClient
.from('profiles')
.select('id, name, role, company:companies(id, name)')
.eq('id', userData.user.id)
.single();
if (profileError) {
return { ok: false, status: 500, message: profileError.message };
}
if (!['team', 'external', 'client'].includes(profile?.role)) {
return { ok: false, status: 403, message: 'Forbidden' };
}
return {
ok: true,
callerClient,
profile: {
...profile,
email: userData.user.email,
},
};
}
function getUserRoot(config, profile) {
if (profile.role === 'team') return config.teamRoot;
if (profile.role === 'client') {
const templated = fillTemplate(config.clientTemplate, profile);
if (templated.startsWith('/')) return normalizePath(templated);
return joinPath(config.clientRoot, templated);
}
const templated = fillTemplate(config.externalTemplate, profile);
if (templated.startsWith('/')) return normalizePath(templated);
return joinPath(config.externalRoot, templated);
}
function resolveSeafilePath(config, profile, requestedPath = '/') {
const root = getUserRoot(config, profile);
const virtualPath = normalizePath(requestedPath);
return {
root,
virtualPath,
seafilePath: joinPath(root, virtualPath),
};
}
async function seafileRequest(config, endpoint, options = {}) {
const response = await fetch(`${config.serverUrl}${endpoint}`, {
...options,
headers: {
Authorization: `Token ${config.apiToken}`,
Accept: 'application/json',
...(options.headers || {}),
},
});
const text = await response.text();
let body = text;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
if (!response.ok) {
const message = typeof body === 'object' && body?.error_msg ? body.error_msg : text || `Seafile returned ${response.status}`;
const error = new Error(message);
error.status = response.status;
throw error;
}
return body;
}
async function createSeafileFolder(config, path) {
const body = new URLSearchParams({ operation: 'mkdir', create_parents: 'true' });
await seafileRequest(config, `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
}
async function createSeafileFolderIfMissing(config, path) {
try {
await createSeafileFolder(config, path);
return true;
} catch (error) {
const message = String(error.message || '').toLowerCase();
if (error.status === 400 || message.includes('already') || message.includes('exist')) return false;
throw error;
}
}
function getCachedDirectoryUsage(cacheKey) {
const cached = directoryUsageCache.get(cacheKey);
if (!cached) return null;
if ((Date.now() - cached.timestamp) > DIRECTORY_USAGE_CACHE_TTL_MS) {
directoryUsageCache.delete(cacheKey);
return null;
}
return cached.bytes;
}
function setCachedDirectoryUsage(cacheKey, bytes) {
directoryUsageCache.set(cacheKey, {
bytes: Number(bytes) || 0,
timestamp: Date.now(),
});
}
async function listDirectoryEntries(config, path) {
const entries = await seafileRequest(
config,
`/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`
);
return Array.isArray(entries) ? entries : [];
}
async function getDirectoryUsageBytes(config, path, prefetchedEntries = null) {
const normalizedPath = normalizePath(path);
const cacheKey = `${config.repoId}:${normalizedPath}`;
const cached = getCachedDirectoryUsage(cacheKey);
if (cached != null) return cached;
const entries = prefetchedEntries || await listDirectoryEntries(config, normalizedPath);
let total = 0;
for (const entry of entries) {
if (entry.type === 'file') {
total += Number(entry.size || 0);
continue;
}
if (entry.type === 'dir') {
total += await getDirectoryUsageBytes(config, joinPath(normalizedPath, entry.name));
}
}
setCachedDirectoryUsage(cacheKey, total);
return total;
}
function clearDirectoryUsageCache(config, path) {
const normalizedPath = normalizePath(path);
const prefixes = [];
let cursor = normalizedPath;
while (true) {
prefixes.push(`${config.repoId}:${cursor}`);
if (cursor === '/') break;
cursor = parentDir(cursor);
}
for (const key of prefixes) {
directoryUsageCache.delete(key);
}
}
function entryPath(parent, name) {
return joinPath(parent, name);
}
function parentDir(path) {
const normalized = normalizePath(path);
const parts = normalized.split('/').filter(Boolean);
parts.pop();
return `/${parts.join('/')}`;
}
function basename(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
async function syncManagedFolders(config, auth) {
if (auth.profile.role !== 'team') {
const error = new Error('Team only');
error.status = 403;
throw error;
}
const [{ data: companies, error: companiesError }, { data: externals, error: externalsError }] = await Promise.all([
auth.callerClient.from('companies').select('id, name').order('name'),
auth.callerClient.from('profiles').select('id, name, email, role').eq('role', 'external').order('name'),
]);
if (companiesError) throw new Error(companiesError.message);
if (externalsError) throw new Error(externalsError.message);
const folderPaths = [
config.clientRoot,
config.externalRoot,
...(companies || []).map(company => {
const profile = { id: company.id, name: company.name, email: '', company };
const templated = fillTemplate(config.clientTemplate, profile);
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.clientRoot, templated);
}),
...(externals || []).map(profile => {
const templated = fillTemplate(config.externalTemplate, profile);
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.externalRoot, templated);
}),
];
const uniquePaths = [...new Set(folderPaths)].filter(path => path !== '/');
let created = 0;
for (const path of uniquePaths) {
if (await createSeafileFolderIfMissing(config, path)) created += 1;
}
return {
created,
checked: uniquePaths.length,
clients: companies?.length || 0,
subcontractors: externals?.length || 0,
};
}
export default async function handler(req, res) {
try {
const authHeader = req.headers.authorization || '';
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
const auth = await requirePortalUser(authHeader);
if (!auth.ok) return json(res, auth.status, { error: auth.message });
const config = getConfig();
if (!config.configured) {
return json(res, 200, {
configured: false,
error: 'Seafile is not configured yet.',
requiredEnv: ['SEAFILE_SERVER_URL', 'SEAFILE_API_TOKEN', 'SEAFILE_REPO_ID'],
});
}
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
const requestedPath = req.query.path || req.body?.path;
const resolved = resolveSeafilePath(config, auth.profile, requestedPath || '/');
const invalidateUsage = req.query.invalidateUsage === '1';
if (req.method === 'POST' && action === 'sync-folders') {
const result = await syncManagedFolders(config, auth);
return json(res, 200, { success: true, ...result });
}
if (req.method === 'GET' && action === 'config') {
return json(res, 200, {
configured: true,
role: auth.profile.role,
root: resolved.root,
webUrl: config.webUrl,
});
}
if (req.method === 'GET' && action === 'list') {
if (invalidateUsage) clearDirectoryUsageCache(config, resolved.seafilePath);
let entries;
try {
entries = await listDirectoryEntries(config, resolved.seafilePath);
} catch (error) {
if (!['external', 'client'].includes(auth.profile.role) || resolved.virtualPath !== '/') throw error;
await createSeafileFolder(config, resolved.root);
entries = await listDirectoryEntries(config, resolved.seafilePath);
}
const normalizedEntries = (Array.isArray(entries) ? entries : []).map((item) => {
const itemPath = entryPath(resolved.virtualPath, item.name);
return {
id: item.id,
name: item.name,
type: item.type,
size: item.type === 'file' ? Number(item.size || 0) : 0,
aggregateSize: item.type === 'file' ? Number(item.size || 0) : null,
mtime: item.mtime || null,
permission: item.permission || null,
path: itemPath,
};
}).sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return json(res, 200, {
configured: true,
path: resolved.virtualPath,
canGoUp: resolved.virtualPath !== '/',
parentPath: parentDir(resolved.virtualPath),
entries: normalizedEntries,
webUrl: config.webUrl,
});
}
if (req.method === 'GET' && action === 'download') {
const url = await seafileRequest(
config,
`/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}&reuse=1`
);
return json(res, 200, { url });
}
if (req.method === 'POST' && action === 'mkdir') {
const folderName = safeName(req.body?.name, '');
if (!folderName) return json(res, 400, { error: 'Folder name is required.' });
const folderPath = joinPath(resolved.seafilePath, folderName);
await createSeafileFolder(config, folderPath);
clearDirectoryUsageCache(config, resolved.seafilePath);
return json(res, 200, { success: true });
}
if (req.method === 'POST' && action === 'upload-link') {
const uploadLink = await seafileRequest(
config,
`/api2/repos/${encodeURIComponent(config.repoId)}/upload-link/?p=${encodeURIComponent(resolved.seafilePath)}`
);
return json(res, 200, {
uploadLink: typeof uploadLink === 'string' ? uploadLink : String(uploadLink || ''),
parentDir: resolved.seafilePath,
});
}
if (req.method === 'POST' && action === 'rename') {
const newName = safeName(req.body?.name, '');
if (!newName) return json(res, 400, { error: 'New name is required.' });
const type = req.body?.type || 'file';
const endpoint = type === 'dir'
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolved.seafilePath)}`
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}`;
const body = new URLSearchParams({ operation: 'rename', newname: newName });
await seafileRequest(config, endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
return json(res, 200, { success: true });
}
if (req.method === 'POST' && action === 'move') {
const srcPath = req.body?.srcPath;
const dstDir = req.body?.dstDir;
if (!srcPath || !dstDir) return json(res, 400, { error: 'srcPath and dstDir are required.' });
const resolvedSrc = resolveSeafilePath(config, auth.profile, srcPath);
const resolvedDst = resolveSeafilePath(config, auth.profile, dstDir);
const itemName = basename(resolvedSrc.seafilePath);
const srcDir = parentDir(resolvedSrc.seafilePath);
if (!itemName) return json(res, 400, { error: 'Cannot move root.' });
const type = req.body?.type || 'file';
const endpoint = type === 'dir'
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`;
const body = new URLSearchParams({
operation: 'move',
dst_repo: config.repoId,
dst_dir: resolvedDst.seafilePath,
});
await seafileRequest(config, endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
clearDirectoryUsageCache(config, srcDir);
clearDirectoryUsageCache(config, resolvedDst.seafilePath);
return json(res, 200, { success: true });
}
if (req.method === 'DELETE' && action === 'delete') {
const type = req.query.type || req.body?.type;
if (!['file', 'dir'].includes(type)) return json(res, 400, { error: 'Valid item type is required.' });
const itemName = basename(resolved.seafilePath);
const itemParent = parentDir(resolved.seafilePath);
if (!itemName) return json(res, 400, { error: 'Cannot delete the root folder.' });
const body = new URLSearchParams({
file_names: itemName,
});
await seafileRequest(
config,
`/api2/repos/${encodeURIComponent(config.repoId)}/fileops/delete/?p=${encodeURIComponent(itemParent)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
}
);
clearDirectoryUsageCache(config, itemParent);
return json(res, 200, { success: true });
}
return json(res, 405, { error: 'Method not allowed' });
} catch (error) {
return json(res, error.status || 500, { error: error.message || 'Unexpected Seafile error' });
}
}
@@ -0,0 +1,686 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Checked Sites Revision Audit</title>
<style>
:root {
--bg: #f5f1e8;
--panel: rgba(255,255,255,0.78);
--panel-strong: rgba(255,255,255,0.92);
--border: rgba(43,37,28,0.12);
--text: #231d14;
--muted: #6b6255;
--accent: #b8831f;
--accent-soft: rgba(184,131,31,0.18);
--ok: #1f8a4c;
--ok-soft: rgba(31,138,76,0.12);
--warn: #b45309;
--warn-soft: rgba(180,83,9,0.14);
--review: #2563eb;
--review-soft: rgba(37,99,235,0.13);
--todo: #7c3aed;
--todo-soft: rgba(124,58,237,0.12);
--shadow: 0 18px 40px rgba(35,29,20,0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(184,131,31,0.14), transparent 28%),
radial-gradient(circle at top right, rgba(37,99,235,0.09), transparent 24%),
linear-gradient(180deg, #fbf8f2 0%, var(--bg) 100%);
}
.wrap {
width: min(1400px, calc(100vw - 40px));
margin: 32px auto 48px;
}
.hero, .panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.hero {
padding: 28px 30px;
margin-bottom: 24px;
}
.eyebrow {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 10px;
}
h1 {
margin: 0 0 10px;
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.04em;
}
.subtitle {
margin: 0;
color: var(--muted);
font-size: 15px;
max-width: 900px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
padding: 20px 22px;
}
.label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 8px;
}
.value {
font-size: 34px;
line-height: 1;
letter-spacing: -0.05em;
}
.sub {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.panel {
padding: 20px 22px;
}
h2 {
margin: 0 0 14px;
font-size: 18px;
letter-spacing: -0.03em;
}
.bar-row {
display: grid;
grid-template-columns: 150px 1fr 36px;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.bar-label, .bar-value {
font-size: 13px;
}
.bar-track {
height: 10px;
border-radius: 999px;
background: rgba(35,29,20,0.08);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), #d7a84b);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(35,29,20,0.08);
vertical-align: top;
text-align: left;
font-size: 13px;
}
th {
position: sticky;
top: 0;
background: var(--panel-strong);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
z-index: 1;
}
.mono {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
border: 1px solid transparent;
white-space: nowrap;
}
.ok { color: var(--ok); background: var(--ok-soft); border-color: rgba(31,138,76,0.2); }
.warn { color: var(--warn); background: var(--warn-soft); border-color: rgba(180,83,9,0.2); }
.review { color: var(--review); background: var(--review-soft); border-color: rgba(37,99,235,0.2); }
.todo { color: var(--todo); background: var(--todo-soft); border-color: rgba(124,58,237,0.18); }
.neutral { color: var(--muted); background: rgba(35,29,20,0.06); border-color: rgba(35,29,20,0.08); }
.section {
margin-bottom: 24px;
}
.small {
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1100px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.two-col { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.wrap { width: min(100vw - 20px, 1400px); margin: 20px auto 32px; }
.hero, .panel { padding: 18px; border-radius: 14px; }
.grid { grid-template-columns: 1fr; }
h1 { font-size: 28px; }
}
</style>
</head>
<body>
<div class="wrap">
<section class="hero">
<div class="eyebrow">Fourge Portal Audit</div>
<h1>Checked Sites Revision Audit</h1>
<p class="subtitle">This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. It is designed to make client-billing comparison easier at a glance.</p>
</section>
<section class="grid">
<div class="panel card">
<div class="label">Checked Sites</div>
<div class="value">26</div>
<div class="sub">Sites found in the database from your checked list.</div>
</div>
<div class="panel card">
<div class="label">Active Revisions</div>
<div class="value">16</div>
<div class="sub">Checked sites that now have an active R01+.</div>
</div>
<div class="panel card">
<div class="label">R00 Billed</div>
<div class="value">23</div>
<div class="sub">Checked sites where the new-book unit is already on an invoice.</div>
</div>
<div class="panel card">
<div class="label">R00 Unbilled</div>
<div class="value">3</div>
<div class="sub">Checked sites completed at R00 but not yet billed.</div>
</div>
</section>
<section class="two-col">
<div class="panel">
<h2>Active Revision Status</h2>
<div class="bar-row">
<div class="bar-label">not_started</div>
<div class="bar-track"><div class="bar-fill" style="width:100%"></div></div>
<div class="bar-value">12</div>
</div>
<div class="bar-row">
<div class="bar-label">client_review</div>
<div class="bar-track"><div class="bar-fill" style="width:25%"></div></div>
<div class="bar-value">3</div>
</div>
<div class="bar-row">
<div class="bar-label">client_approved</div>
<div class="bar-track"><div class="bar-fill" style="width:8%"></div></div>
<div class="bar-value">1</div>
</div>
</div>
<div class="panel">
<h2>Active Revision Depth</h2>
<div class="bar-row">
<div class="bar-label">R01</div>
<div class="bar-track"><div class="bar-fill" style="width:100%"></div></div>
<div class="bar-value">10</div>
</div>
<div class="bar-row">
<div class="bar-label">R02</div>
<div class="bar-track"><div class="bar-fill" style="width:60%"></div></div>
<div class="bar-value">6</div>
</div>
</div>
</section>
<section class="panel section">
<h2>Sites With Active Revisions</h2>
<p class="small">These are the ones where R00 may be done, but a newer version is still active or awaiting review/approval.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:90px;">Site #</th>
<th style="width:240px;">DB Title</th>
<th style="width:80px;">Internal #</th>
<th style="width:96px;">R00 Done</th>
<th style="width:180px;">R00 Billed</th>
<th style="width:90px;">Active R#</th>
<th style="width:120px;">Active Status</th>
<th style="width:170px;">Completed Revisions</th>
<th style="width:220px;">Billed Versions</th>
<th>Version Summary</th>
</tr>
</thead>
<tbody>
<tr>
<td class="mono">00286</td>
<td>00286 Riverside, CA</td>
<td class="mono">31</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">30055</td>
<td>30055 Moreno Valley, CA</td>
<td class="mono">33</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill review">client_review</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=client_review</td>
</tr>
<tr>
<td class="mono">30096</td>
<td>30096 Eerie, CO</td>
<td class="mono">36</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (unbilled)</td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed; R02=not_started</td>
</tr>
<tr>
<td class="mono">68639</td>
<td>68639 NSA Los Lunas NM</td>
<td class="mono">40</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill review">client_review</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=client_review</td>
</tr>
<tr>
<td class="mono">68663</td>
<td>68663 NSA Oklahoma City OK</td>
<td class="mono">41</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68664</td>
<td>68664 NSA Oklahoma City OK</td>
<td class="mono">42</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68666</td>
<td>68666 NSA Oklahoma City, OK</td>
<td class="mono">43</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68669</td>
<td>68669 NSA Oklahoma City OK</td>
<td class="mono">44</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68670</td>
<td>68670 NSA Oklahoma City OK</td>
<td class="mono">45</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (billed)</td>
<td>R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed/billed; R02=not_started</td>
</tr>
<tr>
<td class="mono">68671</td>
<td>68671 NSA Oklahoma City OK</td>
<td class="mono">46</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68673</td>
<td>68673 NSA Oklahoma City OK</td>
<td class="mono">47</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill review">client_review</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=client_review</td>
</tr>
<tr>
<td class="mono">68721</td>
<td>68721 NSA Mechanicsburg PA</td>
<td class="mono">48</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill approved">client_approved</span></td>
<td>R01 (billed), R02 (unbilled)</td>
<td>R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed/billed; R02=completed</td>
</tr>
<tr>
<td class="mono">68808</td>
<td>68808 Camas, WA</td>
<td class="mono">49</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R01</td>
<td><span class="pill todo">not_started</span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=not_started</td>
</tr>
<tr>
<td class="mono">68809</td>
<td>68809 Centralia, WA</td>
<td class="mono">50</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (billed)</td>
<td>R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed/billed; R02=not_started</td>
</tr>
<tr>
<td class="mono">68810</td>
<td>68810 Chehalis, WA</td>
<td class="mono">51</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (billed)</td>
<td>R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed/billed; R02=not_started</td>
</tr>
<tr>
<td class="mono">68811</td>
<td>68811 Kelso, WA</td>
<td class="mono">52</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill review">Yes</span></td>
<td class="mono">R02</td>
<td><span class="pill todo">not_started</span></td>
<td>R01 (unbilled)</td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed; R01=completed; R02=not_started</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="panel section">
<h2>Checked Sites With No Active Revision</h2>
<p class="small">These are simpler to read: R00 completed, and no current revision chain is active right now.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:90px;">Site #</th>
<th style="width:240px;">DB Title</th>
<th style="width:80px;">Internal #</th>
<th style="width:96px;">R00 Done</th>
<th style="width:180px;">R00 Billed</th>
<th style="width:90px;">Active R#</th>
<th style="width:120px;">Active Status</th>
<th style="width:170px;">Completed Revisions</th>
<th style="width:220px;">Billed Versions</th>
<th>Version Summary</th>
</tr>
</thead>
<tbody>
<tr>
<td class="mono">30062</td>
<td>30062 Riverside, CA</td>
<td class="mono">34</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">30094</td>
<td>30094 Colorado Springs, CO</td>
<td class="mono">35</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill warn">No</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td></td>
<td>R00=completed</td>
</tr>
<tr>
<td class="mono">30258</td>
<td>30258 Lebanon, NH</td>
<td class="mono">38</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">30259</td>
<td>30259 Hamburg, NJ</td>
<td class="mono">39</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">30261</td>
<td>30261 NSA Clovis NM</td>
<td class="mono">70</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill warn">No</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td></td>
<td>R00=completed</td>
</tr>
<tr>
<td class="mono">68720</td>
<td>68720 NSA Lancaster PA</td>
<td class="mono">74</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill warn">No</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td></td>
<td>R00=completed</td>
</tr>
<tr>
<td class="mono">68724</td>
<td>68724 NSA York PA</td>
<td class="mono">75</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">68752</td>
<td>68752 NSA Brownsville TX</td>
<td class="mono">76</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">68753</td>
<td>68753 NSA Brownsville TX</td>
<td class="mono">77</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
<tr>
<td class="mono">68758</td>
<td>68758 NSA Brownsville TX</td>
<td class="mono">81</td>
<td><span class="pill ok">Yes</span></td>
<td><span class="pill ok">INV-2026-006 (sent)</span></td>
<td><span class="pill neutral">No</span></td>
<td class="mono"></td>
<td><span class="pill neutral"></span></td>
<td></td>
<td>R00: INV-2026-006 (sent)</td>
<td>R00=completed/billed</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</body>
</html>
@@ -0,0 +1,340 @@
[
{
"site": "00286",
"dbTitle": "00286 Riverside, CA",
"internalTaskNumber": 31,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "not_started",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=not_started"
},
{
"site": "30055",
"dbTitle": "30055 Moreno Valley, CA",
"internalTaskNumber": 33,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "client_review",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=client_review"
},
{
"site": "30062",
"dbTitle": "30062 Riverside, CA",
"internalTaskNumber": 34,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed"
},
{
"site": "30094",
"dbTitle": "30094 Colorado Springs, CO",
"internalTaskNumber": 35,
"r00Completed": "Yes",
"r00Billed": "No",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "—",
"versionSummary": "R00=completed"
},
{
"site": "30096",
"dbTitle": "30096 Eerie, CO",
"internalTaskNumber": 36,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R02",
"activeStatus": "not_started",
"completedRevisions": "R01 (unbilled)",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=completed; R02=not_started"
},
{
"site": "30258",
"dbTitle": "30258 Lebanon, NH",
"internalTaskNumber": 38,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed"
},
{
"site": "30259",
"dbTitle": "30259 Hamburg, NJ",
"internalTaskNumber": 39,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed"
},
{
"site": "30261",
"dbTitle": "30261 NSA Clovis NM",
"internalTaskNumber": 70,
"r00Completed": "Yes",
"r00Billed": "No",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "—",
"versionSummary": "R00=completed"
},
{
"site": "68639",
"dbTitle": "68639 NSA Los Lunas NM",
"internalTaskNumber": 40,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "client_review",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=client_review"
},
{
"site": "68663",
"dbTitle": "68663 NSA Oklahoma City OK",
"internalTaskNumber": 41,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "not_started",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=not_started"
},
{
"site": "68664",
"dbTitle": "68664 NSA Oklahoma City OK",
"internalTaskNumber": 42,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "not_started",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=not_started"
},
{
"site": "68666",
"dbTitle": "68666 NSA Oklahoma City, OK",
"internalTaskNumber": 43,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "not_started",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=not_started"
},
{
"site": "68669",
"dbTitle": "68669 NSA Oklahoma City OK",
"internalTaskNumber": 44,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "not_started",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=not_started"
},
{
"site": "68670",
"dbTitle": "68670 NSA Oklahoma City OK",
"internalTaskNumber": 45,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R02",
"activeStatus": "not_started",
"completedRevisions": "R01 (billed)",
"billedVersions": "R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=completed/billed; R02=not_started"
},
{
"site": "68671",
"dbTitle": "68671 NSA Oklahoma City OK",
"internalTaskNumber": 46,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "not_started",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=not_started"
},
{
"site": "68673",
"dbTitle": "68673 NSA Oklahoma City OK",
"internalTaskNumber": 47,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "client_review",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=client_review"
},
{
"site": "68720",
"dbTitle": "68720 NSA Lancaster PA",
"internalTaskNumber": 74,
"r00Completed": "Yes",
"r00Billed": "No",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "—",
"versionSummary": "R00=completed"
},
{
"site": "68721",
"dbTitle": "68721 NSA Mechanicsburg PA",
"internalTaskNumber": 48,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R02",
"activeStatus": "client_approved",
"completedRevisions": "R01 (billed), R02 (unbilled)",
"billedVersions": "R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=completed/billed; R02=completed"
},
{
"site": "68724",
"dbTitle": "68724 NSA York PA",
"internalTaskNumber": 75,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed"
},
{
"site": "68752",
"dbTitle": "68752 NSA Brownsville TX",
"internalTaskNumber": 76,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed"
},
{
"site": "68753",
"dbTitle": "68753 NSA Brownsville TX",
"internalTaskNumber": 77,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed"
},
{
"site": "68758",
"dbTitle": "68758 NSA Brownsville TX",
"internalTaskNumber": 81,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "No",
"activeRevision": "—",
"activeStatus": "—",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed"
},
{
"site": "68808",
"dbTitle": "68808 Camas, WA",
"internalTaskNumber": 49,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R01",
"activeStatus": "not_started",
"completedRevisions": "—",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=not_started"
},
{
"site": "68809",
"dbTitle": "68809 Centralia, WA",
"internalTaskNumber": 50,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R02",
"activeStatus": "not_started",
"completedRevisions": "R01 (billed)",
"billedVersions": "R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=completed/billed; R02=not_started"
},
{
"site": "68810",
"dbTitle": "68810 Chehalis, WA",
"internalTaskNumber": 51,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R02",
"activeStatus": "not_started",
"completedRevisions": "R01 (billed)",
"billedVersions": "R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=completed/billed; R02=not_started"
},
{
"site": "68811",
"dbTitle": "68811 Kelso, WA",
"internalTaskNumber": 52,
"r00Completed": "Yes",
"r00Billed": "INV-2026-006 (sent)",
"hasActiveRevision": "Yes",
"activeRevision": "R02",
"activeStatus": "not_started",
"completedRevisions": "R01 (unbilled)",
"billedVersions": "R00: INV-2026-006 (sent)",
"versionSummary": "R00=completed/billed; R01=completed; R02=not_started"
}
]
@@ -0,0 +1,32 @@
# Checked Sites Revision Audit
Definition used: your checkbox means `R00` was completed at some point, not that the task has no active revisions now.
| Site # | DB Title | Internal # | R00 Completed | R00 Billed | Active Revision? | Active R# | Active Status | Completed Revisions | Billed Versions | Version Summary |
|---|---|---:|---|---|---|---|---|---|---|---|
| 00286 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 30055 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 30062 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 30094 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 30096 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 30258 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 30259 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 30261 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68639 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68663 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68664 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68666 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68669 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68670 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68671 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68673 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68720 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68721 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68724 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68752 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68753 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68758 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68808 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68809 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68810 | — | — | — | — | — | — | — | — | — | Missing from DB |
| 68811 | — | — | — | — | — | — | — | — | — | Missing from DB |
@@ -0,0 +1,118 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 11 0 R /MediaBox [ 0 0 1008 612 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 12 0 R /MediaBox [ 0 0 1008 612 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 1008 612 ] /Parent 10 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/PageMode /UseNone /Pages 10 0 R /Type /Catalog
>>
endobj
9 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260604232324-04'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260604232324-04'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
10 0 obj
<<
/Count 3 /Kids [ 5 0 R 6 0 R 7 0 R ] /Type /Pages
>>
endobj
11 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3045
>>
stream
Gb"/+D/\/u')q<+0u0K5Ru<Y^j)TE65PQfn?X::/'c.)l-to$RfLSt:U20P]hs;:rfqKnF)^CH.(Mg@hS=(DC4O<@l$jD6tWVJQE!:16Sh0TKn_;HP7&IB;E^sQQQIlgLW8YQ>UWJF-Gn.U9a!OJ8ddL%-cJ]''O3IH4\'+'Oti&n.FVg'4@^[uoO,W.VRUmqq]VTt]2n44fR0EQk5m/EI0j?UJ8_4NE+USn*G#5Pf]5qE\V?5pFQ.BOE0[+KTkRBqHZ$[1F"IH^]Q"M^Q$#C@i&pYkUdYb:QD;Qc9KmcC]0L%9o90gsMJ+O]-,oSG$6S&0ieKc+gq*fT[KVu.IYM^k>d5Q@dO(Xj#jDQet5V,8dL@['F4c29q-CDA_@5nCrSWkkX$j6ce-SQ/M2nuS:trUb7;6Z)39Rsfm)%[W.E4s=m#mGW0XH(7t3oipbt:lY#Yk5"1VQK_s\UTB'.ZfqUqMXe2)D]gR6D+@X`6^cin1WY;NnQ1`SXUF8'**7Z&>j(_<VF25#;j_*fEYsgp]W?EcPZF@"ME,u7cfTqI<'fYp[sKpVb$c?nntf0)M(cF6,r'gB3)a9K`TiEP(J3QYMXR_=S"bQc],Ku=%4c;eB1sDI-VcAL-%:l*;&[Cj#T0I=![_?m9FIa-9`r:JC`8J(M>dPo)]AE*e<?`#AhV1On["(dIV:JPa,-9oIhHP?&4Z0d)^opEbOLTT2Ak$Qe0hu]M3df'djG%uIYlb89Nu)%_ktSkNt-V&3e[+b_9mPorKJ3n2G-[:-o%RG`oIIAI)G&6Ytm0PRl(i3XT(8n7H`ue)<Gh.iEaOe\P8,3lS&@Z`Oo@iaT')kJ*o_Y#+e)U-r'1X77f$F6<F17*.kd3B:!:RaqBdEh8V0]7?hbM3$'Nf+GgpoiFdd]kbA`BQ)H*n_F=omQu:Vn)!+5OP;q>XYEdb4?3eFgVOWN)9^@p_njtFi:A<2=W+Ej54KF&r'L/s]U,+&iN,UinZUi8Hn:MCeTFCE0#uiS*$J/Ei`@oPKVo2s'eNA!#@am,u7dM)2HqB"E3uArE*'sVgiqWHHm.AJR%"/GDnKl2fW4BF.hSCU-[`qFj]7!$Gq$gS8/%CD&i>*Om\4Gkc9'?>d,gscL%g+G@XMH2;mdjna$jSDAoiM_h2[1)/Bdt_;(L8NZ+.o&C*NmP1)PhQ8iEug6B.[B!)s-9b?,9o/hn,=P5J_2>QjQ"ritgYN^+RNMjdoho1tX($!GiD=!,N;4ic+)`ZN1.A3#2tY2*RM"`30Pb9[;UPH2oi0I/l0`<rX2EWRKQMnJ?I.:YkUj#*V%C[TE="Q&Gp:&#Yt;';gWnLc0#a0c"Tt,4tuQ[*7Lo<`_-n=DWQcr7b!2eJ0mT4[%NXbE$g]6=2P:\W;)P@rg8Uh=!ciJj!1/qa7Gba%flUGZ`"7C8;;`ljcM\*:%ji]e/L=f&T>4]!6$W@?s1@%c7,?S)4NGB9:q\7AUOHg`bFpj"nuQ*(<c":O@fencCYR<.Z;,X3D^[8'$T%n.tm-gn-=!c8-p+-$3@UMNk@A$G9l0Y?2?-;mi;iMHRJn,T=Fca>@0S;9B=Oij?`BCd9dMOT7M=6baZ?=[YV+3o#KV;2s=q;'hqrPd]VXb%G9DL4?Sq6e"HSLJas*oaoTZ?!0dO.bcm5frC(Q'FQa`CoE@H5?'A9;A]B3G*hb6EB_`!*>"iE=`q=XKr#3Tn\@?j=TeV4>q-*-C[WQSjKmAt-*h6`r,Bg:r,sj;B(r>U9S"1t@?DEfSVV1rFjCQ.d7XaD.uqh)hOqjt+3h'H2cV5H)WO:3oJ)YN`RdYV#CE!Q'QhHeG!Sr17SlQaM(B5H?)^0/;FQ\:FeP73Tc,BWmjPRI/='gY^7ga^d!oL=RqOG-Iq(=CM0tK^^22C)Y:5T3D*6BdS>enpZNf$cqqo&HcJIPW)rQR5@.d0>5br9@M9[dME%1+)(hm,G`+$jd;N=O+HAjQ2,G^>i/5%F1VHiQ7o<Pt*IUU>ICKn']gn:q$-geb1^p^4.Q"5,$,/t6-;]QjW5X;N*-Zp$Z^(%=)D5eS19oH55_^:@P7;G-IY3q?rQ-H=`Y^-[5g/'p\%UA!k]&9)sGipL(?\qmf%pAa9YUmqg`)Rc'#I`d8q@W`h]>soETl`VN>]Q0sYlWuP5\MDgNk<pD227"jUChsCcLPu_(E'gCIP""p]Bo7_(<6]jmVQM<dd[L6m:1gBZ*OWZ1r84ds2C744Ka=3@0D2]e)5-"[[Ie3KWQ+M(tGANU\t8@+8Xb:^T3p90W0mDJtg(BS:FT7'lcQJCH=F`l$ZaP$<5EWXH7Vm&[]QNXH6%6b%HFb+.RJY6/^k5YLToZ<IA$(J0s9@YsI$pknDXa##4T'h>SNJ"sDd=jmHu"EGu'o>@*uEL(F-q-ng7^VMc8gkRW1U\E;@n$5G8c28H%o,U$.&!&5DNoR%0EVK4`-/[II-rmJn'6!#X/(K\OcCUldYf>\OkA#F\:n/utaDs%ZI%+a&k:p.MZL'VTiW1=$M_ifUVnQco'$=q%&=QKg`cu?kTVf=V`:,4dWWof4[B0UV26Vn#DY%J1Lgfqr47(kL-#T>d?n3\3YgUEM7=2$4IKS6&^ZU8,9JimgF/2/r;%VeO:=Cr,j@YUQ!([^AgQ5Gk=<aJ>V=%3Q`"/hV'lH%X>;Gc^k3?][I4IZrb)\[l:6lMF)\fNZs1b9lmVi:+g"%ij1IaRg&3i:Y($)6GhNNJt/TRkQd$$G<F*@7@'?^)0i!mn8&+g<[e."TOVPgK)"6&&s(oQd?)+]g>NpIG3KoX5B!a*TpQ`s4ojArVEf[B4h,NdO\f#`([jVLu+]4Gs["IF6NX]f'l6?0V&"cJAMX2K>M#a3Gf'k$mqb^;*P86M-aco`a!Nqp?F,5IQ!TI]^lhlZV*F`n11O?.8EAS[H$NgV,mHlY^P8?J2DZ2@JWZm_u'jNMU5F]cu3GgcuOLGt&=TAi%FdEA"Q!#9uk9&^tJa\Bo/SlOhlt0VVOBm"f@5,=3#%@U%)J/E;OH]61.lOTst?Li)r"qh?Qdr#aH1lVR~>endstream
endobj
12 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2064
>>
stream
Gb!l"D/\/e&H;*)EPT;u7AKEZ</5p9dWOd-od)=R$j]\>WS4kgS^)CV%+pWunO97D=Jh'sl"8O\6LcHFn*]^/i[LZgn3cThZO+G+!$4>!5T'to!>U<=_+XQ;d/1.N</QoD5Z%hZ.ZM45-P8'0gmblH#go=7c<1UKme7&SnI?PZ[GVbfj7])_n!-jQ#:[%3J_eg/V\6WRAHe%.?ip9c_p:3EHSG3rKDT9E>?ju\(A6Z$9GdkJ\,#f!ENVS@Kk;=oAmrjbLh[-e9%g;ZnnYC#-:MK^XpAh2@>a$bAm2o`>/mQW6e5%kmY^bG(:,hPQ$h>#d>*QMbLML%cc^5ZK\2+!!)m>ObQ<NfO=B+H\T"[+#n?B7"pba31!gCfB$UCtBMgFVU5]P>'$[9:kogI)MfH3]W^S(PBmW$Le?eCY$pSk!A)+RXO1Jgfi9A4fgGi5rXND#Cj)P%&;]V435ZcAce^9ZYSt%B%=2D9CEOSSd,qtD@Z-m??PJqK\3UbHGPfflnk[O+*>VGB'E*Qb0k+diK[pE4cJn<Br@L;Mgfm92]Q:7tFLK=[-Q]oO1p/e^u@(Ig[YE<j!@hK,`)c.A0j7K4g_X#/<<_uRhh!.mCPpDK1(p_^nqC"-&33`cg(X+Co;'ES5Glk2L(_=qG-:9a2/V:h;f.U#..B`i,9I(lad7,"K;B][D6#!<k@/-tA.ldO=L[4ce/$uo@.Z$W8r9_L.E]Gg9";7Ua%C7&2\!jn*]\u]I49q>'1d@B5NL/d9L,3+-*&73orSQpkeUqF18,?^V!Stc&(:AHU9(XN1D,W>eBr&Y4Jk<p";kCaMGJFJ_#DB%q-H\'uEQDjD?;DB8pQeLU=I6uZ"6[YK0k#pg)o3c+@Mk,WYu4,-^ekD3mf>P97t?'l?1PGPf3m&^fnQ`Ik;Z1g+/]p5p[c@C[YXQuYV`(!`uA+&D-b8qd_[`4E8(>"1N/4d8"+'_DloJ3dnLn".`;LL_;BbNHkcG3KAej&4*VtLU*$!b"SpD)m`5)r5j2B#Hb:QhX2mc/Jn65IRN`\'[rkW,DWpKO-E@N-9$rVHU]!8E3';Wpp4dUniWm6N.EZ?*B_P%fW,.]>MM@p_->tY3'2=?OZEGXl+WI]iIS]O?"Ac9*@C]Nmhi9"g\j1?>e$B0hT&!ra,57-EZJoL!Q=pBA=22;sQs]t\,"iL>TLnq(EU-c%A$QQ;K@gseP@rnfHLh$9^)GPZJif==FR]LqGKLSEe'[Q.,5Z[aH];$f>/:nPPH7GTZIF>TWi`b0l^7fS>,M)&5!'i]7q_E(Qa>#S)/l+BQm/^AKMF3)eT?#'JsL6AQm+24dNu!-!4p8g[f]IC"MN(s_lmE[(K[`0`R:[p`lOYtQKqZaT]KepqrMq;8f*asQJTB+SL_;s\#sciB?/kOCkNZ]kGo:k]!JYS,H`d;9s$lE)GZ%Y#OkrdH*WkhWYG&D]sGAhWdSBJXOp,5:Ek%,Y(i3##AgV+TkqoHftY*Wah=,o'eDt%AgZ`eNh-O_cccM,*23CF_sqEM&K>7<=a'fWJS,(jZb);km2HpRL!Bm,&@/Vq2"IMpXkof<X_U>^Irga#c$/Xe?0*Y`Bq"mF/Oa^N$70Y5?CrIECp_=*C9?iO9Q[A1G.>%.pTM#j'g-2:Bq]2&La?ODfSW](s/oapH,Y!C-o?"uMPaaTjP?k5$8:]7r*lFL8krl-G=lOTQg?2mls7R[Do!q%RUfKf,jUfSEQQ5h5Q4<0X#hn;"fZj<Z(;b\_>djr-/gV($&(8;_hF@.2&YIh6saqZE8hD=RJ%Im_O85sQT'?2oq97rpq,n6lSH%d&gcKhdcr-e%iaD09!@+pPs+/>A\K27cr/UCBi`kk"=`@V/C:l$9T(<?m3(FU(<rYh*G&EDo1VpAg-I&"#r:BJ19%'p)"L_[G*".s(sL-M]B+3['XR=f]*^<?"uF?e<?lQt/K:+i$8T_u"P]WB`Sd,,dpKP].bcb!hMV;1`(5hgLntUoBgjPn>_GY,<Ku/2O;Or>ER>pa:/PJ16Xl:3`'oF!(q#H;i^.+-K#C*3`;"T!>Pj!.5En[d"o~>endstream
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2151
>>
stream
Gb!lc>BA7Q'Z],&.F-/O'S[N3XP;%e""h[t"'oRrlnZIO851$o'@q0XiS3g`I#15Mf^9`H-FBV7-!C7hUnaP%fpDlZD]X@gY5jT;nG!k+/n"G\GT=[VHj,!0#QG5WVjXeE6nVNa=o=2m=UaQ6@$WagQj&I^V?qWr3/D%(B4*l:A'+7')8:Ls6Gs$:(>2"VZ[#u>Me*SA#D0e]$4bpO/eX6V@/(Uo%t3!;BM_hj(8/%DhAaS5&P>@-6NSHMZK-]fYFLllfuMDAE#"<;-6+"4-K?c$3LigLA-)^(Z:d0F_io'u\Ok^9\X=uqo@]!9hqJb/_N(bDYLueHKa;9!09\g+,OEG:14aFRe05`p?&gZW?`.TnbGO1THk*K.#+$PkE!3N.2G%>AgRAuP7U$=f_;KVl%O7!i?n,'b0AL3+_^m+OI*4?9`h=s(HDQL<[T5h_iS]TbE&]JTo3p^IW6We*H&`H'hUHKqq_lXNg)2D8Eaq#+DJtc?\D:2GIFHMCkI-k[FjC9RH>g&B`lf6Uh,B#5R,hfiBY\f7!9b?FU7>1@T@b<SrEO)r5V40T-h[*;L6HH-/B9=LdHS?3AP"p%:#WQN73VU0:^N^K##FEgAd=$"`,\A3kqkD='U4JFfV,CU+Q',bUUF,`3,S(cl!gG&Q)=fFca$Dk]kHQ)eqBL4o;f(D,X\l(.nd4S1-?1[b5%FA]-Q48Wg/G)AkO9PB@g3dU\%cs.L7QOgKs3V59X\ao-.LG=3.$b):P=65cE=:Y?JJR,RQ]Be:U\Y-hR-qGm)/`g42TXE86$i@:_<j>]?NZj\R\p7RMie2j4_Hru+90D?l<KO!CR0eIqoEotqD`)#GE:=&H`H*'F^69hhc&_"UnO-70($@Et@'d3aQ)$>U'riS],^`TQ2p;$6`LBar\B\_?UX\72[DKs!Md'kaVaRn+qZ8nVJ#]K6g$kX,c@dOQqiaHomj*%^U%m_aYlVk%Ml3Ti&4r-T'mZuEJq6%(gtg[F-uCDUpW@A0H-kLD+JX;*RRS9!2!mBdkj_XQZolR'],s)>>J&Qe/Q@8fRr`D?Zh.G[L(rYUt0g>e7K0,dYJG-lpHc-.[DO-dn?KQf-jc_pAVKHJ"T^8@+I`<&@*G:VlkrDmd.=)<p`?ZXLHAMKYDXRIs#8L8>/.c25W,`LWcaK,,sQ=c-SX57]2Z`A."dYq783ghXL>:F7f3bb++!ICk(G8TOsPIq:MJ!O?UCI5QH?>fp'G.Jh"[8=:@e>V#@Q/%73<.n>pa3e]eHNY_B>7"$iE3m"WTnV<,5'-J!?&"Q_:)@YHdBga7-CsU2W6P!`\d0[+^ZT/fGES?]Y(c:MOd>DFSr^$<?DeT1pQo`=-[*#QgRR]emHrF_[k2g8p5\RJZ."9lbNRVb]$nt!%Wpl7hBTV\]&/kBRaKPho/=0q7S@sMiT0>3*)G`VP6IJhDWNsZbD*Fp?&QDXHf`tqmB+aT8/I:f?+Iim-_8pr3<&LP$WN;sG26NadLpHiBkp*mZO0/p9BKg4$\WsdRZ0gq/4nepleP<>T2"(r:+kB%DM$K2gH/5q`^ptCf/du.;,'$d]7B`jaVC0iPj8`lXpD3J4>bg\<dI3G`@*,=9Y[3t?b-f;e7$gLRhEQJ[sVe3h4`ZXD%b:LksFbeH?P\/V";8-[FotS&r"p8T%bMkd)'(r4`"d8/_cNrCd1PT\$O&5G8H#6[NV6lCSP)>DrZ6L]_U*L2'`X$%X>0DB6WT83oN09h=UWpoL%;,l?lC7a,V#(Z3BX#,=];X"56>[2U0((UL!D<7=0F6D;/>b4)*Vp5^O5,]8Lj\$/YdNZKEH_58<L\cM,&`8W<AOk7Tf:F#lVV%VR6_g,@j6/bWcLp=[AkmE%cE\EU:HZhr&pP0We3AA:nN-'J,]&aV1@hrd:N=":Ks$`I]YV"fJF(bj-7&6otM\NN0G_HHCEMDCg+jKXtrAW?@@nDLbK&qdJVIT/4m^T=(X$V+3"/=.ZMY4DBdcDSj5rrRd-hl!OQn</@&,TKfq;CkHp?044MARi$ie[ioBA=4rZc2/\dBtr#?47cFbbIFg+!V1q5Z<f"+.loL_Xd%MK/.8G'R>b.12k$XFb,iQ*%qQna08h\<b48NF9]:5P_@*!9Qg$b&?^@UUAT,IF%/pL(+CR2~>endstream
endobj
xref
0 14
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000632 00000 n
0000000828 00000 n
0000001024 00000 n
0000001093 00000 n
0000001373 00000 n
0000001445 00000 n
0000004582 00000 n
0000006738 00000 n
trailer
<<
/ID
[<dccedbca14e5931c52cd86c3e6456193><dccedbca14e5931c52cd86c3e6456193>]
% ReportLab generated PDF document -- digest (opensource)
/Info 9 0 R
/Root 8 0 R
/Size 14
>>
startxref
8981
%%EOF
+91
View File
@@ -0,0 +1,91 @@
# File Sharing — How It Works
## Overview
File sharing proxies all file operations through a Vercel serverless function (`/api/filebrowser`) to a self-hosted **FileBrowser Quantum** instance. The frontend never speaks directly to FBQ. Auth is validated server-side via Supabase on every request.
---
## Infrastructure
| Component | Location |
|---|---|
| FBQ instance | `https://fourgebranding.krao.us` (internal: `192.168.2.200:8082`) |
| API proxy | `api/filebrowser.js` (Vercel function) |
| Frontend | `src/pages/FileSharing.jsx` |
---
## Auth Flow
1. Frontend gets the current Supabase session access token (`supabase.auth.getSession()`).
2. Every request to `/api/filebrowser` sends the token as both `Authorization: Bearer <token>` header and `sb_access_token` query param.
3. The API function validates the token against Supabase and loads the caller's `profiles` row.
4. If valid, the function uses **its own server-side FBQ token** (`FILEBROWSER_TOKEN` env var) to talk to FBQ — the frontend never sees the FBQ token.
5. The FBQ token is a long-lived API token. If it expires or returns 401, the function falls back to admin username/password login (`FILEBROWSER_ADMIN_USER` / `FILEBROWSER_ADMIN_PASS`) to get a fresh token, cached in-memory for 30 minutes.
**No JWT auth from the frontend to FBQ.** That feature was removed — portal users authenticate via Supabase only.
---
## Role-Based Access Control
The API function maps each portal role to a different FBQ root path:
| Portal role | FBQ root | Notes |
|---|---|---|
| `team` | `FILEBROWSER_TEAM_ROOT` (default `/fourgebranding`) | Full access, sees everything under team root |
| `client` | `FILEBROWSER_CLIENT_ROOT/{company-name}` (default `/fourgebranding/Clients/{name}`) | Scoped to their company folder; multi-company clients get a virtual root listing their companies |
| `external` | `FILEBROWSER_SUBS_ROOT/{their-name}` + assigned project folders | Personal folder + read access to `Projects/{project}` folders they are members of via `project_members` table |
Virtual directories (e.g. the multi-company root for clients, the `Projects/` node for externals) are synthesised by the API and never hit FBQ.
Path traversal (`..`) is blocked at `normalizePath()` — throws before any FBQ call.
---
## API Actions (`/api/filebrowser?action=...`)
| Action | Method | What it does |
|---|---|---|
| `list` | GET | List folder contents; returns `{ entries, path, canGoUp, ... }` |
| `download` | GET | Returns a signed FBQ download URL + token for direct browser download |
| `download-blob` | GET | Proxies the file bytes through Vercel (used for ZIP building) |
| `upload-token` | POST | Returns FBQ token + URL for direct upload from browser to FBQ |
| `mkdir` | POST | Creates a new folder |
| `delete` | DELETE | Deletes one file/folder |
| `rename` | POST | Renames a file/folder in place |
| `move` | POST | Moves or copies items; `mode: 'copy'` or `mode: 'move'` |
| `archive-move` | POST | Merge-moves a folder into a destination (merges contents if dest already exists) |
---
## Frontend (`FileSharing.jsx`)
All FBQ calls go through the `fbqProxy()` helper which injects the current Supabase access token on every call.
- **Single file download**: `download` action → signed URL → hidden `<a>` click.
- **Multi-file / folder download**: `download-blob` streams each file, then JSZip builds a ZIP client-side.
- **Upload**: `upload-token` gets a short-lived FBQ token, then browser POSTs binary directly to `${fbqUrl}/api/resources?auth=${token}`.
- **Root path** per user is derived by `rootPath()` in `FileSharing.jsx` — team/external get `/`, clients with one company get `/Clients/{name}`, clients with multiple get `/Clients`.
- **Pinned folders** are persisted in `localStorage` keyed by `fbq_pins:{userId}`.
- **Nav tree** lazily loads folder children into a `navCache` ref; does not re-fetch already-loaded paths.
- **Drag-and-drop upload** uses `webkitGetAsEntry` to walk dropped folder trees, creates missing directories first, then uploads files.
- **Context menu** (right-click): Open, Download, Pin/Unpin, Copy, Cut, Paste here, Delete. Delete in context menu is restricted to `role === 'team'` (`accessScope.canManage`).
---
## Required Env Vars
```
FILEBROWSER_URL=https://fourgebranding.krao.us
FILEBROWSER_TOKEN=<long-lived FBQ API token>
FILEBROWSER_ADMIN_USER=admin
FILEBROWSER_ADMIN_PASS=<password>
FILEBROWSER_TEAM_ROOT=/fourgebranding
FILEBROWSER_CLIENT_ROOT=/fourgebranding/Clients
FILEBROWSER_SUBS_ROOT=/fourgebranding/team
FILEBROWSER_CLIENTS_ROOT=/fourgebranding/Clients
```
`FILEBROWSER_TOKEN` is the primary auth method. `ADMIN_USER`/`ADMIN_PASS` are only used for auto-refresh when the token expires.
+339
View File
@@ -0,0 +1,339 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fourge Portal Finance Summary</title>
<style>
:root {
--bg: #ffffff;
--text: #161616;
--muted: #5e5e5e;
--accent: #f5a523;
--border: #e5dccf;
--surface: #faf7f2;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.45;
}
.page {
width: 100%;
max-width: 920px;
margin: 0 auto;
padding: 44px 40px 56px;
}
.hero {
border: 1px solid var(--border);
background: linear-gradient(135deg, #fffaf2 0%, #ffffff 55%, #fff5e2 100%);
border-radius: 16px;
padding: 28px 30px;
margin-bottom: 28px;
}
h1 {
margin: 0 0 8px;
font-size: 30px;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.03em;
}
.sub {
margin: 0;
color: var(--muted);
font-size: 14px;
}
h2 {
margin: 28px 0 10px;
font-size: 19px;
line-height: 1.15;
}
h3 {
margin: 0 0 8px;
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #7d6d58;
}
p { margin: 0 0 12px; }
ul {
margin: 0;
padding-left: 18px;
}
li { margin: 0 0 6px; }
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.card {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 16px 16px 14px;
}
.card p:last-child,
.flow-box p:last-child { margin-bottom: 0; }
.flow {
display: grid;
gap: 10px;
margin-top: 12px;
}
.flow-box {
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
background: #fff;
position: relative;
}
.flow-box + .flow-box::before {
content: "↓";
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
color: var(--accent);
font-weight: 700;
}
.pill {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
background: #fff4dd;
border: 1px solid #f3d194;
font-size: 12px;
margin: 0 6px 6px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 13px;
}
th, td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 700;
}
.code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
white-space: pre-wrap;
}
.footer {
margin-top: 28px;
color: var(--muted);
font-size: 12px;
}
@media print {
.page { padding: 24px 24px 36px; }
.hero, .card, .flow-box { break-inside: avoid; }
h2, h3 { break-after: avoid; }
table { break-inside: avoid; }
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<h1>Fourge Portal Finance Summary</h1>
<p class="sub">Role cheat sheet, finance lanes, and data flow for team, client, and subcontractor users.</p>
</section>
<section>
<h2>1. Executive Summary</h2>
<p>The finance area is not one shared page for all roles. It is split into three lanes with different permissions and different page sets.</p>
<div class="grid">
<article class="card">
<h3>Team</h3>
<p>Full finance hub at <strong>/invoices</strong>.</p>
<p>Handles client invoices, expenses, subcontractor invoices, and subcontractor purchase orders.</p>
</article>
<article class="card">
<h3>Client</h3>
<p>Invoice-only lane at <strong>/my-invoices</strong>.</p>
<p>Clients can view invoices, filter by company, and download PDFs.</p>
</article>
<article class="card">
<h3>Subcontractor</h3>
<p>Two finance lanes: <strong>/my-invoices-sub</strong> and <strong>/my-purchase-orders</strong>.</p>
<p>They can submit invoices to Fourge and approve POs sent by Fourge.</p>
</article>
</div>
</section>
<section>
<h2>2. Role-by-Role Cheat Sheet</h2>
<table>
<thead>
<tr>
<th>Role</th>
<th>Main Routes</th>
<th>Can Do</th>
<th>Cannot Do</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Team</strong></td>
<td>/invoices</td>
<td>Create/send client invoices, track expenses, create POs, review subcontractor invoices, mark paid, export/send PDFs</td>
<td>Not limited in finance UI</td>
</tr>
<tr>
<td><strong>Client</strong></td>
<td>/my-invoices</td>
<td>View invoices, filter by company, download invoice PDFs</td>
<td>No expenses, no invoice editing, no POs, no subcontractor finance tools</td>
</tr>
<tr>
<td><strong>Subcontractor / External</strong></td>
<td>/my-invoices-sub<br>/my-purchase-orders</td>
<td>Create/submit invoices to Fourge, view status, download receipts after payment, approve POs</td>
<td>No team finance overview, no client invoice management, no internal expenses</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>3. Core Data Models</h2>
<div class="card">
<div class="pill">invoices</div>
<div class="pill">invoice_items</div>
<div class="pill">expenses</div>
<div class="pill">subcontractor_invoices</div>
<div class="pill">subcontractor_invoice_items</div>
<div class="pill">subcontractor_payments</div>
<div class="pill">subcontractor_po_items</div>
<div class="pill">tasks</div>
<div class="pill">submissions</div>
<div class="pill">companies</div>
<div class="pill">profiles</div>
<p style="margin-top:10px;">Simple mental model: <strong>invoices</strong> bill clients, <strong>expenses</strong> track internal costs, <strong>subcontractor_invoices</strong> let subcontractors bill Fourge, and <strong>subcontractor_payments</strong> are POs Fourge sends to subcontractors.</p>
</div>
</section>
<section>
<h2>4. System Flow</h2>
<div class="flow">
<div class="flow-box">
<h3>Client Billing Flow</h3>
<p>Approved client work reaches <strong>tasks.status = client_approved</strong>.</p>
<p>Team finance loads uninvoiced tasks and revisions, builds line items, saves an invoice, and emails the PDF to the client.</p>
<p>Later, team marks the invoice paid.</p>
</div>
<div class="flow-box">
<h3>Expense Flow</h3>
<p>Team logs expenses, optionally with receipt files.</p>
<p>Expenses feed overview charts, yearly summaries, and profit calculations.</p>
</div>
<div class="flow-box">
<h3>Subcontractor Invoice Flow</h3>
<p>Subcontractor creates an invoice from completed approved tasks assigned to them.</p>
<p>Fourge reviews it, then marks it paid and sends a receipt PDF back.</p>
</div>
<div class="flow-box">
<h3>Purchase Order Flow</h3>
<p>Team creates a PO for a subcontractor, optionally tied to project tasks.</p>
<p>Subcontractor reviews the PO and can approve it from their own portal view.</p>
</div>
</div>
</section>
<section>
<h2>5. Status Logic</h2>
<div class="grid">
<article class="card">
<h3>Client Invoice</h3>
<p><strong>draft</strong><strong>sent</strong><strong>paid</strong></p>
</article>
<article class="card">
<h3>Subcontractor Invoice</h3>
<p><strong>draft</strong><strong>submitted</strong><strong>paid</strong></p>
</article>
<article class="card">
<h3>Purchase Order</h3>
<p><strong>draft</strong><strong>sent</strong><strong>approved</strong><strong>ready_to_pay</strong><strong>paid</strong></p>
<p><strong>cancelled</strong> is exit state.</p>
</article>
</div>
</section>
<section>
<h2>6. Key Business Rules</h2>
<ul>
<li>Client invoices only pull approved, uninvoiced work.</li>
<li>Tasks and revisions are marked invoiced after invoice creation.</li>
<li>Deleting a team invoice rolls linked invoice flags back.</li>
<li>Team invoice status changes also push task status changes.</li>
<li>Revision billing rule: <strong>R00 = new work</strong>, <strong>R01 = free</strong>, <strong>R02+ = billable increments</strong>.</li>
<li>Subcontractor invoices are created by the external user and then reviewed/paid by team.</li>
<li>POs are created by team and approved by the subcontractor.</li>
</ul>
</section>
<section>
<h2>7. Database Relationship Chart</h2>
<div class="code">COMPANIES
└─ invoices
└─ invoice_items
├─ task_id → tasks
└─ submission_id → submissions
PROJECTS
└─ tasks
└─ submissions
PROFILES (external)
├─ subcontractor_invoices
│ └─ subcontractor_invoice_items
│ └─ task_id → tasks
└─ subcontractor_payments (POs)
└─ subcontractor_po_items
└─ task_id → tasks
EXPENSES
└─ standalone internal finance records</div>
</section>
<section>
<h2>8. Quick Route Map</h2>
<table>
<thead>
<tr>
<th>Surface</th>
<th>Route</th>
<th>User</th>
</tr>
</thead>
<tbody>
<tr><td>Finance Hub</td><td>/invoices</td><td>Team</td></tr>
<tr><td>Client Invoices</td><td>/my-invoices</td><td>Client</td></tr>
<tr><td>Subcontractor Invoices</td><td>/my-invoices-sub</td><td>External</td></tr>
<tr><td>Create Subcontractor Invoice</td><td>/my-invoices-sub/new</td><td>External</td></tr>
<tr><td>Purchase Orders</td><td>/my-purchase-orders</td><td>External</td></tr>
</tbody>
</table>
</section>
<p class="footer">Generated from current portal codebase summary on June 3, 2026.</p>
</main>
</body>
</html>
Binary file not shown.
+379
View File
@@ -0,0 +1,379 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const docsDir = new URL('.', import.meta.url);
const inputPath = new URL('./checked-sites-revision-audit-2026-06-04.json', docsDir);
const outputPath = new URL('./checked-sites-revision-audit-2026-06-04.html', docsDir);
const rows = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
const existingRows = rows.filter((row) => row.exists !== false);
const activeRows = existingRows.filter((row) => row.hasActiveRevision === 'Yes');
const noActiveRows = existingRows.filter((row) => row.hasActiveRevision !== 'Yes');
const r00BilledRows = existingRows.filter((row) => row.r00Billed !== 'No');
const r00UnbilledRows = existingRows.filter((row) => row.r00Billed === 'No');
const activeStatusCounts = activeRows.reduce((acc, row) => {
const key = row.activeStatus || 'unknown';
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const revisionDepthCounts = activeRows.reduce((acc, row) => {
const key = row.activeRevision || '—';
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const maxStatusCount = Math.max(1, ...Object.values(activeStatusCounts));
const maxDepthCount = Math.max(1, ...Object.values(revisionDepthCounts));
const esc = (value) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
function badgeClassForStatus(status) {
if (status === 'client_review') return 'review';
if (status === 'client_approved') return 'approved';
if (status === 'not_started') return 'todo';
if (status === 'in_progress') return 'progress';
return 'neutral';
}
function barChart(entries, maxValue) {
return entries
.map(([label, count]) => {
const pct = Math.max(4, Math.round((count / maxValue) * 100));
return `
<div class="bar-row">
<div class="bar-label">${esc(label)}</div>
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
<div class="bar-value">${count}</div>
</div>
`;
})
.join('');
}
function renderTableRows(items) {
return items
.map((row) => {
const billedClass = row.r00Billed === 'No' ? 'warn' : 'ok';
const activeClass = badgeClassForStatus(row.activeStatus);
return `
<tr>
<td class="mono">${esc(row.site)}</td>
<td>${esc(row.dbTitle)}</td>
<td class="mono">${esc(row.internalTaskNumber)}</td>
<td><span class="pill ${row.r00Completed === 'Yes' ? 'ok' : 'neutral'}">${esc(row.r00Completed)}</span></td>
<td><span class="pill ${billedClass}">${esc(row.r00Billed)}</span></td>
<td><span class="pill ${row.hasActiveRevision === 'Yes' ? 'review' : 'neutral'}">${esc(row.hasActiveRevision)}</span></td>
<td class="mono">${esc(row.activeRevision)}</td>
<td><span class="pill ${activeClass}">${esc(row.activeStatus)}</span></td>
<td>${esc(row.completedRevisions)}</td>
<td>${esc(row.billedVersions)}</td>
<td>${esc(row.versionSummary)}</td>
</tr>
`;
})
.join('');
}
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Checked Sites Revision Audit</title>
<style>
:root {
--bg: #f5f1e8;
--panel: rgba(255,255,255,0.78);
--panel-strong: rgba(255,255,255,0.92);
--border: rgba(43,37,28,0.12);
--text: #231d14;
--muted: #6b6255;
--accent: #b8831f;
--accent-soft: rgba(184,131,31,0.18);
--ok: #1f8a4c;
--ok-soft: rgba(31,138,76,0.12);
--warn: #b45309;
--warn-soft: rgba(180,83,9,0.14);
--review: #2563eb;
--review-soft: rgba(37,99,235,0.13);
--todo: #7c3aed;
--todo-soft: rgba(124,58,237,0.12);
--shadow: 0 18px 40px rgba(35,29,20,0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(184,131,31,0.14), transparent 28%),
radial-gradient(circle at top right, rgba(37,99,235,0.09), transparent 24%),
linear-gradient(180deg, #fbf8f2 0%, var(--bg) 100%);
}
.wrap {
width: min(1400px, calc(100vw - 40px));
margin: 32px auto 48px;
}
.hero, .panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.hero {
padding: 28px 30px;
margin-bottom: 24px;
}
.eyebrow {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 10px;
}
h1 {
margin: 0 0 10px;
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.04em;
}
.subtitle {
margin: 0;
color: var(--muted);
font-size: 15px;
max-width: 900px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
padding: 20px 22px;
}
.label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 8px;
}
.value {
font-size: 34px;
line-height: 1;
letter-spacing: -0.05em;
}
.sub {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.panel {
padding: 20px 22px;
}
h2 {
margin: 0 0 14px;
font-size: 18px;
letter-spacing: -0.03em;
}
.bar-row {
display: grid;
grid-template-columns: 150px 1fr 36px;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.bar-label, .bar-value {
font-size: 13px;
}
.bar-track {
height: 10px;
border-radius: 999px;
background: rgba(35,29,20,0.08);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), #d7a84b);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(35,29,20,0.08);
vertical-align: top;
text-align: left;
font-size: 13px;
}
th {
position: sticky;
top: 0;
background: var(--panel-strong);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
z-index: 1;
}
.mono {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
border: 1px solid transparent;
white-space: nowrap;
}
.ok { color: var(--ok); background: var(--ok-soft); border-color: rgba(31,138,76,0.2); }
.warn { color: var(--warn); background: var(--warn-soft); border-color: rgba(180,83,9,0.2); }
.review { color: var(--review); background: var(--review-soft); border-color: rgba(37,99,235,0.2); }
.todo { color: var(--todo); background: var(--todo-soft); border-color: rgba(124,58,237,0.18); }
.neutral { color: var(--muted); background: rgba(35,29,20,0.06); border-color: rgba(35,29,20,0.08); }
.section {
margin-bottom: 24px;
}
.small {
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1100px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.two-col { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.wrap { width: min(100vw - 20px, 1400px); margin: 20px auto 32px; }
.hero, .panel { padding: 18px; border-radius: 14px; }
.grid { grid-template-columns: 1fr; }
h1 { font-size: 28px; }
}
</style>
</head>
<body>
<div class="wrap">
<section class="hero">
<div class="eyebrow">Fourge Portal Audit</div>
<h1>Checked Sites Revision Audit</h1>
<p class="subtitle">This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. It is designed to make client-billing comparison easier at a glance.</p>
</section>
<section class="grid">
<div class="panel card">
<div class="label">Checked Sites</div>
<div class="value">${existingRows.length}</div>
<div class="sub">Sites found in the database from your checked list.</div>
</div>
<div class="panel card">
<div class="label">Active Revisions</div>
<div class="value">${activeRows.length}</div>
<div class="sub">Checked sites that now have an active R01+.</div>
</div>
<div class="panel card">
<div class="label">R00 Billed</div>
<div class="value">${r00BilledRows.length}</div>
<div class="sub">Checked sites where the new-book unit is already on an invoice.</div>
</div>
<div class="panel card">
<div class="label">R00 Unbilled</div>
<div class="value">${r00UnbilledRows.length}</div>
<div class="sub">Checked sites completed at R00 but not yet billed.</div>
</div>
</section>
<section class="two-col">
<div class="panel">
<h2>Active Revision Status</h2>
${barChart(Object.entries(activeStatusCounts), maxStatusCount)}
</div>
<div class="panel">
<h2>Active Revision Depth</h2>
${barChart(Object.entries(revisionDepthCounts), maxDepthCount)}
</div>
</section>
<section class="panel section">
<h2>Sites With Active Revisions</h2>
<p class="small">These are the ones where R00 may be done, but a newer version is still active or awaiting review/approval.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:90px;">Site #</th>
<th style="width:240px;">DB Title</th>
<th style="width:80px;">Internal #</th>
<th style="width:96px;">R00 Done</th>
<th style="width:180px;">R00 Billed</th>
<th style="width:90px;">Active R#</th>
<th style="width:120px;">Active Status</th>
<th style="width:170px;">Completed Revisions</th>
<th style="width:220px;">Billed Versions</th>
<th>Version Summary</th>
</tr>
</thead>
<tbody>
${renderTableRows(activeRows)}
</tbody>
</table>
</div>
</section>
<section class="panel section">
<h2>Checked Sites With No Active Revision</h2>
<p class="small">These are simpler to read: R00 completed, and no current revision chain is active right now.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:90px;">Site #</th>
<th style="width:240px;">DB Title</th>
<th style="width:80px;">Internal #</th>
<th style="width:96px;">R00 Done</th>
<th style="width:180px;">R00 Billed</th>
<th style="width:90px;">Active R#</th>
<th style="width:120px;">Active Status</th>
<th style="width:170px;">Completed Revisions</th>
<th style="width:220px;">Billed Versions</th>
<th>Version Summary</th>
</tr>
</thead>
<tbody>
${renderTableRows(noActiveRows)}
</tbody>
</table>
</div>
</section>
</div>
</body>
</html>`;
fs.writeFileSync(outputPath, html);
console.log(fileURLToPath(outputPath));
+362
View File
@@ -0,0 +1,362 @@
from pathlib import Path
import json
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import landscape, legal
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
BaseDocTemplate,
Frame,
LongTable,
PageBreak,
PageTemplate,
Paragraph,
Spacer,
Table,
TableStyle,
)
DOCS_DIR = Path(__file__).resolve().parent
INPUT_PATH = DOCS_DIR / "checked-sites-revision-audit-2026-06-04.json"
OUTPUT_PATH = DOCS_DIR / "checked-sites-revision-audit-2026-06-04.pdf"
BG = colors.HexColor("#F5F1E8")
TEXT = colors.HexColor("#231D14")
MUTED = colors.HexColor("#6B6255")
ACCENT = colors.HexColor("#B8831F")
ACCENT_SOFT = colors.HexColor("#F1E3C2")
OK = colors.HexColor("#1F8A4C")
OK_SOFT = colors.HexColor("#E5F4EB")
WARN = colors.HexColor("#B45309")
WARN_SOFT = colors.HexColor("#F7E7D7")
REVIEW = colors.HexColor("#2563EB")
REVIEW_SOFT = colors.HexColor("#E6EEFF")
TODO = colors.HexColor("#7C3AED")
TODO_SOFT = colors.HexColor("#F0E8FF")
LINE = colors.HexColor("#D7CFC1")
def page_template(canvas, doc):
canvas.saveState()
canvas.setFillColor(BG)
canvas.rect(0, 0, doc.pagesize[0], doc.pagesize[1], fill=1, stroke=0)
canvas.setFillColor(MUTED)
canvas.setFont("Helvetica", 9)
canvas.drawRightString(doc.pagesize[0] - 36, 20, f"Page {doc.page}")
canvas.restoreState()
def pill(text, fg, bg):
return Paragraph(
(
f'<para alignment="center" backColor="{bg}" borderColor="{fg}" '
f'borderWidth="0.7" borderPadding="4" textColor="{fg}"><b>{escape(text)}</b></para>'
),
styles["pill"],
)
def escape(value):
return (
str(value or "")
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def status_pill(value):
value = value or ""
if value == "client_review":
return pill(value, REVIEW, REVIEW_SOFT)
if value == "client_approved":
return pill(value, OK, OK_SOFT)
if value == "not_started":
return pill(value, TODO, TODO_SOFT)
if value == "in_progress":
return pill(value, ACCENT, ACCENT_SOFT)
if value == "Yes":
return pill(value, OK, OK_SOFT)
if value == "No":
return pill(value, WARN, WARN_SOFT)
return pill(value, MUTED, colors.white)
def text_para(value, style_name="cell"):
return Paragraph(escape(value), styles[style_name])
def build_summary_cards(rows):
existing_rows = [row for row in rows if row.get("exists", True) is not False]
active_rows = [row for row in existing_rows if row.get("hasActiveRevision") == "Yes"]
billed_rows = [row for row in existing_rows if row.get("r00Billed") != "No"]
unbilled_rows = [row for row in existing_rows if row.get("r00Billed") == "No"]
cards = [
("Checked Sites", str(len(existing_rows)), "Sites found in the database from your checked list."),
("Active Revisions", str(len(active_rows)), "Checked sites that now have an active R01+."),
("R00 Billed", str(len(billed_rows)), "Checked sites where the new-book unit is already billed."),
("R00 Unbilled", str(len(unbilled_rows)), "Checked sites where R00 is done but not billed yet."),
]
card_rows = []
for label, value, subtext in cards:
card_rows.append(
Table(
[
[Paragraph(label.upper(), styles["card_label"])],
[Paragraph(value, styles["card_value"])],
[Paragraph(subtext, styles["card_sub"])],
],
colWidths=[3.0 * inch],
style=TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.white),
("BOX", (0, 0), (-1, -1), 0.8, LINE),
("INNERPADDING", (0, 0), (-1, -1), 10),
("ROUNDEDCORNERS", [10, 10, 10, 10]),
]
),
)
)
return Table([card_rows], colWidths=[3.1 * inch] * 4, hAlign="LEFT")
def build_table(rows, title, intro):
flow = [Paragraph(title, styles["section_title"]), Paragraph(intro, styles["small"]), Spacer(1, 10)]
header = [
text_para("Site", "th"),
text_para("DB Title", "th"),
text_para("Internal #", "th"),
text_para("R00 Done", "th"),
text_para("R00 Billed", "th"),
text_para("Active R#", "th"),
text_para("Active Status", "th"),
text_para("Completed Revisions", "th"),
text_para("Billed Versions", "th"),
text_para("Version Summary", "th"),
]
data = [header]
for row in rows:
data.append(
[
text_para(row.get("site", ""), "cell_mono"),
text_para(row.get("dbTitle", "")),
text_para(row.get("internalTaskNumber", ""), "cell_mono"),
status_pill(row.get("r00Completed", "No")),
status_pill(row.get("r00Billed", "No")),
text_para(row.get("activeRevision", ""), "cell_mono"),
status_pill(row.get("activeStatus", "")),
text_para(row.get("completedRevisions", "")),
text_para(row.get("billedVersions", "")),
text_para(row.get("versionSummary", "")),
]
)
table = LongTable(
data,
colWidths=[
0.78 * inch,
1.7 * inch,
0.72 * inch,
0.75 * inch,
0.85 * inch,
0.6 * inch,
1.0 * inch,
1.45 * inch,
1.2 * inch,
2.15 * inch,
],
repeatRows=1,
)
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.white),
("BOX", (0, 0), (-1, -1), 0.8, LINE),
("INNERGRID", (0, 0), (-1, -1), 0.4, LINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBF8F2")]),
]
)
)
flow.append(table)
return flow
styles = getSampleStyleSheet()
styles.add(
ParagraphStyle(
name="title_main",
fontName="Helvetica-Bold",
fontSize=24,
leading=28,
textColor=TEXT,
alignment=TA_LEFT,
)
)
styles.add(
ParagraphStyle(
name="eyebrow",
fontName="Helvetica-Bold",
fontSize=9,
leading=11,
textColor=MUTED,
)
)
styles.add(
ParagraphStyle(
name="subtitle",
fontName="Helvetica",
fontSize=11,
leading=15,
textColor=MUTED,
)
)
styles.add(
ParagraphStyle(
name="section_title",
fontName="Helvetica-Bold",
fontSize=15,
leading=18,
textColor=TEXT,
spaceAfter=2,
)
)
styles.add(
ParagraphStyle(
name="small",
fontName="Helvetica",
fontSize=9,
leading=12,
textColor=MUTED,
)
)
styles.add(
ParagraphStyle(
name="card_label",
fontName="Helvetica-Bold",
fontSize=8,
leading=10,
textColor=MUTED,
)
)
styles.add(
ParagraphStyle(
name="card_value",
fontName="Helvetica-Bold",
fontSize=24,
leading=26,
textColor=TEXT,
)
)
styles.add(
ParagraphStyle(
name="card_sub",
fontName="Helvetica",
fontSize=8.5,
leading=11,
textColor=MUTED,
)
)
styles.add(
ParagraphStyle(
name="th",
fontName="Helvetica-Bold",
fontSize=8,
leading=10,
textColor=MUTED,
)
)
styles.add(
ParagraphStyle(
name="cell",
fontName="Helvetica",
fontSize=8.2,
leading=10.5,
textColor=TEXT,
)
)
styles.add(
ParagraphStyle(
name="cell_mono",
fontName="Courier",
fontSize=8.1,
leading=10.3,
textColor=TEXT,
)
)
styles.add(
ParagraphStyle(
name="pill",
fontName="Helvetica-Bold",
fontSize=7.5,
leading=9,
alignment=1,
)
)
def main():
rows = json.loads(INPUT_PATH.read_text())
existing_rows = [row for row in rows if row.get("exists", True) is not False]
active_rows = [row for row in existing_rows if row.get("hasActiveRevision") == "Yes"]
no_active_rows = [row for row in existing_rows if row.get("hasActiveRevision") != "Yes"]
doc = BaseDocTemplate(
str(OUTPUT_PATH),
pagesize=landscape(legal),
leftMargin=36,
rightMargin=36,
topMargin=28,
bottomMargin=28,
)
frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="main")
doc.addPageTemplates([PageTemplate(id="default", frames=[frame], onPage=page_template)])
story = [
Paragraph("FOURGE PORTAL AUDIT", styles["eyebrow"]),
Spacer(1, 4),
Paragraph("Checked Sites Revision Audit", styles["title_main"]),
Spacer(1, 6),
Paragraph(
"This report treats your checkmark as “R00 was completed,” then separates that from billing and from any newer active revisions. "
"It is designed to make client-billing comparison easier at a glance.",
styles["subtitle"],
),
Spacer(1, 18),
build_summary_cards(rows),
Spacer(1, 18),
]
story.extend(
build_table(
active_rows,
"Checked Sites With Active Revisions",
"These are the sites where R00 is done, but there is still a newer revision active or awaiting billing.",
)
)
story.append(PageBreak())
story.extend(
build_table(
no_active_rows,
"Checked Sites With No Active Revision",
"These are the checked sites that currently do not have a newer active revision.",
)
)
doc.build(story)
print(OUTPUT_PATH)
if __name__ == "__main__":
main()
+301
View File
@@ -0,0 +1,301 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const outputPath = path.join(__dirname, 'finance-summary.pdf');
const pdf = await PDFDocument.create();
const fontRegular = await pdf.embedFont(StandardFonts.Helvetica);
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
const PAGE_W = 612;
const PAGE_H = 792;
const MARGIN = 42;
const ACCENT = rgb(0.96, 0.65, 0.14);
const TEXT = rgb(0.09, 0.09, 0.09);
const MUTED = rgb(0.36, 0.36, 0.36);
const BORDER = rgb(0.90, 0.86, 0.80);
const SURFACE = rgb(0.985, 0.97, 0.94);
const WHITE = rgb(1, 1, 1);
let page = pdf.addPage([PAGE_W, PAGE_H]);
let y = PAGE_H - MARGIN;
function ensureSpace(heightNeeded = 40) {
if (y - heightNeeded < MARGIN) {
page = pdf.addPage([PAGE_W, PAGE_H]);
y = PAGE_H - MARGIN;
}
}
function drawText(text, x, yy, size = 12, font = fontRegular, color = TEXT) {
page.drawText(text, { x, y: yy, size, font, color });
}
function wrapText(text, maxWidth, size = 12, font = fontRegular) {
const words = String(text).split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const test = line ? `${line} ${word}` : word;
const width = font.widthOfTextAtSize(test, size);
if (width <= maxWidth || !line) line = test;
else {
lines.push(line);
line = word;
}
}
if (line) lines.push(line);
return lines;
}
function drawParagraph(text, x, maxWidth, size = 12, color = TEXT, lineGap = 4, font = fontRegular) {
const lines = wrapText(text, maxWidth, size, font);
const lineHeight = size + lineGap;
ensureSpace(lines.length * lineHeight + 4);
for (const line of lines) {
drawText(line, x, y, size, font, color);
y -= lineHeight;
}
y -= 4;
}
function drawSectionTitle(text) {
ensureSpace(30);
drawText(text, MARGIN, y, 18, fontBold, TEXT);
y -= 26;
}
function drawCard(x, yy, w, h, title, bodyLines) {
page.drawRectangle({ x, y: yy - h, width: w, height: h, color: SURFACE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 });
drawText(title, x + 12, yy - 20, 11, fontBold, MUTED);
let innerY = yy - 40;
for (const line of bodyLines) {
const wrapped = wrapText(line, w - 24, 11, fontRegular);
for (const piece of wrapped) {
drawText(piece, x + 12, innerY, 11, fontRegular, TEXT);
innerY -= 15;
}
innerY -= 2;
}
}
function drawFlowBox(title, lines) {
const contentLines = lines.flatMap(line => wrapText(line, PAGE_W - MARGIN * 2 - 24, 11, fontRegular));
const h = 40 + contentLines.length * 15 + 10;
ensureSpace(h + 24);
page.drawRectangle({ x: MARGIN, y: y - h, width: PAGE_W - MARGIN * 2, height: h, color: WHITE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 });
drawText(title, MARGIN + 14, y - 20, 11, fontBold, MUTED);
let innerY = y - 40;
for (const line of contentLines) {
drawText(line, MARGIN + 14, innerY, 11, fontRegular, TEXT);
innerY -= 15;
}
y -= h + 18;
if (y > MARGIN + 40) {
drawText("v", PAGE_W / 2 - 3, y + 3, 14, fontBold, ACCENT);
}
}
function drawBullet(text) {
const bulletX = MARGIN + 4;
const textX = MARGIN + 18;
const maxWidth = PAGE_W - textX - MARGIN;
const lines = wrapText(text, maxWidth, 11, fontRegular);
const lineHeight = 15;
ensureSpace(lines.length * lineHeight + 4);
drawText("•", bulletX, y, 12, fontBold, TEXT);
for (const line of lines) {
drawText(line, textX, y, 11, fontRegular, TEXT);
y -= lineHeight;
}
y -= 2;
}
function drawTable(headers, rows, colWidths) {
const tableW = colWidths.reduce((a, b) => a + b, 0);
const startX = MARGIN;
const rowPad = 8;
const headerH = 24;
ensureSpace(36);
page.drawRectangle({ x: startX, y: y - headerH, width: tableW, height: headerH, color: SURFACE, borderColor: BORDER, borderWidth: 1 });
let x = startX;
headers.forEach((head, i) => {
drawText(head, x + 6, y - 15, 10, fontBold, MUTED);
x += colWidths[i];
});
y -= headerH;
for (const row of rows) {
const wrappedCells = row.map((cell, i) => wrapText(cell, colWidths[i] - 12, 10, fontRegular));
const lines = Math.max(...wrappedCells.map(c => c.length));
const rowH = Math.max(22, lines * 13 + rowPad);
ensureSpace(rowH + 2);
page.drawRectangle({ x: startX, y: y - rowH, width: tableW, height: rowH, borderColor: BORDER, borderWidth: 1 });
let cellX = startX;
wrappedCells.forEach((cellLines, i) => {
let cellY = y - 14;
for (const line of cellLines) {
drawText(line, cellX + 6, cellY, 10, fontRegular, TEXT);
cellY -= 12;
}
cellX += colWidths[i];
});
y -= rowH;
}
y -= 10;
}
function newPage() {
page = pdf.addPage([PAGE_W, PAGE_H]);
y = PAGE_H - MARGIN;
}
// Page 1
page.drawRectangle({
x: MARGIN,
y: y - 88,
width: PAGE_W - MARGIN * 2,
height: 88,
color: SURFACE,
borderColor: BORDER,
borderWidth: 1.2,
borderRadius: 16,
});
drawText('Fourge Portal Finance Summary', MARGIN + 18, y - 30, 24, fontBold, TEXT);
drawText('Role cheat sheet, finance lanes, and data flow for team, client, and subcontractor users.', MARGIN + 18, y - 54, 12, fontRegular, MUTED);
y -= 112;
drawSectionTitle('1. Executive Summary');
drawParagraph('The finance area is split into three separate role lanes. Team gets the full finance hub, clients get invoice viewing only, and subcontractors get invoice submission plus purchase-order review.', MARGIN, PAGE_W - MARGIN * 2, 12, TEXT);
ensureSpace(140);
const gap = 12;
const cardW = (PAGE_W - MARGIN * 2 - gap * 2) / 3;
drawCard(MARGIN, y, cardW, 104, 'TEAM', [
'Main route: /invoices',
'Handles client invoices, expenses, subcontractor invoices, and POs.',
]);
drawCard(MARGIN + cardW + gap, y, cardW, 104, 'CLIENT', [
'Main route: /my-invoices',
'Can view invoices, filter by company, and download PDFs.',
]);
drawCard(MARGIN + (cardW + gap) * 2, y, cardW, 104, 'SUBCONTRACTOR', [
'Main routes: /my-invoices-sub and /my-purchase-orders',
'Can submit invoices to Fourge and approve POs.',
]);
y -= 126;
drawSectionTitle('2. Role-by-Role Cheat Sheet');
drawTable(
['ROLE', 'MAIN ROUTES', 'CAN DO', 'CANNOT DO'],
[
['Team', '/invoices', 'Client invoices, expenses, POs, subcontractor invoices, paid status, PDFs, email sends', 'No major finance restrictions in UI'],
['Client', '/my-invoices', 'View invoices, filter by company, download invoice PDFs', 'No expenses, no edits, no POs, no subcontractor finance tools'],
['Subcontractor', '/my-invoices-sub /my-purchase-orders', 'Submit invoices, view status, download receipt after paid, approve POs', 'No team finance overview, no client billing control'],
],
[72, 120, 205, 131],
);
drawSectionTitle('3. Core Data Models');
drawBullet('invoices + invoice_items: Fourge bills clients.');
drawBullet('expenses: Fourge internal finance records.');
drawBullet('subcontractor_invoices + subcontractor_invoice_items: subcontractors bill Fourge.');
drawBullet('subcontractor_payments + subcontractor_po_items: Fourge sends purchase orders to subcontractors.');
drawBullet('tasks and submissions connect work records to invoice line items.');
// Page 2
newPage();
drawSectionTitle('4. System Flow');
drawFlowBox('CLIENT BILLING FLOW', [
'Approved work reaches tasks.status = client_approved.',
'Team finance loads uninvoiced tasks and revisions and builds invoice line items.',
'Invoice is saved, PDF is generated, and email is sent to the client.',
'Later team marks the invoice paid.',
]);
drawFlowBox('EXPENSE FLOW', [
'Team logs internal expenses and can attach receipt files.',
'Expenses feed overview charts, yearly finance summaries, and profit calculations.',
]);
drawFlowBox('SUBCONTRACTOR INVOICE FLOW', [
'Subcontractor creates an invoice from completed approved tasks assigned to them.',
'Fourge reviews the invoice, marks it paid, and can send a receipt PDF back.',
]);
drawFlowBox('PURCHASE ORDER FLOW', [
'Team creates a PO for a subcontractor, optionally linked to project tasks.',
'Subcontractor reviews and approves the PO from their own portal view.',
]);
y -= 8;
drawSectionTitle('5. Status Logic');
drawBullet('Client invoice: draft -> sent -> paid');
drawBullet('Subcontractor invoice: draft -> submitted -> paid');
drawBullet('Purchase order: draft -> sent -> approved -> ready_to_pay -> paid');
drawBullet('Cancelled is the exit state for a PO.');
drawSectionTitle('6. Key Business Rules');
drawBullet('Client invoices only pull approved, uninvoiced work.');
drawBullet('Tasks and revisions are marked invoiced after invoice creation.');
drawBullet('Deleting a team invoice rolls linked invoice flags back.');
drawBullet('Team invoice status changes also push task status changes.');
drawBullet('Revision billing rule: R00 = new work, R01 = free, R02+ = billable increments.');
drawBullet('Subcontractor invoices are created by the external user and then reviewed/paid by team.');
drawBullet('POs are created by team and approved by the subcontractor.');
// Page 3
newPage();
drawSectionTitle('7. Database Relationship Chart');
drawParagraph('Simple relationship map:', MARGIN, PAGE_W - MARGIN * 2, 12, TEXT, 4, fontBold);
const diagram = [
'COMPANIES',
' -> invoices',
' -> invoice_items',
' -> task_id -> tasks',
' -> submission_id -> submissions',
'',
'PROJECTS',
' -> tasks',
' -> submissions',
'',
'PROFILES (external)',
' -> subcontractor_invoices',
' -> subcontractor_invoice_items',
' -> task_id -> tasks',
' -> subcontractor_payments (POs)',
' -> subcontractor_po_items',
' -> task_id -> tasks',
'',
'EXPENSES',
' -> standalone internal finance records',
];
page.drawRectangle({ x: MARGIN, y: y - 270, width: PAGE_W - MARGIN * 2, height: 270, color: SURFACE, borderColor: BORDER, borderWidth: 1, borderRadius: 12 });
let codeY = y - 20;
for (const line of diagram) {
drawText(line, MARGIN + 14, codeY, 11, fontRegular, TEXT);
codeY -= 14;
}
y -= 292;
drawSectionTitle('8. Quick Route Map');
drawTable(
['SURFACE', 'ROUTE', 'USER'],
[
['Finance Hub', '/invoices', 'Team'],
['Client Invoices', '/my-invoices', 'Client'],
['Subcontractor Invoices', '/my-invoices-sub', 'External'],
['Create Subcontractor Invoice', '/my-invoices-sub/new', 'External'],
['Purchase Orders', '/my-purchase-orders', 'External'],
],
[210, 230, 88],
);
drawParagraph('Generated from the current portal codebase summary on June 3, 2026.', MARGIN, PAGE_W - MARGIN * 2, 10, MUTED);
const bytes = await pdf.save();
await fs.writeFile(outputPath, bytes);
console.log(outputPath);
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
# Invoice Match Audit
Date: `2026-06-05`
Definition used: your checked sites mean `R00` was completed. This audit compares that list against live client invoice data.
- Checked sites: 26
- Sites found in DB: 39
- Checked sites with R00 complete but not billed: 3
- Checked sites with completed unbilled versions: 6
- Unchecked sites that are already billed: 0
## Main Finding
Client invoicing appears aligned with your checked list overall: there are **no unchecked sites currently billed** from this list.
## Checked But R00 Not Billed Yet
- `30094` 30094 Colorado Springs, CO -> R00, status `client_approved`
- `30261` 30261 NSA Clovis NM -> R00, status `client_approved`
- `68720` 68720 NSA Lancaster PA -> R00, status `client_approved`
## Completed Versions Still Unbilled
- `30094` 30094 Colorado Springs, CO -> unbilled: R00; billed: —; current R00 (client_approved)
- `30096` 30096 Eerie, CO -> unbilled: R01; billed: R00: INV-2026-006 (sent); current R02 (not_started)
- `30261` 30261 NSA Clovis NM -> unbilled: R00; billed: —; current R00 (client_approved)
- `68720` 68720 NSA Lancaster PA -> unbilled: R00; billed: —; current R00 (client_approved)
- `68721` 68721 NSA Mechanicsburg PA -> unbilled: R02; billed: R00: INV-2026-006 (sent) | R01: INV-2026-006 (sent); current R02 (client_approved)
- `68811` 68811 Kelso, WA -> unbilled: R01; billed: R00: INV-2026-006 (sent); current R02 (not_started)
## Checked Sites With Active Revisions
| Site | Title | Current | Status | Billed Versions | Completed Unbilled |
|---|---|---|---|---|---|
| 00286 | 00286 Riverside, CA | R01 | not_started | R00: INV-2026-006 (sent) | — |
| 30055 | 30055 Moreno Valley, CA | R01 | client_review | R00: INV-2026-006 (sent) | — |
| 30096 | 30096 Eerie, CO | R02 | not_started | R00: INV-2026-006 (sent) | R01 |
| 68639 | 68639 NSA Los Lunas NM | R01 | client_review | R00: INV-2026-006 (sent) | — |
| 68663 | 68663 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — |
| 68664 | 68664 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — |
| 68666 | 68666 NSA Oklahoma City, OK | R01 | not_started | R00: INV-2026-006 (sent) | — |
| 68669 | 68669 NSA Oklahoma City OK | R01 | not_started | R00: INV-2026-006 (sent) | — |
| 68670 | 68670 NSA Oklahoma City OK | R02 | client_review | R00: INV-2026-006 (sent)<br>R01: INV-2026-006 (sent) | — |
| 68671 | 68671 NSA Oklahoma City OK | R01 | client_review | R00: INV-2026-006 (sent) | — |
| 68673 | 68673 NSA Oklahoma City OK | R01 | client_review | R00: INV-2026-006 (sent) | — |
| 68721 | 68721 NSA Mechanicsburg PA | R02 | client_approved | R00: INV-2026-006 (sent)<br>R01: INV-2026-006 (sent) | R02 |
| 68808 | 68808 Camas, WA | R01 | not_started | R00: INV-2026-006 (sent) | — |
| 68809 | 68809 Centralia, WA | R02 | not_started | R00: INV-2026-006 (sent)<br>R01: INV-2026-006 (sent) | — |
| 68810 | 68810 Chehalis, WA | R02 | not_started | R00: INV-2026-006 (sent)<br>R01: INV-2026-006 (sent) | — |
| 68811 | 68811 Kelso, WA | R02 | not_started | R00: INV-2026-006 (sent) | R01 |
## Unchecked But Already Billed
- None
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
# Site Completion and Billing Audit
Generated: 2026-06-04T13:58:27.846Z
- Listed sites: 95
- Found in database: 39
- Missing from database: 56
- Checked vs DB mismatches: 15
## Missing From Database
- 00278
- 00279
- 00281
- 00282
- 00284
- 00290
- 00291
- 30051
- 30054
- 30056
- 30058
- 30061
- 30063
- 30068
- 30095
- 30098
- 30210
- 30262
- 30319
- 30342
- 00288
- 30344
- 30345
- 30348
- 30349
- 30350
- 30355
- 30357
- 30407
- 68175
- 30412
- 30413
- 68215
- 68216
- 30437
- 68220
- 68432
- 68516
- 68617
- 68620
- 68631
- 68636
- 68637
- 68638
- 68715
- 68723
- 68751
- 68776
- 68777
- 68778
- 68779
- 68780
- 68781
- 68782
- 68784
- 68785
## Checked vs Database Mismatches
- 00286 00286 Riverside, CA: you marked completed, DB status is not_started
- 30055 30055 Moreno Valley, CA: you marked completed, DB status is client_review
- 30096 30096 Eerie, CO: you marked completed, DB status is not_started
- 68639 68639 NSA Los Lunas NM: you marked completed, DB status is client_review
- 68663 68663 NSA Oklahoma City OK: you marked completed, DB status is not_started
- 68664 68664 NSA Oklahoma City OK: you marked completed, DB status is not_started
- 68666 68666 NSA Oklahoma City, OK: you marked completed, DB status is not_started
- 68669 68669 NSA Oklahoma City OK: you marked completed, DB status is not_started
- 68670 68670 NSA Oklahoma City OK: you marked completed, DB status is not_started
- 68671 68671 NSA Oklahoma City OK: you marked completed, DB status is not_started
- 68673 68673 NSA Oklahoma City OK: you marked completed, DB status is client_review
- 68808 68808 Camas, WA: you marked completed, DB status is not_started
- 68809 68809 Centralia, WA: you marked completed, DB status is not_started
- 68810 68810 Chehalis, WA: you marked completed, DB status is not_started
- 68811 68811 Kelso, WA: you marked completed, DB status is not_started
## Audit Table
| Site # | DB Title | Internal # | In DB | Your Check | DB Completed | Match | Current R# | Active Status | R00 Billed | Billed Revisions | Completed Unbilled Revisions | Version Status Summary |
|---|---|---:|---:|---:|---:|---:|---|---|---|---|---|---|
| 00278 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 00279 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 00281 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 00282 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 00284 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 00285 | 00285 NSA Moreno Valley CA | 65 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 00286 | 00286 Riverside, CA | 31 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
| 00290 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 00291 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30049 | 30049 Elk Grove, CA | 32 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 30051 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30054 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30055 | 30055 Moreno Valley, CA | 33 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
| 30056 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30058 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30060 | 30060 NSA Palmdale CA | 66 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 30061 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30062 | 30062 Riverside, CA | 34 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
| 30063 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30068 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30091 | 30091 NSA Colorado Springs CO | 67 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 30094 | 30094 Colorado Springs, CO | 35 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
| 30095 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30096 | 30096 Eerie, CO | 36 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | — | R01 | R00: completed / billed; R01: completed; R02: not_started |
| 30098 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30101 | 30101 Monument, CO | 37 | Yes | No | No | Yes | R00 | on_hold | No | — | — | R00: on_hold |
| 30210 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30211 | 30211 NSA Post Falls ID | 68 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 30212 | 30212 NSA Sandpoint ID | 69 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 30258 | 30258 Lebanon, NH | 38 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
| 30259 | 30259 Hamburg, NJ | 39 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
| 30261 | 30261 NSA Clovis NM | 70 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
| 30262 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30319 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30342 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 00288 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30344 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30345 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30348 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30349 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30350 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30355 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30357 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30407 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68175 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30412 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30413 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68215 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68216 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 30437 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68220 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68432 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68452 | 68452 NSA Houston TX | 71 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 68453 | 68453 NSA Katy TX | 72 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 68454 | 68454 NSA Katy TX | 73 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 68516 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68617 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68620 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68631 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68636 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68637 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68638 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68639 | 68639 NSA Los Lunas NM | 40 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
| 68663 | 68663 NSA Oklahoma City OK | 41 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
| 68664 | 68664 NSA Oklahoma City OK | 42 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
| 68666 | 68666 NSA Oklahoma City, OK | 43 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
| 68669 | 68669 NSA Oklahoma City OK | 44 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
| 68670 | 68670 NSA Oklahoma City OK | 45 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
| 68671 | 68671 NSA Oklahoma City OK | 46 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
| 68673 | 68673 NSA Oklahoma City OK | 47 | Yes | Yes | No | No | R01 | client_review | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: client_review |
| 68715 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68720 | 68720 NSA Lancaster PA | 74 | Yes | Yes | Yes | Yes | R00 | client_approved | No | — | — | R00: client_approved |
| 68721 | 68721 NSA Mechanicsburg PA | 48 | Yes | Yes | Yes | Yes | R02 | client_approved | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | R02 | R00: completed / billed; R01: completed / billed; R02: client_approved |
| 68723 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68724 | 68724 NSA York PA | 75 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
| 68751 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68752 | 68752 NSA Brownsville TX | 76 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
| 68753 | 68753 NSA Brownsville TX | 77 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
| 68754 | 68754 NSA Brownsville TX | 78 | Yes | No | No | Yes | R00 | in_progress | No | — | — | R00: in_progress |
| 68755 | 68755 NSA Brownsville TX | 79 | Yes | No | No | Yes | R00 | not_started | No | — | — | R00: not_started |
| 68756 | 68756 NSA Brownsville TX | 80 | Yes | No | No | Yes | R00 | in_progress | No | — | — | R00: in_progress |
| 68758 | 68758 NSA Brownsville TX | 81 | Yes | Yes | Yes | Yes | R00 | invoiced | Yes (INV-2026-006 (sent)) | — | — | R00: invoiced / billed |
| 68776 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68777 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68778 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68779 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68780 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68781 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68782 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68784 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68785 | — | — | No | No | — | — | — | — | — | — | — | Missing from DB |
| 68808 | 68808 Camas, WA | 49 | Yes | Yes | No | No | R01 | not_started | Yes (INV-2026-006 (sent)) | — | — | R00: completed / billed; R01: not_started |
| 68809 | 68809 Centralia, WA | 50 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
| 68810 | 68810 Chehalis, WA | 51 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | R01 [INV-2026-006 (sent)] | — | R00: completed / billed; R01: completed / billed; R02: not_started |
| 68811 | 68811 Kelso, WA | 52 | Yes | Yes | No | No | R02 | not_started | Yes (INV-2026-006 (sent)) | — | R01 | R00: completed / billed; R01: completed; R02: not_started |
Executable → Regular
View File
Executable → Regular
+9
View File
@@ -2,6 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>
(() => {
const theme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
})();
</script>
<link rel="preconnect" href="https://fqflxxqvennhvoeywrdw.supabase.co" crossorigin />
<link rel="dns-prefetch" href="https://fqflxxqvennhvoeywrdw.supabase.co" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
Generated Executable → Regular
+377 -3
View File
@@ -16,7 +16,8 @@
"jszip": "^3.10.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
"react-router-dom": "^7.13.1",
"recharts": "^2.15.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -962,6 +963,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1272,6 +1336,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1366,9 +1439,129 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1387,6 +1580,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1404,6 +1603,16 @@
"node": ">=8"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
@@ -1628,6 +1837,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1635,6 +1850,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -1897,6 +2121,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
@@ -1943,7 +2176,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -2360,6 +2592,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -2367,6 +2605,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2430,6 +2680,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2591,6 +2850,23 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2632,6 +2908,12 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
@@ -2670,6 +2952,37 @@
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -2685,6 +2998,39 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
@@ -2885,6 +3231,12 @@
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2984,6 +3336,28 @@
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
Executable → Regular
+2 -1
View File
@@ -18,7 +18,8 @@
"jszip": "^3.10.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
"react-router-dom": "^7.13.1",
"recharts": "^2.15.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Task Tracker PDF Generator</title>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<style>
body { font-family: -apple-system, sans-serif; background: #111; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; flex-direction: column; gap: 16px; }
button { background: #F5A523; color: #000; border: none; border-radius: 8px; padding: 12px 28px; font-size: 14px; font-weight: 600; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: default; }
#status { font-size: 13px; color: #888; }
select { background: #222; color: #fff; border: 1px solid #333; border-radius: 8px; padding: 8px 12px; font-size: 13px; min-width: 220px; }
</style>
</head>
<body>
<div style="font-size:22px;font-weight:700;letter-spacing:-0.5px">Task Tracker PDF</div>
<select id="companyFilter"><option value="">All Companies</option></select>
<button id="btn" onclick="generate()">Generate &amp; Download PDF</button>
<div id="status">Ready</div>
<script>
const SUPABASE_URL = 'https://fqflxxqvennhvoeywrdw.supabase.co';
const SUPABASE_ANON_KEY = 'sb_publishable_qNNIKtnu1dUIVKelq9aYYQ_TfHgzhyR';
const { createClient } = supabase;
const sb = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
const STATUS_LABELS = {
not_started: 'Not Started',
in_progress: 'In Progress',
on_hold: 'On Hold',
client_review: 'In Review',
client_approved: 'Approved',
invoiced: 'Invoiced',
paid: 'Paid',
};
const versionLabel = (v) => `R${String(v ?? 0).padStart(2, '0')}`;
const isInvoiced = (t) => t.invoiced || t.status === 'invoiced' || t.status === 'paid';
// Populate company dropdown on load
(async () => {
const { data } = await sb
.from('tasks')
.select('project:projects(company:companies(id, name))')
.limit(1000);
const seen = new Map();
for (const t of data || []) {
const c = t.project?.company;
if (c && !seen.has(c.id)) seen.set(c.id, c);
}
const sel = document.getElementById('companyFilter');
[...seen.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
})();
async function generate() {
const btn = document.getElementById('btn');
const status = document.getElementById('status');
const selectedCompany = document.getElementById('companyFilter').value;
const selectedCompanyName = document.getElementById('companyFilter').selectedOptions[0]?.text || 'All Companies';
btn.disabled = true;
status.textContent = 'Fetching data from Supabase…';
try {
const { data, error } = await sb
.from('tasks')
.select('id, title, status, current_version, invoiced, submitted_at, project:projects(id, name, company:companies(id, name))')
.order('submitted_at', { ascending: false });
if (error) throw error;
let rows = data || [];
if (selectedCompany) {
rows = rows.filter(t => t.project?.company?.id === selectedCompany);
}
status.textContent = `Building PDF for ${rows.length} tasks…`;
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'letter' });
// Header
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.setTextColor(245, 165, 35);
doc.text('Fourge Branding', 40, 48);
doc.setFont('helvetica', 'normal');
doc.setFontSize(11);
doc.setTextColor(120, 120, 120);
doc.text('Task Tracker', 40, 64);
doc.setFontSize(9);
const now = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
doc.text(`Generated ${now} · ${selectedCompanyName} · ${rows.length} tasks`, 40, 78);
// Table
doc.autoTable({
startY: 96,
head: [['TASK NAME', 'VERSION', 'STATUS', 'INVOICED']],
body: rows.map(t => [
[
t.title || '—',
t.project?.company?.name && t.project?.name
? `${t.project.company.name} · ${t.project.name}`
: (t.project?.name || ''),
],
versionLabel(t.current_version),
STATUS_LABELS[t.status] || t.status || '—',
isInvoiced(t) ? 'Yes' : '—',
]),
styles: {
fontSize: 9,
cellPadding: { top: 5, right: 8, bottom: 5, left: 8 },
overflow: 'linebreak',
textColor: [30, 30, 30],
},
headStyles: {
fillColor: [245, 165, 35],
textColor: [0, 0, 0],
fontStyle: 'bold',
fontSize: 8,
},
alternateRowStyles: {
fillColor: [248, 248, 248],
},
columnStyles: {
0: { cellWidth: 220 },
1: { cellWidth: 55, halign: 'center' },
2: { cellWidth: 90 },
3: { cellWidth: 60, halign: 'center' },
},
willDrawCell(data) {
if (data.column.index === 0 && data.section === 'body') {
const [title, sub] = Array.isArray(data.cell.raw) ? data.cell.raw : [data.cell.raw, ''];
const x = data.cell.x + 8;
let y = data.cell.y + 13;
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(30, 30, 30);
doc.text(String(title), x, y, { maxWidth: 204 });
if (sub) {
const titleLines = doc.splitTextToSize(String(title), 204).length;
y += titleLines * 11;
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(140, 140, 140);
doc.text(String(sub), x, y, { maxWidth: 204 });
}
data.cell.text = [];
}
},
margin: { left: 40, right: 40 },
});
const slug = selectedCompany ? selectedCompanyName.replace(/\s+/g, '-').toLowerCase() : 'all';
const filename = `task-tracker-${slug}-${new Date().toISOString().slice(0, 10)}.pdf`;
doc.save(filename);
status.textContent = `Downloaded: ${filename}`;
} catch (err) {
status.textContent = `Error: ${err.message}`;
console.error(err);
} finally {
btn.disabled = false;
}
}
</script>
</body>
</html>
Executable → Regular
View File
Executable → Regular
+137 -71
View File
@@ -1,99 +1,165 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { lazy, Suspense, Component } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import PageLoader from './components/PageLoader';
class ChunkErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
componentDidCatch(error) {
const isChunkError = error?.name === 'ChunkLoadError'
|| error?.message?.includes('dynamically imported module')
|| error?.message?.includes('Failed to fetch');
if (isChunkError) {
const key = 'chunk_reload_attempted';
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, '1');
window.location.reload();
return;
}
sessionStorage.removeItem(key);
}
}
render() {
if (this.state.error) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', gap: 16 }}>
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Page failed to load.</div>
<button className="btn btn-primary" onClick={() => { sessionStorage.removeItem('chunk_reload_attempted'); this.setState({ error: null }); window.location.reload(); }}>
Reload
</button>
</div>
);
}
return this.props.children;
}
}
import Login from './pages/Login';
import PayInvoice from './pages/PayInvoice';
const Settings = lazy(() => import('./pages/Settings'));
const Dashboard = lazy(() => import('./pages/team/Dashboard'));
const Companies = lazy(() => import('./pages/team/Companies'));
const CompanyDetail = lazy(() => import('./pages/team/CompanyDetail'));
const ProjectDetail = lazy(() => import('./pages/team/ProjectDetail'));
const TeamProjects = lazy(() => import('./pages/team/TeamProjects'));
const Requests = lazy(() => import('./pages/team/Requests'));
const Invoices = lazy(() => import('./pages/team/Invoices'));
const MeetingNotes = lazy(() => import('./pages/team/MeetingNotes'));
const TaskDetail = lazy(() => import('./pages/team/TaskDetail'));
const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice'));
const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO'));
const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail'));
const SubcontractorPODetail = lazy(() => import('./pages/team/SubcontractorPODetail'));
const SurveyMaker = lazy(() => import('./pages/team/SurveyMaker'));
const BrandBook = lazy(() => import('./pages/team/BrandBook'));
const Converters = lazy(() => import('./pages/team/Converters'));
const ServerStatus = lazy(() => import('./pages/team/ServerStatus'));
const FileSharing = lazy(() => import('./pages/team/FileSharing'));
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
const ExternalMyRequests = lazy(() => import('./pages/external/MyRequests'));
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders'));
const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices'));
const ExternalProjects = lazy(() => import('./pages/external/ExternalProjects'));
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail'));
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate'));
const ClientDashboard = lazy(() => import('./pages/client/ClientDashboard'));
const MyCompany = lazy(() => import('./pages/client/MyCompany'));
const MyRequests = lazy(() => import('./pages/client/MyRequests'));
const MyProjects = lazy(() => import('./pages/client/MyProjects'));
const MyProjectDetail = lazy(() => import('./pages/client/MyProjectDetail'));
const MyInvoices = lazy(() => import('./pages/client/MyInvoices'));
const RequestDetail = lazy(() => import('./pages/client/RequestDetail'));
const NewRequest = lazy(() => import('./pages/client/NewRequest'));
const NewProject = lazy(() => import('./pages/client/NewProject'));
const ProfilePage = lazy(() => import('./pages/Profile'));
const CompaniesPage = lazy(() => import('./pages/Companies'));
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices'));
const TaskDetail = lazy(() => import('./pages/TaskDetail'));
const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO'));
const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail'));
const TeamSubcontractorPODetail = lazy(() => import('./pages/team/TeamSubcontractorPODetail'));
const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetail'));
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
const BrandBook = lazy(() => import('./pages/BrandBook'));
const Converters = lazy(() => import('./pages/Converters'));
const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords'));
const TeamReports = lazy(() => import('./pages/team/TeamReports'));
const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders'));
const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices'));
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail'));
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/ExternalMyInvoiceCreate'));
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetail'));
const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard'));
const RequestsPage = lazy(() => import('./pages/Tasks'));
const ClientMyInvoices = lazy(() => import('./pages/client/ClientMyInvoices'));
function RedirectProjectDetail() {
const { id } = useParams();
return <Navigate to={`/projects/${id}`} replace />;
}
function RedirectClientDetail() {
const { id } = useParams();
return <Navigate to={`/projects/${id}`} replace />;
}
function RedirectToTask() {
const { id } = useParams();
return <Navigate to={`/tasks/${id}`} replace />;
}
function RedirectTeamInvoiceDetail() {
const { id } = useParams();
return <Navigate to={`/finances/${id}`} replace />;
}
function RedirectSubsInvoiceDetail() {
const { id } = useParams();
return <Navigate to={`/subs-invoices/${id}`} replace />;
}
function NavigateCompanyDetail() {
const { id } = useParams();
return <Navigate to={`/company/${id}`} replace />;
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<ChunkErrorBoundary>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external']}><Dashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external']}><ProjectDetail /></ProtectedRoute>} />
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external']}><TaskDetail /></ProtectedRoute>} />
<Route path="/companies" element={<ProtectedRoute role="team"><Companies /></ProtectedRoute>} />
<Route path="/companies/:id" element={<ProtectedRoute role="team"><CompanyDetail /></ProtectedRoute>} />
<Route path="/requests" element={<ProtectedRoute role="team"><Requests /></ProtectedRoute>} />
<Route path="/team-projects" element={<ProtectedRoute role="team"><TeamProjects /></ProtectedRoute>} />
<Route path="/meeting-notes" element={<ProtectedRoute role="team"><MeetingNotes /></ProtectedRoute>} />
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><CreateSubcontractorPO /></ProtectedRoute>} />
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><InvoiceDetail /></ProtectedRoute>} />
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><SubcontractorPODetail /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/clients/:id" element={<RedirectClientDetail />} />
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
<Route path="/companies" element={<Navigate to="/company" replace />} />
<Route path="/companies/:id" element={<NavigateCompanyDetail />} />
<Route path="/team/tasks" element={<Navigate to="/tasks" replace />} />
<Route path="/tasks" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
<Route path="/finances/new" element={<Navigate to="/finances" replace />} />
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
<Route path="/finances/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} />
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} />
<Route path="/invoices" element={<Navigate to="/finances" replace />} />
<Route path="/invoices/new" element={<Navigate to="/finances" replace />} />
<Route path="/invoices/:id" element={<RedirectTeamInvoiceDetail />} />
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><FourgePasswords /></ProtectedRoute>} />
<Route path="/server-status" element={<ProtectedRoute role="team"><ServerStatus /></ProtectedRoute>} />
<Route path="/assigned-requests" element={<ProtectedRoute role="external"><ExternalMyRequests /></ProtectedRoute>} />
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><MyPurchaseOrders /></ProtectedRoute>} />
<Route path="/my-projects-sub" element={<ProtectedRoute role="external"><ExternalProjects /></ProtectedRoute>} />
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><TeamFourgePasswords /></ProtectedRoute>} />
<Route path="/reports" element={<ProtectedRoute role="team"><TeamReports /></ProtectedRoute>} />
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
<Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} />
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><ExternalMyPurchaseOrders /></ProtectedRoute>} />
<Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} />
<Route path="/subs-invoices" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
<Route path="/subs-invoices/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
<Route path="/subs-invoices/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
<Route path="/my-invoices-sub" element={<Navigate to="/subs-invoices" replace />} />
<Route path="/my-invoices-sub/new" element={<Navigate to="/subs-invoices/new" replace />} />
<Route path="/my-invoices-sub/:id" element={<RedirectSubsInvoiceDetail />} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/settings" element={<Navigate to="/profile" replace />} />
<Route path="/my-dashboard" element={<ProtectedRoute role="client"><ClientDashboard /></ProtectedRoute>} />
<Route path="/my-company" element={<ProtectedRoute role="client"><MyCompany /></ProtectedRoute>} />
<Route path="/my-requests" element={<ProtectedRoute role="client"><MyRequests /></ProtectedRoute>} />
<Route path="/my-requests/:id" element={<ProtectedRoute role="client"><RequestDetail /></ProtectedRoute>} />
<Route path="/my-projects" element={<ProtectedRoute role="client"><MyProjects /></ProtectedRoute>} />
<Route path="/my-projects/:id" element={<ProtectedRoute role="client"><MyProjectDetail /></ProtectedRoute>} />
<Route path="/my-invoices" element={<ProtectedRoute role="client"><MyInvoices /></ProtectedRoute>} />
<Route path="/new-request" element={<ProtectedRoute role="client"><NewRequest /></ProtectedRoute>} />
<Route path="/new-project" element={<ProtectedRoute role="client"><NewProject /></ProtectedRoute>} />
<Route path="/my-dashboard" element={<Navigate to="/dashboard" replace />} />
<Route path="/my-company" element={<Navigate to="/company" replace />} />
<Route path="/my-requests" element={<Navigate to="/tasks" replace />} />
<Route path="/my-requests/:id" element={<RedirectToTask />} />
<Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
<Route path="/client-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} />
<Route path="/my-invoices" element={<Navigate to="/client-invoices" replace />} />
<Route path="/new-request" element={<Navigate to="/tasks" replace />} />
<Route path="/new-project" element={<Navigate to="/tasks" replace />} />
<Route path="/pay/:id" element={<PayInvoice />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</ChunkErrorBoundary>
</BrowserRouter>
</AuthProvider>
);
Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Executable → Regular
View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

+15 -14
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react';
import { useId, useRef, useState } from 'react';
const MAX_FILES = 20;
const MAX_SIZE_MB = 50;
const MAX_SIZE_MB = 250;
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
const formatSize = (bytes) => {
@@ -10,6 +10,7 @@ const formatSize = (bytes) => {
};
export default function FileAttachment({ files, onChange }) {
const inputId = useId();
const [errors, setErrors] = useState([]);
const [dragging, setDragging] = useState(false);
const dragCounter = useRef(0);
@@ -60,12 +61,12 @@ export default function FileAttachment({ files, onChange }) {
return (
<div className="form-group">
<label>
<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 }}>
<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>
</label>
</div>
<div
onDragEnter={handleDragEnter}
@@ -74,15 +75,15 @@ export default function FileAttachment({ files, onChange }) {
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8, padding: '18px 16px', textAlign: 'center',
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)',
transition: 'all 0.15s',
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="req-file-upload" />
<label htmlFor="req-file-upload" style={{ cursor: 'pointer' }}>
<div style={{ fontSize: 22, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
<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
@@ -102,12 +103,12 @@ export default function FileAttachment({ files, onChange }) {
{files.map((file, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)',
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: 600 }}>{file.name}</div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{file.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>
</div>
</div>
+84
View File
@@ -0,0 +1,84 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
const FilterIcon = () => (
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4h12M4 8h8M6 12h4"/>
</svg>
);
export default function FilterDropdown({ value, onChange, options }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const btnRef = useRef(null);
const menuRef = useRef(null);
const active = value && value !== 'all';
const place = useCallback(() => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return;
const left = Math.min(r.left, window.innerWidth - 176);
setPos({ top: r.bottom + 4, left: Math.max(8, left) });
}, []);
useEffect(() => {
if (!open) return;
place();
function onDown(e) {
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
setOpen(false);
}
function onScroll(e) {
// Don't close when scrolling inside the menu itself.
if (menuRef.current && menuRef.current.contains(e.target)) return;
setOpen(false);
}
document.addEventListener('mousedown', onDown);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
document.removeEventListener('mousedown', onDown);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
}, [open, place]);
return (
<>
<button
ref={btnRef}
type="button"
onClick={() => setOpen(o => !o)}
aria-label="Filter"
title="Filter"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
color: active ? 'var(--accent)' : 'var(--text-primary)',
opacity: active || open ? 0.9 : 0.35,
}}
>
<FilterIcon />
</button>
{open && pos && createPortal(
<div
ref={menuRef}
className="site-header-avatar-menu"
style={{ position: 'fixed', top: pos.top, left: pos.left, right: 'auto', width: 'max-content', minWidth: 160, maxHeight: `calc(100vh - ${pos.top}px - 16px)`, overflowY: 'auto', zIndex: 4000 }}
>
{options.map(opt => (
<button
key={opt.value}
className="site-header-avatar-item"
onClick={() => { onChange(opt.value); setOpen(false); }}
style={value === opt.value ? { color: 'var(--accent)' } : undefined}
>
{opt.label}
</button>
))}
</div>,
document.body
)}
</>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import PageLoader from './PageLoader';
export const POPUP_FIELD_LABEL = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
display: 'block',
marginBottom: 4,
};
export default function InvoiceDetailPopup({
title,
subtitle,
headerRight,
onClose,
blockClose = false,
metaContent, // JSX fields rendered in flat meta grid (no cards)
metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates)
metaCols = 4, // number of grid columns for meta strip
footerActions,
loading = false,
children,
}) {
return (
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}>
<div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{title}</div>
{subtitle && <div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>{subtitle}</div>}
</div>
{headerRight && <div style={{ flexShrink: 0 }}>{headerRight}</div>}
</div>
{/* Flat meta strip */}
{metaContent && (
<div style={{ padding: '18px 0', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${metaCols}, minmax(0, 1fr))`, gap: '14px 18px', flex: 1 }}>
{metaContent}
</div>
{metaActions && (
<div style={{ flexShrink: 0, display: 'flex', gap: 8, alignItems: 'flex-start' }}>
{metaActions}
</div>
)}
</div>
</div>
)}
{/* Body */}
{loading ? (
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PageLoader />
</div>
) : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
{children}
</div>
)}
{/* Footer */}
{footerActions && (
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)', flexShrink: 0 }}>
{footerActions}
</div>
)}
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import SortTh from './SortTh';
function fmt(val) {
return `$${Number(val || 0).toFixed(2)}`;
}
function inferItemType(item) {
if (item._inferredType) return item._inferredType;
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
if (match) {
return Number(match[1]) <= 0
? { label: 'New', badgeClass: 'badge-initial' }
: { label: 'Revision', badgeClass: 'badge-client_revision' };
}
if (item?.task_id) return { label: 'Task', badgeClass: 'badge-in_progress' };
return { label: 'Other', badgeClass: 'badge-initial' };
}
export default function InvoicePopupTable({ sortKey, sortDir, onSort, items, notes }) {
return (
<>
{items.length === 0 ? (
<div className="card-empty-center" style={{ minHeight: 120 }}>No line items.</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '12%' }} />
<col style={{ width: '46%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={onSort}>Type</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={onSort}>Description</SortTh>
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'center' }}>Qty</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'right' }}>Unit Price</SortTh>
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{items.map(item => {
const itemType = inferItemType(item);
return (
<tr key={item.id}>
<td style={{ padding: '5px 0' }}>
<span className={`badge ${itemType.badgeClass}`}>{itemType.label}</span>
</td>
<td style={{ padding: '5px 0', fontSize: 13 }}>{item.description}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'center' }}>{item.quantity}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right' }}>{fmt(item.unit_price)}</td>
<td style={{ padding: '5px 0', fontSize: 13, textAlign: 'right', fontWeight: 400 }}>
{fmt(Number(item.unit_price || 0) * Number(item.quantity || 1))}
</td>
</tr>
);
})}
</tbody>
</table>
)}
{notes && (
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 4 }}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{notes}</div>
</div>
)}
</>
);
}
Executable → Regular
+313 -94
View File
@@ -1,59 +1,87 @@
import { useState, useEffect } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react';
import PageLoader from './PageLoader';
import ProfileAvatar from './ProfileAvatar';
import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const ICONS = {
dashboard: <svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/></svg>,
requests: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="2" width="12" height="12" rx="1.5"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="10.5" x2="8" y2="10.5"/></svg>,
projects: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1.5 5.5C1.5 4.67 2.17 4 3 4h2.5l1.5 2H13c.83 0 1.5.67 1.5 1.5v5.5c0 .83-.67 1.5-1.5 1.5H3c-.83 0-1.5-.67-1.5-1.5V5.5z"/></svg>,
invoices: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="1.5" width="12" height="13" rx="1"/><line x1="8" y1="4" x2="8" y2="12"/><path d="M10 5.5H7a1.5 1.5 0 000 3h2a1.5 1.5 0 010 3H5.5"/></svg>,
notes: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="1.5" width="10" height="13" rx="1"/><line x1="5.5" y1="5" x2="10.5" y2="5"/><line x1="5.5" y1="7.5" x2="10.5" y2="7.5"/><line x1="5.5" y1="10" x2="8.5" y2="10"/></svg>,
survey: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/><rect x="6.5" y="6" width="3" height="8.5" rx="0.5"/><rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/></svg>,
brandBook: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 2.5h4a2 2 0 012 2v9a2 2 0 00-2-2h-4V2.5z"/><path d="M13.5 2.5h-4a2 2 0 00-2 2v9a2 2 0 012-2h4V2.5z"/></svg>,
converter: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M1.5 11.5l3.5-3.5 3 3L11 7.5l3 3"/></svg>,
reports: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 13.5h11"/><path d="M4.5 11V7.5"/><path d="M8 11V4.5"/><path d="M11.5 11V6"/></svg>,
passwords: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5 7V5.5a3 3 0 016 0V7"/><circle cx="8" cy="10.5" r="1" fill="currentColor" stroke="none"/></svg>,
companies: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="5" width="13" height="9.5" rx="1"/><path d="M5.5 5V3a1 1 0 011-1h3a1 1 0 011 1v2"/><line x1="8" y1="5" x2="8" y2="14.5"/><line x1="1.5" y1="9" x2="14.5" y2="9"/></svg>,
users: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6" cy="5" r="2.5"/><path d="M1 14c0-2.76 2.24-5 5-5s5 2.24 5 5"/><circle cx="12.5" cy="5.5" r="2"/><path d="M12.5 10c1.93 0 3.5 1.57 3.5 3.5"/></svg>,
};
function NI({ icon }) {
return <span className="nav-icon">{icon}</span>;
}
function TeamNav({ onNav }) {
const location = useLocation();
const primaryLinks = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/requests', label: 'Requests' },
{ to: '/team-projects', label: 'Projects' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/companies', label: 'Clients & Users' },
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/finances', label: 'Finances', icon: ICONS.invoices },
];
const utilityLinks = [
{ to: '/meeting-notes', label: 'Meeting Notes' },
{ to: '/invoices', label: 'Invoices & Expenses' },
{ to: '/survey-maker', label: 'Survey Maker' },
{ to: '/brand-book', label: 'Brand Book Maker' },
{ to: '/converters', label: 'Image Converter' },
{ to: '/fourge-passwords', label: 'Fourge Passwords' },
{ to: '/server-status', label: 'Server Status' },
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
{ to: '/reports', label: 'Reports', icon: ICONS.reports },
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
];
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
return (
<div className="sidebar-section">
{primaryLinks.map(({ to, label }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
{primaryLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
Team Tools
</div>
{utilityLinks.map(({ to, label }) => (
<div className="sidebar-tools-divider" />
<div className="sidebar-tools-label">Tools</div>
{utilityLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
<NavLink to="/company" onClick={onNav} className={() => `sidebar-link${isCompaniesActive ? ' active' : ''}`}>
<NI icon={ICONS.companies} /><span className="nav-label">Companies</span>
</NavLink>
<NavLink to="/company?tab=users" onClick={onNav} className={() => `sidebar-link${isUsersActive ? ' active' : ''}`}>
<NI icon={ICONS.users} /><span className="nav-label">Users</span>
</NavLink>
</div>
);
}
function ClientNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/client-invoices', label: 'Invoices', icon: ICONS.invoices },
];
return (
<div className="sidebar-section">
{[
{ to: '/my-dashboard', label: 'Dashboard' },
{ to: '/my-projects', label: 'Projects' },
{ to: '/my-requests', label: 'Requests' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/my-invoices', label: 'Invoices' },
{ to: '/my-company', label: 'Company' },
].map(({ to, label }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
{links.map(({ to, label, icon }) => (
<NavLink key={label} to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
))}
</div>
@@ -61,31 +89,29 @@ function ClientNav({ onNav }) {
}
function ExternalNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const links = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/assigned-requests', label: 'Requests' },
{ to: '/my-projects-sub', label: 'Projects' },
{ to: '/my-invoices-sub', label: 'Invoices' },
{ to: '/file-sharing', label: 'File Sharing' },
{ to: '/survey-maker', label: 'Survey Maker' },
{ to: '/brand-book', label: 'Brand Book Maker' },
{ to: '/converters', label: 'Image Converter' },
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/subs-invoices', label: 'Invoices', icon: ICONS.invoices },
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
];
return (
<div className="sidebar-section">
{links.map(({ to, label }, index) => (
{links.map(({ to, label, icon }, index) => (
<div key={to}>
{index === 4 && (
<>
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
Team Tools
</div>
<div className="sidebar-tools-divider" />
<div className="sidebar-tools-label">Tools</div>
</>
)}
<NavLink to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
{label}
<NavLink to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink>
</div>
))}
@@ -93,95 +119,288 @@ function ExternalNav({ onNav }) {
);
}
export default function Layout({ children }) {
function getInitialTheme() {
if (typeof document !== 'undefined') {
return document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'dark';
}
return 'dark';
}
export default function Layout({ children, loading = false }) {
const { currentUser, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark');
const [theme, setTheme] = useState(getInitialTheme);
const [menuOpen, setMenuOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('sidebarCollapsed') === 'true');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [], companies: [], people: [] });
const [searchOpen, setSearchOpen] = useState(false);
const searchInputRef = useRef(null);
const [avatarOpen, setAvatarOpen] = useState(false);
const avatarRef = useRef(null);
const hour = new Date().getHours();
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const isInvoiceDetailRoute =
location.pathname.startsWith('/finances/') ||
location.pathname.startsWith('/invoices/') ||
location.pathname.startsWith('/client-invoices/') ||
location.pathname.startsWith('/subs-invoices/') ||
location.pathname.startsWith('/my-invoices-sub/') ||
location.pathname.startsWith('/sub-invoices/');
const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute;
const isFinancesRoute =
location.pathname === '/finances' ||
location.pathname.startsWith('/finances/') ||
location.pathname === '/invoices' ||
location.pathname.startsWith('/invoices/') ||
location.pathname === '/sub-invoices' ||
location.pathname.startsWith('/sub-invoices/') ||
location.pathname === '/client-invoices' ||
location.pathname.startsWith('/client-invoices/') ||
location.pathname === '/my-invoices' ||
location.pathname.startsWith('/my-invoices/') ||
location.pathname === '/subs-invoices' ||
location.pathname.startsWith('/subs-invoices/') ||
location.pathname === '/my-invoices-sub' ||
location.pathname.startsWith('/my-invoices-sub/');
const isReportsRoute = location.pathname === '/reports';
const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices';
const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
const headerSubtitle = isProfileRoute
? 'Account details and security settings.'
: isFinancesRoute
? currentUser?.role === 'external'
? 'Track completed work, invoice history and payment status.'
: currentUser?.role === 'client'
? 'View invoices, payment status and receipt history.'
: 'Invoices, expenses and subcontractor POs.'
: isReportsRoute
? 'Track task versions, billing state and exportable summaries.'
: isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and clients.'
: isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
const detailBackTarget = isTaskDetailRoute
? '/tasks'
: currentUser?.role === 'team'
? '/finances'
: currentUser?.role === 'client'
? '/client-invoices'
: '/subs-invoices';
const detailBackLabel = isTaskDetailRoute ? 'Tasks' : financeHeaderTitle;
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
}, [theme]);
useEffect(() => {
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed));
}, [sidebarCollapsed]);
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [], companies: [], people: [] }); return; }
const t = setTimeout(async () => {
const { supabase } = await import('../lib/supabase');
const [{ data: projects }, { data: tasks }, { data: companies }, { data: people }] = await Promise.all([
supabase.from('projects').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
supabase.from('tasks').select('id, title').ilike('title', `%${searchQuery}%`).limit(6),
supabase.from('companies').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
supabase.from('profiles').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
]);
setSearchResults({ projects: projects || [], tasks: tasks || [], companies: companies || [], people: people || [] });
}, 280);
return () => clearTimeout(t);
}, [searchQuery]);
useEffect(() => {
if (!avatarOpen) return;
function handler(e) {
if (avatarRef.current && !avatarRef.current.contains(e.target)) setAvatarOpen(false);
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [avatarOpen]);
const [navigating, setNavigating] = useState(false);
const navTimerRef = useRef(null);
// Close menu on route change (derived-state pattern, no effect needed)
const [lastPathname, setLastPathname] = useState(location.pathname);
if (lastPathname !== location.pathname) {
setLastPathname(location.pathname);
setMenuOpen(false);
setAvatarOpen(false);
}
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const handleLogout = async () => {
await logout();
navigate('/');
};
useEffect(() => {
setNavigating(true);
clearTimeout(navTimerRef.current);
navTimerRef.current = setTimeout(() => setNavigating(false), 500);
return () => clearTimeout(navTimerRef.current);
}, [location.pathname]);
const initials = currentUser?.name
?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const handleLogout = async () => { await logout(); navigate('/'); };
const hasResults =
searchResults.projects.length > 0 ||
searchResults.tasks.length > 0 ||
searchResults.companies.length > 0 ||
searchResults.people.length > 0;
return (
<div className={`app-layout${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
{/* Overlay */}
<div className="app-layout">
{(navigating || loading) && <PageLoader />}
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
<div className="sidebar-logo">
<img className="brand-logo brand-logo-sidebar" src="/fourge-logo.png" alt="Fourge Branding" />
<button
className="sidebar-pin-toggle"
onClick={() => setSidebarCollapsed(current => !current)}
title={sidebarCollapsed ? 'Pin sidebar open' : 'Collapse sidebar'}
aria-label={sidebarCollapsed ? 'Pin sidebar open' : 'Collapse sidebar'}
>
{sidebarCollapsed ? '' : ''}
</button>
<img className="brand-logo brand-logo-sidebar" src="/logonowordmark.png" alt="Fourge Branding" />
</div>
{!sidebarCollapsed && (
currentUser?.role === 'team'
{currentUser?.role === 'team'
? <TeamNav onNav={() => setMenuOpen(false)} />
: currentUser?.role === 'external'
? <ExternalNav onNav={() => setMenuOpen(false)} />
: <ClientNav onNav={() => setMenuOpen(false)} />
)}
}
<div className="sidebar-bottom">
<NavLink to="/settings" onClick={() => setMenuOpen(false)} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<div className="sidebar-avatar" style={{ width: 28, height: 28, fontSize: 11, flexShrink: 0 }}>{initials}</div>
<div className="sidebar-user-info">
<div className="sidebar-user-name">{currentUser?.name || 'Set your name'}</div>
<div className="sidebar-user-role">{currentUser?.role === 'external' ? 'Team' : currentUser?.role}</div>
</div>
</NavLink>
<div className="sidebar-bottom-actions">
{!sidebarCollapsed && <button className="sidebar-link" style={{ flex: 1 }} onClick={handleLogout}>Sign Out</button>}
<button
onClick={toggleTheme}
className="sidebar-theme-toggle"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? '☀' : '☾'}
<div className="sidebar-mobile-footer">
<button className="sidebar-link" onClick={() => { navigate('/profile'); setMenuOpen(false); }} title="Profile">
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={26} fontSize={10} />
<span className="nav-label">Profile</span>
</button>
<button className="sidebar-link" onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
<span className="nav-icon">
{theme === 'dark'
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>}
</span>
<span className="nav-label">{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
</button>
<button className="sidebar-link" onClick={handleLogout} title="Sign Out">
<span className="nav-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 14H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h3"/><polyline points="10 11 13 8 10 5"/><line x1="13" y1="8" x2="6" y2="8"/></svg>
</span>
<span className="nav-label">Sign Out</span>
</button>
</div>
</div>
</aside>
<div className="main-wrapper">
{/* Mobile top bar inside main wrapper so it sits at the top */}
<div className="mobile-topbar">
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
<span /><span /><span />
</button>
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
</div>
<main className="main-content">
<div className="site-header">
<div>
{isTaskDetailRoute || isInvoiceDetailRoute ? (
<div>
<Link to={detailBackTarget} className="dashboard-inline-link" style={{ display: 'inline-flex', alignItems: 'center', gap: 1, color: 'var(--text-muted)', textDecoration: 'none' }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 24, height: 24, flexShrink: 0 }}><polyline points="10 4 6 8 10 12"/></svg>
<span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>{detailBackLabel}</span>
</Link>
<div className="site-header-sub" style={{ paddingLeft: 25 }}>Return back to previous page</div>
</div>
) : (
<>
<div className="site-header-greeting">{headerTitle}</div>
<div className="site-header-sub">{headerSubtitle}</div>
</>
)}
</div>
<div className="site-header-right">
<div className="site-header-search-wrap">
<span className="site-header-search-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>
</span>
<input
ref={searchInputRef}
className="site-header-search"
type="text"
placeholder="Search"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onFocus={() => setSearchOpen(true)}
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchQuery(''); }, 150)}
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchQuery(''); } }}
/>
{searchOpen && searchQuery.trim() && (
<div className="site-header-dropdown">
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
{searchResults.projects.length > 0 && (
<>
<div className="site-header-dropdown-group">Projects</div>
{searchResults.projects.map(p => (
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
{p.name}
</div>
))}
</>
)}
{searchResults.tasks.length > 0 && (
<>
<div className="site-header-dropdown-group">Tasks</div>
{searchResults.tasks.map(r => (
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/tasks/${r.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
{r.title}
</div>
))}
</>
)}
{searchResults.companies.length > 0 && (
<>
<div className="site-header-dropdown-group">Companies</div>
{searchResults.companies.map(c => (
<div key={c.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/company/${c.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.companies}</span>
{c.name}
</div>
))}
</>
)}
{searchResults.people.length > 0 && (
<>
<div className="site-header-dropdown-group">People</div>
{searchResults.people.map(p => (
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/profile/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.users}</span>
{p.name || 'Unknown User'}
</div>
))}
</>
)}
</div>
)}
</div>
<button onClick={toggleTheme} className="site-header-theme-toggle" title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
{theme === 'dark'
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>
}
</button>
<div className="site-header-avatar-wrap" ref={avatarRef}>
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu" style={{ overflow: 'hidden' }}>
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={68} fontSize={20} />
</button>
{avatarOpen && (
<div className="site-header-avatar-menu">
<button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Profile</button>
<div className="site-header-avatar-divider" />
<button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button>
</div>
)}
</div>
</div>
</div>
{children}
</main>
</div>
+37 -8
View File
@@ -1,15 +1,44 @@
export default function PageLoader({ opacity = 0.6 }) {
export default function PageLoader({ label = 'Loading', progress = null, scope = 'page' }) {
const hasProgress = typeof progress === 'number' && Number.isFinite(progress);
const isContainerScope = scope === 'container';
return (
<div
style={{
minHeight: '100vh',
background: 'var(--bg, #111)',
<div style={{
position: isContainerScope ? 'absolute' : 'fixed',
inset: 0,
zIndex: isContainerScope ? 20 : 1200,
background: 'var(--overlay-scrim)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img src="/fourge-logo.png" alt="Loading" style={{ width: 160, opacity }} />
padding: 24,
borderRadius: isContainerScope ? 'inherit' : 0,
}}>
<div style={{
background: 'var(--popup-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '18px 21px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
width: 'min(320px, 100%)',
}}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 14 }}>
{label}
</div>
<>
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden', marginBottom: hasProgress ? 8 : 0 }}>
<div
className={hasProgress ? undefined : 'shared-progress-bar indeterminate'}
style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: hasProgress ? `${progress}%` : undefined, transition: 'width 0.2s ease' }}
/>
</div>
{hasProgress && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', textAlign: 'right' }}>{progress}%</div>
)}
</>
</div>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
function normalizeName(value) {
return String(value || '').trim().toLowerCase();
}
export function resolveProfileAvatar({
avatarUrl = '',
profile = null,
profileId = '',
name = '',
profilesById = null,
profilesByName = null,
}) {
if (avatarUrl) return avatarUrl;
if (profile?.avatar_url) return profile.avatar_url;
if (profileId && profilesById?.get(profileId)?.avatar_url) return profilesById.get(profileId).avatar_url;
const normalized = normalizeName(name || profile?.name);
if (normalized && profilesByName?.get(normalized)?.avatar_url) return profilesByName.get(normalized).avatar_url;
return '';
}
export default function ProfileAvatar({
profile = null,
profileId = '',
name = '',
avatarUrl = '',
profilesById = null,
profilesByName = null,
size = 32,
fontSize = 12,
fallbackBg = 'var(--accent)',
fallbackColor = '#000',
alt = '',
style = {},
}) {
const resolvedUrl = resolveProfileAvatar({ avatarUrl, profile, profileId, name, profilesById, profilesByName });
const initials = String(name || profile?.name || '?')
.split(' ')
.map((part) => part[0])
.slice(0, 2)
.join('')
.toUpperCase();
if (resolvedUrl) {
return (
<div style={{ width: size, height: size, borderRadius: '50%', overflow: 'hidden', flexShrink: 0, ...style }}>
<img src={resolvedUrl} alt={alt || name || profile?.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
);
}
return (
<div
style={{
width: size,
height: size,
borderRadius: '50%',
background: fallbackBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
...style,
}}
>
<span style={{ fontSize, fontWeight: 600, color: fallbackColor, lineHeight: 1 }}>{initials || '?'}</span>
</div>
);
}
+7 -2
View File
@@ -1,13 +1,18 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children, role }) {
function getRoleHomePath(role) {
if (role === 'team') return '/team/dashboard';
return '/dashboard';
}
export default function ProtectedRoute({ children, role, redirectTo }) {
const { currentUser } = useAuth();
if (!currentUser) return <Navigate to="/" replace />;
if (role) {
const allowed = Array.isArray(role) ? role : [role];
if (!allowed.includes(currentUser.role)) {
return <Navigate to={currentUser.role === 'client' ? '/my-dashboard' : '/dashboard'} replace />;
return <Navigate to={redirectTo || getRoleHomePath(currentUser.role)} replace />;
}
}
return children;
+364
View File
@@ -0,0 +1,364 @@
import { useState, useEffect, useRef } from 'react';
import { supabase } from '../lib/supabase';
import { serviceTypes } from '../data/mockData';
import FileAttachment from './FileAttachment';
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
const modalInputStyle = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
const modalTextAreaStyle = { ...modalInputStyle, minHeight: 100, padding: '8px 5px', lineHeight: 1.4 };
const modalActionButtonStyle = { width: 132, justifyContent: 'center', flex: '0 0 132px' };
const emptyForm = (companyId = '') => ({
companyId,
project: '',
serviceType: '',
signFamily: '',
title: '',
deadline: defaultDeadline(),
description: '',
isHot: false,
requestedBy: '',
});
// Props:
// companies [{id, name}] — all selectable companies
// currentUser {id, name} — logged-in team member (added to requester list as "You")
// showRequester bool — true for team (submitting on behalf of client)
// onSubmit async (formData, files, existingProjects) => void — throw to show error
// onCancel () => void — optional; shows Cancel button when provided
// saving bool
// error string
// submitLabel string
// initialCompanyId string — pre-selected company (client with 1 company)
export default function RequestForm({
companies = [],
currentUser = null,
showRequester = false,
onSubmit,
onCancel = null,
saving = false,
error = '',
submitLabel = 'Submit Request',
initialCompanyId = '',
initialValues = null,
lockedFields = [],
}) {
const [form, setForm] = useState(() => ({
...emptyForm(initialCompanyId),
requestedBy: showRequester ? (currentUser?.id || '') : '',
...(initialValues || {}),
}));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
const [files, setFiles] = useState([]);
const [existingProjects, setExistingProjects] = useState([]);
const [customProjects, setCustomProjects] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [companyUsers, setCompanyUsers] = useState([]);
const [signFamilies, setSignFamilies] = useState([]);
const [signCount, setSignCount] = useState('');
const [signs, setSigns] = useState([]);
const [localError, setLocalError] = useState('');
const usesSignFields = form.serviceType === 'Brand Book';
useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []);
useEffect(() => {
if (!usesSignFields) { setSignCount(''); setSigns([]); }
}, [usesSignFields]);
useEffect(() => {
const n = Math.max(0, parseInt(signCount) || 0);
setSigns(prev => {
if (n < prev.length) return prev.slice(0, n);
if (n > prev.length) {
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
return [...prev, ...extra];
}
return prev;
});
}, [signCount]);
const companyId = form.companyId || initialCompanyId;
// Single-company user: lock selection to their one company.
useEffect(() => {
if (companies.length === 1 && !form.companyId) {
setForm(f => ({ ...f, companyId: companies[0].id }));
}
}, [companies, form.companyId]);
useEffect(() => {
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
Promise.all([
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
showRequester
? Promise.all([
supabase.from('profiles').select('id, name, email').eq('company_id', companyId).eq('role', 'client'),
supabase.from('company_members').select('profile:profiles(id, name, email, role)').eq('company_id', companyId),
])
: Promise.resolve(null),
]).then(([projectsRes, usersData]) => {
setExistingProjects(projectsRes.data || []);
if (usersData) {
const [directRes, membersRes] = usersData;
const direct = (directRes.data || []);
const fromMembers = (membersRes.data || []).map(m => m.profile).filter(p => p?.role === 'client');
const seen = new Set();
const merged = [...direct, ...fromMembers].filter(u => {
if (!u?.id || seen.has(u.id)) return false;
seen.add(u.id);
return true;
}).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
setCompanyUsers(merged);
}
});
if (companyId !== prevCompanyIdRef.current) {
setForm(f => ({ ...f, project: '', requestedBy: '' }));
setCustomProjects([]);
setIsTypingProject(false);
setNewProjectName('');
}
prevCompanyIdRef.current = companyId;
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
const set = (field) => (e) => {
if (localError) setLocalError('');
setForm(f => ({ ...f, [field]: e.target.value }));
};
const setSign = (id, field, value) => {
if (localError) setLocalError('');
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
};
const allProjectNames = [
...existingProjects.map(p => p.name),
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
];
const handleProjectSelect = (e) => {
if (e.target.value === '__new__') {
setIsTypingProject(true);
setForm(f => ({ ...f, project: '' }));
} else {
setForm(f => ({ ...f, project: e.target.value }));
}
};
const handleAddProject = () => {
const name = newProjectName.trim();
if (!name) return;
if (!customProjects.includes(name) && !existingProjects.some(p => p.name === name)) {
setCustomProjects(prev => [...prev, name]);
}
setForm(f => ({ ...f, project: name }));
setIsTypingProject(false);
setNewProjectName('');
};
const showCompanySelect = companies.length > 1 || showRequester;
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
];
const handleSubmit = (e) => {
e.preventDefault();
setLocalError('');
const selectedRequester = showRequester
? requesterOptions.find(option => option.id === form.requestedBy) || null
: null;
const requestedBy = showRequester
? (selectedRequester?.id || '')
: (currentUser?.id || '');
const requestedByName = showRequester
? ((selectedRequester?.name || '').replace(/\s+\(You\)$/, '') || '')
: (currentUser?.name || '');
let normalizedSignCount = null;
let normalizedSigns = [];
if (usesSignFields) {
normalizedSignCount = parseInt(signCount, 10) || null;
const formData = new FormData(e.currentTarget);
normalizedSigns = Array.from({ length: normalizedSignCount || 0 }, (_, index) => {
const stateSign = signs[index] || {};
const domValue = formData.get(`sign_info_${index + 1}`);
const signName = typeof domValue === 'string'
? domValue.trim()
: String(stateSign.signName || '').trim();
return {
id: stateSign.id || `sign-${index + 1}`,
signName,
signFamily: stateSign.signFamily || '',
};
}).filter(sign => sign.signName || sign.signFamily);
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
setLocalError('Please fill in the sign information for each sign before submitting.');
return;
}
}
onSubmit(
{
...form,
companyId,
requestedBy,
requestedByName,
signCount: usesSignFields ? normalizedSignCount : null,
signs: usesSignFields ? normalizedSigns : [],
},
files,
existingProjects
);
};
return (
<form onSubmit={handleSubmit}>
{/* Row 1: Company + Project */}
<div className="grid-2">
{lockedFields.includes('company') ? (
<div className="form-group">
<label style={modalLabelStyle}>Company</label>
<input value={companies.find(c => c.id === companyId)?.name || companyId} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
</div>
) : showCompanySelect ? (
<div className="form-group">
<label style={modalLabelStyle}>Company *</label>
<select value={form.companyId} onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}>
<option value="">Select company...</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
) : companies.length === 1 ? (
<div className="form-group">
<label style={modalLabelStyle}>Company</label>
<input value={companies[0].name} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
</div>
) : <div />}
<div className="form-group">
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
{lockedFields.includes('project') ? (
<input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
) : 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(); handleAddProject(); } }} autoFocus style={{ ...modalInputStyle, flex: 1 }} />
<button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
</div>
) : (
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId} style={modalInputStyle}>
<option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option>
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
{(!showCompanySelect || companyId) && <option value="__new__"> Create new project...</option>}
</select>
)}
</div>
</div>
{/* Row 2: Service Type + Desired Deadline */}
<div className="grid-2">
<div className="form-group">
<label style={modalLabelStyle}>Service Type *</label>
<select value={form.serviceType} onChange={set('serviceType')} required style={modalInputStyle}>
<option value="">Select service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="form-group">
<label style={modalLabelStyle}>Desired Deadline</label>
<input type="date" value={form.deadline} onChange={set('deadline')} style={modalInputStyle} />
</div>
</div>
{showRequester && (
<div className="form-group">
<label style={modalLabelStyle}>Requested By *</label>
<select
value={form.requestedBy}
onChange={set('requestedBy')}
required
disabled={!companyId}
style={modalInputStyle}
>
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
{requesterOptions.map(user => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</div>
)}
{/* Row 3: Title/Location + Mark as Hot inline */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={modalLabelStyle}>Project / Location *</label>
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
</div>
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 4, whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
<span>Mark as Hot</span>
</label>
</div>
{usesSignFields && (
<div className="form-group">
<label style={modalLabelStyle}>Sign Count *</label>
<input
type="number"
min="1"
max="50"
placeholder="How many signs?"
value={signCount}
onChange={e => setSignCount(e.target.value)}
required
style={{ ...modalInputStyle, width: '100%' }}
/>
</div>
)}
{usesSignFields && signs.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{signs.map((sign, i) => (
<div key={sign.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px' }}>
<div style={{ ...modalLabelStyle, marginBottom: 8 }}>Sign {i + 1}</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={modalLabelStyle}>Sign Information *</label>
<input
type="text"
name={`sign_info_${i + 1}`}
placeholder="e.g. Main Entry, Drive Thru"
value={sign.signName}
onChange={e => setSign(sign.id, 'signName', e.target.value)}
required
style={modalInputStyle}
/>
</div>
</div>
))}
</div>
)}
<div className="form-group">
<label style={modalLabelStyle}>{usesSignFields ? 'Notes' : 'Description'} *</label>
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required />
</div>
<FileAttachment files={files} onChange={setFiles} />
{(localError || error) && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {localError || error}</div>}
<div className="action-buttons modal-action-row" style={{ justifyContent: 'flex-end' }}>
<button type="submit" className="btn btn-outline" disabled={saving} style={modalActionButtonStyle}>
{saving ? 'Submitting...' : submitLabel}
</button>
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel} style={modalActionButtonStyle}>Cancel</button>}
</div>
</form>
);
}
+15
View File
@@ -0,0 +1,15 @@
export default function SortTh({ col, children, sortKey, sortDir, onSort, style, className }) {
const active = sortKey === col;
return (
<th
className={className}
onClick={() => onSort(col)}
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
>
{children}
<span style={{ marginLeft: 4, opacity: active ? 0.85 : 0.2, fontSize: 9 }}>
{active ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</th>
);
}
Executable → Regular
+7 -5
View File
@@ -4,8 +4,10 @@ const labels = {
not_started: 'Not Started',
in_progress: 'In Progress',
on_hold: 'On Hold',
client_review: 'Client Review',
client_approved: 'Client Approved',
client_review: 'In Review',
client_approved: 'Approved',
invoiced: 'Invoiced',
paid: 'Paid',
active: 'Active',
completed: 'Completed',
superseded: 'Superseded',
@@ -16,10 +18,10 @@ const labels = {
client: 'Client',
};
export default function StatusBadge({ status }) {
export default function StatusBadge({ status, label }) {
return (
<span className={`badge badge-${status}`}>
{labels[status] || status}
<span className={`badge badge-status badge-${status}`}>
{label ?? labels[status] ?? status}
</span>
);
}
@@ -0,0 +1,112 @@
import SortTh from './SortTh';
function fmt(val) {
return `$${Number(val || 0).toFixed(2)}`;
}
function inferItemType(item) {
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
if (match) {
return Number(match[1]) <= 0
? { label: 'New', badgeClass: 'badge-initial' }
: { label: 'Revision', badgeClass: 'badge-client_revision' };
}
if (item?.task_id) return { label: 'Task', badgeClass: 'badge-in_progress' };
return { label: 'Other', badgeClass: 'badge-initial' };
}
export default function SubcontractorInvoiceDetailView({
error,
headerActions,
leftCardTitle,
leftCardBody,
rightCardTitle,
rightCardBody,
sortKey,
sortDir,
onSort,
items,
notes,
total,
}) {
return (
<div className="invoice-detail-shell">
{error ? <div className="notification notification-info" style={{ marginBottom: 16, flexShrink: 0 }}>{error}</div> : null}
<div className="invoice-detail-stack">
<div className="invoice-detail-summary-grid">
<div className="card invoice-detail-card">
<div className="invoice-detail-section-title">{leftCardTitle}</div>
{leftCardBody}
</div>
<div className="card invoice-detail-card">
<div className="invoice-detail-card-header">
<div className="invoice-detail-section-title" style={{ marginBottom: 0 }}>{rightCardTitle}</div>
{headerActions ? <div className="invoice-detail-card-actions">{headerActions}</div> : null}
</div>
{rightCardBody}
</div>
</div>
<div className="card invoice-detail-card invoice-detail-line-items-card">
<div className="invoice-detail-section-title">Line Items</div>
{items.length === 0 ? (
<div className="card-empty-center" style={{ minHeight: 120 }}>No line items.</div>
) : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ overflowY: 'auto' }}>
<table className="table-sticky-head invoice-detail-table" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '12%' }} />
<col style={{ width: '46%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '14%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={onSort}>Type</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={onSort}>Description</SortTh>
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'center' }}>Qty</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'right' }}>Unit Price</SortTh>
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={onSort} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{items.map((item) => {
const itemType = item._inferredType || inferItemType(item);
return (
<tr key={item.id}>
<td style={{ padding: '5px 0' }}>
<span className={`badge ${itemType.badgeClass}`}>{itemType.label}</span>
</td>
<td className="invoice-detail-cell">{item.description}</td>
<td className="invoice-detail-cell" style={{ textAlign: 'center' }}>{item.quantity}</td>
<td className="invoice-detail-cell" style={{ textAlign: 'right' }}>{fmt(item.unit_price)}</td>
<td className="invoice-detail-cell" style={{ textAlign: 'right', fontWeight: 400 }}>
{fmt(Number(item.unit_price || 0) * Number(item.quantity || 1))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="invoice-detail-total-row">
<div style={{ textAlign: 'right' }}>
<div className="invoice-detail-total-label">Total</div>
<div className="invoice-detail-total-value">{fmt(total)}</div>
</div>
</div>
</div>
{notes ? (
<div className="card invoice-detail-card">
<div className="invoice-detail-section-title">Notes</div>
<p className="invoice-detail-notes">{notes}</p>
</div>
) : null}
</div>
</div>
);
}
+567
View File
@@ -0,0 +1,567 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import LoadingButton from './LoadingButton';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { sendEmail } from '../lib/email';
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
import { useActionLock } from '../hooks/useActionLock';
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const REVISION_RATE = 30;
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 FINANCE_MODAL_INPUT_STYLE = {
...FIELD_INPUT_STYLE,
width: '100%',
padding: '0 10px',
fontSize: 13,
color: 'var(--text-primary)',
background: 'var(--card-bg-2)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'inherit',
boxSizing: 'border-box',
};
const FINANCE_MODAL_TEXTAREA_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 72,
padding: '10px',
resize: 'vertical',
};
const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 32,
padding: '0 8px',
};
const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
fontVariantNumeric: 'tabular-nums',
};
const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
fontSize: 24,
fontWeight: 500,
lineHeight: 1.1,
color: 'var(--accent)',
fontVariantNumeric: 'tabular-nums',
};
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function versionLabel(version = 0) {
return `R${String(version).padStart(2, '0')}`;
}
function parseVersionFromDescription(description = '') {
const match = String(description).match(/\bR(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
function subcontractorVersionRate(version, newWorkRate) {
if (version <= 0) return newWorkRate;
if (version === 1) return 0;
return REVISION_RATE;
}
function newItem(description = '', unitPrice = '', quantity = 1, taskId = null, isRevision = false, versionNumber = 0, lineKey = null) {
return {
id: crypto.randomUUID(),
description,
unit_price: unitPrice,
quantity,
task_id: taskId,
is_revision: isRevision,
version_number: versionNumber,
line_key: lineKey || (taskId ? `${taskId}:${versionNumber}` : crypto.randomUUID()),
};
}
function genNumber(count) {
const year = new Date().getFullYear();
return `INVSUB-${year}-${String(count + 1).padStart(3, '0')}`;
}
export default function SubcontractorInvoiceForm({ embedded = false, onCancel, onSubmitted }) {
const navigate = useNavigate();
const { currentUser } = useAuth();
const [invoiceNumber, setInvoiceNumber] = useState('');
const [completedTasks, setCompletedTasks] = useState([]);
const [loadingTasks, setLoadingTasks] = useState(true);
const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const guard = useActionLock();
const [error, setError] = useState('');
const [lineItemsPulse, setLineItemsPulse] = useState(false);
const dragItem = useRef(null);
const lineItemsRef = useRef(null);
const rate = Number(currentUser?.brand_book_rate || 60);
useEffect(() => {
async function load() {
if (!currentUser?.id) {
setCompletedTasks([]);
setLoadingTasks(false);
return;
}
setLoadingTasks(true);
setError('');
try {
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
supabase
.from('tasks')
.select('id, title, status, current_version, project:projects(name)')
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
.eq('assigned_to', currentUser.id)
.order('title'),
supabase
.from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'),
]);
if (tasksError) throw tasksError;
if (invoicesError) throw invoicesError;
if (nextNumError) throw nextNumError;
setInvoiceNumber(nextNum || genNumber(0));
const invoicedUnitKeys = new Set(
(existingInvoices || []).flatMap((inv) => asArray(inv.items).map((item) => {
if (!item.task_id) return null;
// Stored version preferred; legacy rows (null) fall back to description parsing
const version = item.version_number != null ? Number(item.version_number) : parseVersionFromDescription(item.description);
return `${item.task_id}:${version}`;
}).filter(Boolean))
);
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean);
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
? await supabase
.from('submissions')
.select('task_id, deliveries(version_number, sent_by, sent_at)')
.in('task_id', taskIds)
: { data: [], error: null };
if (submissionsError) throw submissionsError;
const deliveriesByTask = new Map();
for (const row of (submissionRows || [])) {
const taskId = row.task_id;
if (!taskId) continue;
const bucket = deliveriesByTask.get(taskId) || new Map();
for (const delivery of asArray(row.deliveries)) {
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
const version = Number(delivery.version_number || 0);
if (!bucket.has(version)) bucket.set(version, delivery);
}
deliveriesByTask.set(taskId, bucket);
}
const units = (tasks || []).flatMap((task) => {
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
return versions
.filter((version) => isCompletedVersionEligible(task, version))
.map((version) => {
const key = `${task.id}:${version}`;
return {
key,
task_id: task.id,
version_number: version,
is_revision: version > 0,
title: task.title,
project_name: task.project?.name || '',
description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
};
});
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
setCompletedTasks(units);
} catch (err) {
console.error('Failed to load subcontractor invoice tasks:', err);
setCompletedTasks([]);
setError(err?.message || 'Could not load completed tasks.');
} finally {
setLoadingTasks(false);
}
}
load();
}, [currentUser?.id, currentUser?.name, rate]);
const addedTaskIds = useMemo(() => new Set(items.map((item) => item.line_key).filter(Boolean)), [items]);
const focusLineItems = () => {
setLineItemsPulse(true);
lineItemsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
window.setTimeout(() => setLineItemsPulse(false), 1200);
};
const addTask = (task) => {
if (addedTaskIds.has(task.key)) return;
const toAdd = [newItem(task.description, task.rate, 1, task.task_id, task.is_revision, task.version_number, task.key)];
setItems((prev) => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
return [...prev, ...toAdd];
});
focusLineItems();
};
const updateItem = (id, field, value) => {
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)));
};
const removeItem = (id) => setItems((prev) => prev.filter((item) => item.id !== id));
const handleDrop = (targetIndex) => {
if (dragItem.current === null || dragItem.current === targetIndex) {
dragItem.current = null;
return;
}
setItems((prev) => {
const next = [...prev];
const [moved] = next.splice(dragItem.current, 1);
next.splice(targetIndex, 0, moved);
return next;
});
dragItem.current = null;
};
const handleCancel = () => {
if (saving) return;
if (onCancel) {
onCancel();
return;
}
navigate('/subs-invoices');
};
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
const handleSubmit = guard('sub-invoice-submit', async () => {
const valid = items.filter((item) => String(item.description).trim());
if (!valid.length) {
setError('Add at least one line item.');
return;
}
setSaving(true);
setError('');
try {
const { data: inv, error: invErr } = await supabase
.from('subcontractor_invoices')
.insert({
profile_id: currentUser.id,
invoice_number: invoiceNumber.trim(),
status: 'submitted',
notes: notes.trim(),
submitted_at: new Date().toISOString(),
})
.select()
.single();
if (invErr) throw invErr;
const { error: itemsErr } = await supabase.from('subcontractor_invoice_items').insert(
valid.map((item, idx) => ({
invoice_id: inv.id,
task_id: item.task_id || null,
version_number: item.task_id ? Number(item.version_number) || 0 : null,
description: String(item.description).trim(),
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
sort_order: idx,
}))
);
if (itemsErr) throw itemsErr;
const invoiceTotal = valid.reduce((sum, item) => sum + (Number(item.unit_price) || 0) * (Number(item.quantity) || 1), 0);
sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
subName: currentUser.name,
subEmail: currentUser.email || '',
invoiceNumber: inv.invoice_number,
total: invoiceTotal.toFixed(2),
invoiceId: inv.id,
}).catch((err) => console.error('Sub invoice notification failed:', err));
if (onSubmitted) {
await onSubmitted(inv);
} else {
navigate(`/subs-invoices/${inv.id}`);
}
} catch (err) {
setError(err?.message || 'Failed to submit invoice.');
setSaving(false);
}
});
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
{!embedded && (
<>
<button className="back-link" onClick={handleCancel}> Back to Invoices</button>
<div className="page-header">
<div>
<div className="page-title">New Invoice</div>
<div className="page-subtitle">
Invoice date: {new Date(INVOICE_TODAY).toLocaleDateString()} · {invoiceNumber}
</div>
</div>
</div>
</>
)}
{embedded ? (
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>New Invoice</div>
<div>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Invoice Date</div>
<input type="text" value={new Date(INVOICE_TODAY).toLocaleDateString()} readOnly style={FINANCE_MODAL_INPUT_STYLE} />
</div>
<div>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Invoice Number</div>
<input type="text" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} style={FINANCE_MODAL_INPUT_STYLE} />
</div>
<div>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Notes</div>
<textarea
placeholder="Additional notes for the team..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={FINANCE_MODAL_TEXTAREA_STYLE}
/>
</div>
<div style={{ marginTop: 'auto', paddingTop: 8 }}>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Total</div>
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${total.toFixed(2)}</div>
</div>
{error && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 3 }}>Completed Tasks</div>
{completedTasks.length > 0 && !loadingTasks && (
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 11 }}
onClick={() => completedTasks.forEach((task) => { if (!addedTaskIds.has(task.key)) addTask(task); })}
>
+ All
</button>
)}
</div>
{loadingTasks ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div>
) : completedTasks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
) : (
completedTasks.map((task) => {
const alreadyAdded = addedTaskIds.has(task.key);
return (
<div key={task.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className={`badge ${task.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
{task.is_revision ? 'Revision' : 'New'}
</span>
<div style={{ fontSize: 12 }}>{task.project_name ? `${task.project_name}` : ''}{task.title} · {versionLabel(task.version_number)}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{task.version_number === 0
? `$${rate.toFixed(2)}`
: task.version_number === 1
? 'Free'
: `$${REVISION_RATE.toFixed(2)}`}
</div>
</div>
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 11 }}
onClick={() => !alreadyAdded && addTask(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})
)}
</div>
<div
ref={lineItemsRef}
style={{
flex: 1,
border: '1px solid var(--border)',
borderColor: lineItemsPulse ? 'var(--accent)' : 'var(--border)',
borderRadius: 8,
padding: 12,
boxShadow: lineItemsPulse ? '0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent)' : 'none',
transition: 'border-color 180ms ease, box-shadow 180ms ease',
}}
>
<div style={{ ...FIELD_LABEL_STYLE, marginBottom: 6 }}>Line Items ({items.filter((item) => String(item.description || '').trim()).length})</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((header, index) => (
<div key={index} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: index > 3 ? 'right' : 'left' }}>{header}</div>
))}
</div>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}
>
<div draggable onDragStart={(e) => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}></div>
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
{item.is_revision ? 'Revision' : 'New'}
</span>
<input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={(e) => updateItem(item.id, 'description', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="0.5" step="0.5" value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={(e) => updateItem(item.id, 'unit_price', e.target.value)} />
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
<button type="button" onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}></button>
</div>
))}
<button type="button" className="btn btn-outline" style={{ marginTop: 8, fontSize: 12 }} onClick={() => { setItems((prev) => [...prev, newItem()]); focusLineItems(); }}>+ Line Item</button>
</div>
</div>
</div>
) : (
<>
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Completed Tasks</div>
{completedTasks.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => completedTasks.forEach((task) => { if (!addedTaskIds.has(task.key)) addTask(task); })}
>
+ Add All
</button>
)}
</div>
{loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : completedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{completedTasks.map((task) => {
const alreadyAdded = addedTaskIds.has(task.key);
return (
<div key={task.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>
{task.project_name ? `${task.project_name}` : ''}{task.title} · {versionLabel(task.version_number)}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{task.version_number === 0
? `New Book · $${rate.toFixed(2)}`
: task.version_number === 1
? `Revision ${versionLabel(task.version_number)} · Free`
: `Revision ${versionLabel(task.version_number)} · $${REVISION_RATE.toFixed(2)}`}
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addTask(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
<div
ref={lineItemsRef}
className="card"
style={{
marginBottom: 24,
borderColor: lineItemsPulse ? 'var(--accent)' : 'var(--border)',
boxShadow: lineItemsPulse ? '0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent)' : 'none',
transition: 'border-color 180ms ease, box-shadow 180ms ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Line Items ({items.filter((item) => String(item.description || '').trim()).length})</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((header, index) => (
<div key={index} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 3 ? 'right' : 'left' }}>{header}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
>
<div draggable onDragStart={(e) => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}></div>
<span className={`badge ${item.is_revision ? 'badge-client_revision' : 'badge-initial'}`}>
{item.is_revision ? 'Revision' : 'New'}
</span>
<input type="text" placeholder="Description..." value={item.description} onChange={(e) => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
<input type="number" min="0.5" step="0.5" value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={(e) => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
</div>
<button onClick={() => removeItem(item.id)} 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={() => { setItems((prev) => [...prev, newItem()]); focusLineItems(); }}>
+ 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: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<textarea
placeholder="Additional notes for the team..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{ minHeight: 80 }}
/>
</div>
</>
)}
<div className={embedded ? 'modal-action-row' : 'action-buttons'} style={embedded ? { paddingTop: 14, marginTop: 14, flexShrink: 0 } : undefined}>
<LoadingButton className="btn btn-primary" onClick={handleSubmit} loading={saving} loadingText="Submitting...">
Submit Invoice
</LoadingButton>
<button className="btn btn-outline" onClick={handleCancel} disabled={saving}>Cancel</button>
</div>
</div>
);
}
Executable → Regular
+22 -7
View File
@@ -12,20 +12,24 @@ export function AuthProvider({ children }) {
const fetchAndCacheProfile = async (authUser, attempt = 0) => {
try {
const { data, error } = await Promise.race([
const [profileResult, membershipsResult] = await Promise.race([
Promise.all([
supabase
.from('profiles')
.select('*, company:companies(id, name, phone, address)')
.eq('id', authUser.id)
.single(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Profile fetch timeout')), 8000)),
]);
if (data) {
const { data: memberships } = await supabase
supabase
.from('company_members')
.select('company:companies(id, name, phone, address)')
.eq('profile_id', authUser.id);
const companies = (memberships || [])
.eq('profile_id', authUser.id),
]),
new Promise((_, reject) => setTimeout(() => reject(new Error('Profile fetch timeout')), 8000)),
]);
const { data, error } = profileResult;
if (data) {
const companies = ((membershipsResult.data || []))
.map(membership => membership.company)
.filter(Boolean);
if (data.role === 'client' && data.company && !companies.some(company => company.id === data.company.id)) {
@@ -120,6 +124,17 @@ export function AuthProvider({ children }) {
};
}, []);
useEffect(() => {
if (!currentUser?.id) return;
const channel = supabase.channel(`profile-${currentUser.id}`)
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'profiles', filter: `id=eq.${currentUser.id}` }, async () => {
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) fetchAndCacheProfile(session.user);
})
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const login = async (email, password) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
Executable → Regular
+2 -1
View File
@@ -8,10 +8,11 @@ export const mockUsers = [
];
export const serviceTypes = [
'Brand Book',
'Sign Family',
'Logo Design',
'Brand Identity',
'Brand Guidelines',
'Brand Book',
'Social Media Graphics',
'Print Design',
'Business Cards',
+24
View File
@@ -0,0 +1,24 @@
import { useRef, useCallback } from 'react';
// Guards async handlers against double-fire (e.g. a rapid double-click that triggers
// both onClick callbacks before React re-renders the now-disabled button). Disabled
// buttons alone don't prevent this because the disable only takes effect after a render.
//
// Usage:
// const guard = useActionLock();
// const handleSubmit = guard('submit', async () => { ... });
//
// Each `key` locks independently, so unrelated buttons don't block each other. While a
// run is in-flight the same key is a no-op until it settles (resolve or throw).
export function useActionLock() {
const locks = useRef(new Set());
return useCallback((key, fn) => async (...args) => {
if (locks.current.has(key)) return undefined;
locks.current.add(key);
try {
return await fn(...args);
} finally {
locks.current.delete(key);
}
}, []);
}
+11
View File
@@ -0,0 +1,11 @@
import { useCallback, useState } from 'react';
import { useRefetchOnFocus } from './useRefetchOnFocus';
import { useRealtimeSubscription } from './useRealtimeSubscription';
export function useLiveRefresh(tables = []) {
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey((key) => key + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(tables, refresh);
return { refreshKey, refresh };
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useRef } from 'react';
import { supabase } from '../lib/supabase';
export function useRealtimeSubscription(tables, onchange) {
const onchangeRef = useRef(onchange);
useEffect(() => { onchangeRef.current = onchange; });
useEffect(() => {
const channelName = `rt-${tables.join('-')}-${Date.now()}`;
let channel = supabase.channel(channelName);
for (const table of tables) {
channel = channel.on('postgres_changes', { event: '*', schema: 'public', table }, () => onchangeRef.current());
}
channel.subscribe();
return () => { supabase.removeChannel(channel); };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useRef } from 'react';
export function useRefetchOnFocus(refetch) {
const refetchRef = useRef(refetch);
useEffect(() => { refetchRef.current = refetch; });
useEffect(() => {
const onFocus = () => refetchRef.current();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, []);
}
+28
View File
@@ -0,0 +1,28 @@
import { useState } from 'react';
export function useSortable(defaultKey = '', defaultDir = 'asc') {
const [sortKey, setSortKey] = useState(defaultKey);
const [sortDir, setSortDir] = useState(defaultDir);
const toggle = (key) => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
const sort = (data, getVal) => {
if (!sortKey) return data;
return [...data].sort((a, b) => {
const av = getVal ? getVal(a, sortKey) : a[sortKey];
const bv = getVal ? getVal(b, sortKey) : b[sortKey];
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const cmp = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
return sortDir === 'asc' ? cmp : -cmp;
});
};
return { sortKey, sortDir, toggle, sort };
}
Executable → Regular
+1005 -443
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
import { supabase } from './supabase';
export async function logActivity({ actorId, actorName, action, taskId, taskTitle, projectId, projectName }) {
const duplicateWindowMs = 8000;
const duplicateCutoff = new Date(Date.now() - duplicateWindowMs).toISOString();
const normalizedActorId = actorId || null;
const normalizedTaskId = taskId || null;
const normalizedProjectId = projectId || null;
// Guard against fast duplicate writes from double-clicks/retries.
const { data: existing, error: existingError } = await supabase
.from('activity_log')
.select('id')
.eq('action', action)
.eq('actor_id', normalizedActorId)
.eq('task_id', normalizedTaskId)
.eq('project_id', normalizedProjectId)
.gte('created_at', duplicateCutoff)
.limit(1);
if (!existingError && (existing || []).length > 0) return;
const { error } = await supabase.from('activity_log').insert({
actor_id: normalizedActorId,
actor_name: actorName || null,
action,
task_id: normalizedTaskId,
task_title: taskTitle || null,
project_id: normalizedProjectId,
project_name: projectName || null,
});
if (error) console.error('logActivity failed:', action, error);
}
+6 -7
View File
@@ -1,4 +1,9 @@
import jsPDF from 'jspdf';
import { formatLongDate } from './dates';
function formatDate(dateStr) {
return formatLongDate(dateStr ? `${dateStr}T12:00:00` : '', '-');
}
// Letter landscape: 792 x 612 pt
const W = 792;
@@ -8,12 +13,6 @@ const ACCENT = [245, 165, 35];
const DARK = [18, 18, 18];
const HEADER_H = 64;
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
function formatCoverAddress(address) {
const parts = String(address || '')
.split(',')
@@ -384,7 +383,7 @@ export async function generateBrandBookEditorPDF(data) {
let pageNum = 0;
const rev = String(revision || '01').padStart(2, '0');
const displayDate = bookDate
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
? formatLongDate(`${bookDate}T12:00:00`, '-')
: '';
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
export function useLiveClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return now;
}
export function DashboardBanner() {
const now = useLiveClock();
const day = now.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'America/New_York' });
const date = now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'America/New_York' });
const time = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' });
return (
<div className="dashboard-banner">
Fourge Branding &bull; {day}, {date} &bull; {time}
</div>
);
}
export function getGreeting() {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
+50
View File
@@ -8,6 +8,46 @@ export function formatDateEST(value) {
});
}
export function formatShortDate(value, fallback = '—') {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export function formatLongDate(value, fallback = '—') {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
export function formatShortDateTime(value, fallback = '—') {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
return `${formatShortDate(date, fallback)} ${date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}`;
}
export function formatDateAtNoon(value, fallback = '—') {
if (!value) return fallback;
const date = new Date(`${value}T12:00:00`);
if (Number.isNaN(date.getTime())) return fallback;
return formatLongDate(date, fallback);
}
export function parseDateOnly(value) {
if (!value) return null;
const [year, month, day] = String(value).split('-').map(Number);
@@ -47,3 +87,13 @@ export function formatDateOnly(value, fallback = '—') {
year: 'numeric',
});
}
export function fmtShortDate(value, fallback = '—') {
if (!value) return fallback;
const date = parseDateOnly(value) || new Date(value);
if (isNaN(date)) return fallback;
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const y = String(date.getFullYear()).slice(2);
return `${m}/${d}/${y}`;
}
+62
View File
@@ -0,0 +1,62 @@
import { supabase } from './supabase';
async function callFolderSync(action, body) {
const { data: { session } } = await supabase.auth.getSession();
const accessToken = session?.access_token;
if (!accessToken) return { ok: false, skipped: true, warning: 'No active session.' };
const response = await fetch(`/api/filebrowser?action=${encodeURIComponent(action)}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.error || 'Folder sync failed.');
}
return payload;
}
export async function ensureCompanyFolder({ companyId }) {
if (!companyId) return { ok: false, skipped: true };
return callFolderSync('ensure-company-folder', { companyId });
}
export async function renameCompanyFolder({ companyId, oldName, newName }) {
if (!companyId || !oldName || !newName) return { ok: false, skipped: true };
return callFolderSync('rename-company-folder', { companyId, oldName, newName });
}
export async function ensureProjectFolder({ projectId }) {
if (!projectId) return { ok: false, skipped: true };
return callFolderSync('ensure-project-folder', { projectId });
}
export async function renameProjectFolder({ projectId, oldName, newName }) {
if (!projectId || !oldName || !newName) return { ok: false, skipped: true };
return callFolderSync('rename-project-folder', { projectId, oldName, newName });
}
export async function ensureSubcontractorFolder({ profileId }) {
if (!profileId) return { ok: false, skipped: true };
return callFolderSync('ensure-profile-folder', { profileId });
}
export async function renameSubcontractorFolder({ profileId, oldName, newName }) {
if (!profileId || !oldName || !newName) return { ok: false, skipped: true };
return callFolderSync('rename-profile-folder', { profileId, oldName, newName });
}
export async function ensureRequestFolder({ projectId, taskTitle }) {
if (!projectId || !taskTitle) return { ok: false, skipped: true };
return callFolderSync('ensure-request-folder', { projectId, taskTitle });
}
export async function uploadRequestFileToSurvey({ projectId, taskTitle, storagePath, fileName, bucket = 'submissions' }) {
if (!projectId || !taskTitle || !storagePath || !fileName) return { ok: false, skipped: true };
return callFolderSync('upload-request-file', { projectId, taskTitle, storagePath, fileName, bucket });
}
+181 -125
View File
@@ -589,158 +589,214 @@ export async function generateReceiptPDF(invoice, company, items, options = {})
export async function generateSubcontractorPOPDF(po, options = {}) {
const logo = await loadImage('/fourge-logo.png');
const doc = new jsPDF({ unit: 'pt', format: 'letter', compress: true, putOnlyUsedFonts: true, precision: 2 });
const pageWidth = 612;
const pageHeight = 792;
const scale = 3;
const margin = 42;
const rightEdge = pageWidth - margin;
const headerH = 62;
const poNumber = po.po_number || 'PO';
const footerLineY = pageHeight - 44;
const footerTextY = pageHeight - 28;
const safeBottom = footerLineY - 16;
const poNumber = po.po_number || 'INV';
const statusLabel = String(po.status || 'draft').replace(/_/g, ' ').toUpperCase();
const projectName = po.project?.name || 'Subcontractor Work';
const companyName = po.project?.company?.name || 'Fourge Branding';
const subcontractorName = po.profile?.name || 'External Team Member';
const subcontractorEmail = po.profile?.email || '';
const tableW = pageWidth - margin * 2;
const rowH = 28;
doc.setFillColor(20, 20, 20);
doc.rect(0, 0, pageWidth, headerH, 'F');
if (logo) {
const logoW = 96;
const logoH = logoW / (logo.naturalWidth / logo.naturalHeight);
doc.addImage(logo, 'PNG', margin, 16, logoW, logoH);
} else {
doc.setFont('helvetica', 'bold');
doc.setFontSize(13);
doc.setTextColor(255, 255, 255);
doc.text('FOURGE BRANDING', margin, 36);
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(170, 170, 170);
doc.text('1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', rightEdge, 35, { align: 'right' });
let y = 102;
doc.setTextColor(30, 30, 30);
doc.setFont('helvetica', 'bold');
doc.setFontSize(22);
doc.text('PURCHASE ORDER', margin, y);
doc.setFontSize(10);
doc.setTextColor(90, 90, 90);
doc.text(poNumber, rightEdge, y - 4, { align: 'right' });
y += 24;
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(120, 120, 120);
doc.text('SUBCONTRACTOR', margin, y);
doc.text('DETAILS', 330, y);
y += 16;
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(30, 30, 30);
doc.text(subcontractorName, margin, y);
doc.text(projectName, 330, y);
y += 14;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(95, 95, 95);
if (subcontractorEmail) doc.text(subcontractorEmail, margin, y);
doc.text(companyName, 330, y);
y += 30;
const rows = [
['PO Date', po.date ? formatDate(po.date) : ''],
const metaRows = [
['Invoice Date', po.date ? formatDate(po.date) : ''],
['Due Date', po.due_date ? formatDate(po.due_date) : 'Not set'],
['Terms', po.terms || 'Net 15'],
['Status', statusLabel],
['Amount', formatCurrency(po.amount)],
];
const tableX = margin;
const tableW = pageWidth - margin * 2;
rows.forEach(([label, value], index) => {
const rowY = y + index * 28;
if (index % 2 === 0) {
doc.setFillColor(248, 248, 248);
doc.rect(tableX, rowY - 16, tableW, 28, 'F');
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(105, 105, 105);
doc.text(label, tableX + 12, rowY);
doc.setFont('helvetica', 'bold');
doc.setTextColor(35, 35, 35);
doc.text(String(value), tableX + tableW - 12, rowY, { align: 'right' });
});
y += rows.length * 28 + 22;
if (po.items?.length > 0) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 30, 30);
doc.text('Line Items', margin, y);
y += 16;
const sortedItems = po.items
const sortedItems = (po.items || [])
.slice()
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
sortedItems.forEach((item, index) => {
const rowTop = y + index * 28;
if (index % 2 === 0) {
doc.setFillColor(248, 248, 248);
doc.rect(tableX, rowTop - 14, tableW, 28, 'F');
// ── Measure and paginate items ──────────────────────────────────────────────
const measureCanvas = document.createElement('canvas');
const mCtx = measureCanvas.getContext('2d');
if (!mCtx) throw new Error('Canvas unavailable');
// First page: header + title block + meta table → items start ~y=310
const firstPageItemsStart = headerH + 20 + 26 + 32 + metaRows.length * rowH + 38;
const contPageItemsStart = headerH + 50; // after continuation label
const firstPageItemSlots = Math.floor((safeBottom - firstPageItemsStart) / rowH);
const contPageItemSlots = Math.floor((safeBottom - contPageItemsStart) / rowH);
const pages = [];
let remaining = [...sortedItems];
pages.push(remaining.splice(0, Math.max(1, firstPageItemSlots)));
while (remaining.length > 0) {
pages.push(remaining.splice(0, Math.max(1, contPageItemSlots)));
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(45, 45, 45);
doc.text(item.description || item.task?.title || 'Work Item', tableX + 12, rowTop);
doc.setFont('helvetica', 'bold');
doc.text(formatCurrency(item.amount), tableX + tableW - 12, rowTop, { align: 'right' });
if (pages.length === 0) pages.push([]);
// ── Per-page canvas renderer ────────────────────────────────────────────────
function renderPageCanvas(pageItems, pageIndex, pageCount) {
const canvas = document.createElement('canvas');
canvas.width = pageWidth * scale;
canvas.height = pageHeight * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
// White background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, pageWidth, pageHeight);
// ── Header bar ────────────────────────────────────────────────────────────
ctx.fillStyle = '#141414';
ctx.fillRect(0, 0, pageWidth, headerH);
if (logo) {
const logoW = 96;
const logoH = logoW / (logo.naturalWidth / logo.naturalHeight);
ctx.drawImage(logo, margin, (headerH - logoH) / 2, logoW, logoH);
} else {
ctx.font = '700 13px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText('FOURGE BRANDING', margin, headerH / 2 + 4);
}
ctx.font = '9px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#aaaaaa';
drawRightText(ctx, '1855.368.7434 | hello@fourgebranding.com | www.fourgebranding.com', rightEdge, headerH / 2 + 3);
// ── Footer (every page) ───────────────────────────────────────────────────
drawRule(ctx, margin, rightEdge, footerLineY);
ctx.font = '8px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#aaaaaa';
mCtx.font = '8px Helvetica, Arial, sans-serif';
const footerLines = wrapText(mCtx,
'This invoice authorizes payment for the subcontractor work described above. Payment is subject to Fourge Branding approval and completion of assigned work.',
rightEdge - margin);
footerLines.forEach((line, i) => ctx.fillText(line, margin, footerTextY + i * 10));
let y = headerH;
if (pageIndex === 0) {
// ── Title ───────────────────────────────────────────────────────────────
y += 26;
ctx.font = '700 22px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText('SUBCONTRACTOR INVOICE', margin, y);
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#5a5a5a';
drawRightText(ctx, poNumber, rightEdge, y - 4);
y += 26;
// ── Two-column info ──────────────────────────────────────────────────────
ctx.font = '700 9px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#787878';
ctx.fillText('SUBCONTRACTOR', margin, y);
ctx.fillText('DETAILS', 330, y);
y += 16;
ctx.font = '700 12px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText(subcontractorName, margin, y);
ctx.fillText(projectName, 330, y);
y += 14;
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#5f5f5f';
if (subcontractorEmail) ctx.fillText(subcontractorEmail, margin, y);
ctx.fillText(companyName, 330, y);
y += 30;
// ── Meta table ───────────────────────────────────────────────────────────
metaRows.forEach(([label, value], idx) => {
const rowY = y + idx * rowH;
if (idx % 2 === 0) {
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(margin, rowY - 16, tableW, rowH);
}
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#696969';
ctx.fillText(label, margin + 12, rowY);
ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#232323';
mCtx.font = '700 10px Helvetica, Arial, sans-serif';
drawRightText(ctx, String(value), margin + tableW - 12, rowY);
});
y += metaRows.length * rowH + 22;
} else {
// ── Continuation label ───────────────────────────────────────────────────
y += 28;
ctx.font = '9px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#888888';
ctx.fillText(`${poNumber} (continued)`, margin, y);
y += 22;
}
// ── Line items ─────────────────────────────────────────────────────────────
if (pageItems.length > 0 || pageIndex === 0) {
ctx.font = '700 11px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText('Line Items', margin, y);
y += 16;
}
pageItems.forEach((item, idx) => {
if (idx % 2 === 0) {
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(margin, y - 14, tableW, rowH);
}
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#2d2d2d';
mCtx.font = '10px Helvetica, Arial, sans-serif';
const descLines = wrapText(mCtx, item.description || item.task?.title || 'Work Item', tableW - 120);
ctx.fillText(descLines[0], margin + 12, y);
ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
drawRightText(ctx, formatCurrency(item.amount), margin + tableW - 12, y);
y += rowH;
});
y += sortedItems.length * 28 + 22;
}
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 30, 30);
doc.text(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
// ── Last page: summary + notes ─────────────────────────────────────────────
if (pageIndex === pageCount - 1) {
y += 10;
if (po.description) {
ctx.font = '700 11px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
y += 16;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(70, 70, 70);
const scopeLines = doc.splitTextToSize(po.description || '', pageWidth - margin * 2);
doc.text(scopeLines, margin, y);
y += scopeLines.length * 13 + 18;
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#464646';
mCtx.font = '10px Helvetica, Arial, sans-serif';
const scopeLines = wrapText(mCtx, po.description, tableW);
scopeLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
y += 10;
}
if (po.notes) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(30, 30, 30);
doc.text('Notes', margin, y);
ctx.font = '700 11px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#1e1e1e';
ctx.fillText('Notes', margin, y);
y += 16;
doc.setFont('helvetica', 'normal');
doc.setTextColor(70, 70, 70);
const noteLines = doc.splitTextToSize(po.notes, pageWidth - margin * 2);
doc.text(noteLines, margin, y);
ctx.font = '10px Helvetica, Arial, sans-serif';
ctx.fillStyle = '#464646';
mCtx.font = '10px Helvetica, Arial, sans-serif';
const noteLines = wrapText(mCtx, po.notes, tableW);
noteLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
}
}
doc.setDrawColor(220, 220, 220);
doc.line(margin, 704, rightEdge, 704);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(120, 120, 120);
doc.text(
'This purchase order authorizes the subcontractor work described above. Payment is subject to Fourge Branding approval and completion of assigned work.',
margin,
724,
{ maxWidth: pageWidth - margin * 2 },
);
return canvas;
}
// ── Assemble PDF ────────────────────────────────────────────────────────────
const doc = new jsPDF({ unit: 'pt', format: 'letter', compress: true, putOnlyUsedFonts: true, precision: 2 });
for (let i = 0; i < pages.length; i++) {
if (i > 0) doc.addPage();
const canvas = renderPageCanvas(pages[i], i, pages.length);
const imgData = canvas.toDataURL('image/jpeg', 0.97);
doc.addImage(imgData, 'JPEG', 0, 0, pageWidth, pageHeight);
}
const filename = `${poNumber}.pdf`;
if (options.save === false || options.output === 'blob') return doc.output('blob');
+27
View File
@@ -0,0 +1,27 @@
export const CLIENT_APPROVED_OR_BETTER = new Set(['client_approved', 'invoiced', 'paid']);
export function isCompletedVersionEligible(task, versionNumber) {
const taskVersion = Number(task?.current_version || 0);
const version = Number(versionNumber || 0);
if (version < taskVersion) return true;
return version === taskVersion && CLIENT_APPROVED_OR_BETTER.has(task?.status);
}
export function isInitialVersionEligible(task) {
if (!task || task.invoiced) return false;
return isCompletedVersionEligible(task, 0);
}
export function getRevisionChargeQuantity(versionNumber, revisionType) {
// Fourge's own error revisions are never billed to the client.
if (revisionType === 'fourge_error') return 0;
const version = Number(versionNumber || 0);
return version >= 2 ? 1 : 0;
}
// Parses version number from a subcontractor invoice item description like
// "Project • Task R01". Legacy fallback only — new rows store version_number directly.
export function parseVersionFromItemDescription(description = '') {
const match = String(description).match(/[\-]\s*R(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
+31
View File
@@ -0,0 +1,31 @@
export const popupOverlayStyle = {
position: 'fixed',
inset: 0,
zIndex: 1200,
background: 'var(--overlay-scrim)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
};
export const popupSurfaceStyle = {
background: 'var(--popup-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '18px 21px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
boxShadow: 'var(--popup-shadow)',
};
export const popupMenuStyle = {
background: 'var(--popup-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
boxShadow: 'var(--popup-shadow)',
};
+48 -2
View File
@@ -1,9 +1,18 @@
import { supabase } from './supabase';
import { ensureProjectFolder } from './folderSync';
function normalizeProjectName(name = '') {
return String(name).trim().toLowerCase();
}
function serializeSubmissionSigns(normalizedSigns = []) {
return normalizedSigns.map((s, i) => ({
sign_number: i + 1,
sign_name: s.signName,
sign_family: s.signFamily || null,
}));
}
export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
const normalized = normalizeProjectName(projectName);
if (!companyId || !normalized) throw new Error('Project company and name are required.');
@@ -31,7 +40,14 @@ export async function findOrCreateProject(companyId, projectName, knownProjects
.select('id, name, company_id, status')
.single();
if (!insertError && newProject) return newProject;
if (!insertError && newProject) {
try {
await ensureProjectFolder({ projectId: newProject.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
return newProject;
}
if (insertError?.code === '23505') {
const { data: retryProjects, error: retryError } = await supabase
@@ -80,11 +96,28 @@ export async function createInitialSubmissionForRequest({
requestKey,
isHot,
serviceType,
signFamily,
signCount,
signs,
deadline,
description,
submittedBy,
submittedByName,
}) {
const normalizedSignCount = Number.isFinite(Number(signCount)) ? Number(signCount) : null;
const normalizedSigns = Array.isArray(signs)
? signs
.map((sign = {}) => ({
signName: String(sign.signName || '').trim(),
signFamily: String(sign.signFamily || '').trim(),
}))
.filter(sign => sign.signName || sign.signFamily)
: [];
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
throw new Error('Please fill in the sign information for each sign before submitting.');
}
const { data: submission, error } = await supabase
.from('submissions')
.insert({
@@ -94,6 +127,9 @@ export async function createInitialSubmissionForRequest({
type: 'initial',
is_hot: isHot,
service_type: serviceType,
sign_family: signFamily || null,
sign_count: normalizedSignCount || null,
signs: serializeSubmissionSigns(normalizedSigns),
deadline: deadline || null,
description,
submitted_by: submittedBy,
@@ -111,7 +147,17 @@ export async function createInitialSubmissionForRequest({
.eq('request_key', requestKey)
.single();
if (existingError) throw existingError;
if (existingSubmission) return { submission: existingSubmission, duplicate: true };
if (existingSubmission) {
const existingSigns = Array.isArray(existingSubmission.signs) ? existingSubmission.signs : [];
if (normalizedSigns.length > 0 && existingSigns.length === 0) {
const { error: repairError } = await supabase
.from('submissions')
.update({ signs: serializeSubmissionSigns(normalizedSigns) })
.eq('id', existingSubmission.id);
if (repairError) throw repairError;
}
return { submission: existingSubmission, duplicate: true };
}
}
throw error || new Error('Failed to create submission.');
-19
View File
@@ -1,19 +0,0 @@
import { supabase } from './supabase';
export async function syncSeafileFolders() {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return { skipped: true };
const response = await fetch('/api/seafile?action=sync-folders', {
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || 'Failed to sync Seafile folders.');
return data;
}
+16
View File
@@ -0,0 +1,16 @@
export function mergeSubmissionDisplayNames(submissions = [], profiles = []) {
const nameById = new Map(
(profiles || [])
.filter((profile) => profile?.id)
.map((profile) => [profile.id, profile.name || ''])
);
return (submissions || []).map((submission) => ({
...submission,
display_submitted_by_name: nameById.get(submission?.submitted_by) || submission?.submitted_by_name || '',
}));
}
export function getSubmissionDisplayName(submission) {
return submission?.display_submitted_by_name || submission?.submitted_by_name || '';
}
Executable → Regular
View File
+2 -3
View File
@@ -1,4 +1,5 @@
import jsPDF from 'jspdf';
import { formatDateAtNoon } from './dates';
const W = 792;
const H = 612;
@@ -8,9 +9,7 @@ const DARK = [18, 18, 18];
const HEADER_H = 64;
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(`${dateStr}T12:00:00`);
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
return formatDateAtNoon(dateStr, '-');
}
async function blobToDataUrl(blob) {
+42
View File
@@ -0,0 +1,42 @@
import { sendEmail } from './email';
export async function sendTaskStatusUpdate({
taskId,
taskTitle,
projectId,
projectName = '',
companyId,
companyName = '',
assignedProfileId,
subject,
headline,
statusLabel,
message,
buttonLabel = 'View Task',
includeTeam = false,
includeAssigned = false,
includeClient = false,
includeProjectMembers = false,
recipientStrategy = '',
}) {
if (!taskId || !taskTitle || !statusLabel || !message) return;
return sendEmail('task_status_update', [], {
taskId,
taskTitle,
projectId,
projectName,
companyId,
companyName,
assignedProfileId,
subject,
headline,
statusLabel,
message,
buttonLabel,
includeTeam,
includeAssigned,
includeClient,
includeProjectMembers,
recipientStrategy,
});
}
+70
View File
@@ -0,0 +1,70 @@
import { popupSurfaceStyle } from './popupStyles';
export const TASK_TABLE_TH_STYLE = {
fontSize: 10,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 0.6,
color: 'var(--text-muted)',
textAlign: 'left',
padding: '0 0 12px 5px',
border: 'none',
background: 'transparent',
verticalAlign: 'top',
};
export const TASK_TABLE_TD_BASE = {
padding: '5px',
border: 'none',
background: 'transparent',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
export const TASK_MODAL_TITLE_STYLE = {
fontSize: 11,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 0.8,
color: 'var(--text-secondary)',
};
export const TASK_MODAL_LABEL_STYLE = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 6,
};
export const TASK_MODAL_INPUT_STYLE = {
fontSize: 13,
padding: '0 5px',
textAlign: 'left',
lineHeight: 1,
};
export const TASK_MODAL_BUTTON_STYLE = {
lineHeight: 1,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
export const TASK_MODAL_SHELL_STYLE = {
...popupSurfaceStyle,
background: 'var(--card-bg)',
width: 'min(720px, 96vw)',
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
};
export const PROJECT_MODAL_SHELL_STYLE = {
...popupSurfaceStyle,
background: 'var(--card-bg)',
width: 'min(434px, 100%)',
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
};
+103
View File
@@ -0,0 +1,103 @@
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from './taskDeadlines';
import { serviceTypes } from '../data/mockData';
export const REJECT_NOTE_PREFIX = '[rejected-note]';
export const REVIEW_SHADOW_PREFIX = '[review-shadow]';
const SERVICE_TYPE_SET = new Set(serviceTypes);
function isRecognizedServiceType(value) {
const normalized = String(value || '').trim();
return normalized ? SERVICE_TYPE_SET.has(normalized) : false;
}
export function parseRejectedNote(body = '') {
if (!String(body).startsWith(REJECT_NOTE_PREFIX)) return null;
const rest = String(body).slice(REJECT_NOTE_PREFIX.length);
const [versionRaw, ...noteParts] = rest.split('::');
const versionNumber = Number(versionRaw);
if (!Number.isFinite(versionNumber)) return null;
return { versionNumber, body: noteParts.join('::').trim() };
}
export function encodeRejectedNote(versionNumber, body) {
return `${REJECT_NOTE_PREFIX}${versionNumber}::${body}`;
}
export function isReviewShadowSubmission(submission) {
return String(submission?.description || '').startsWith(REVIEW_SHADOW_PREFIX);
}
export function isReviewShadowDescription(description = '') {
return String(description).startsWith(REVIEW_SHADOW_PREFIX);
}
export function getVisibleTaskSubmissions(submissions = []) {
return (submissions || []).filter(submission => !isReviewShadowSubmission(submission));
}
// Picks the representative service_type for a task's initial work for pricing.
// Skips review-shadow rows and blank service_types (those break price lookups),
// preferring a real initial submission, then any submission with a service_type.
export function pickInitialServiceType(submissions = [], fallback = '') {
const visible = getVisibleTaskSubmissions(submissions);
const initialWithSvc = visible.find(s => s?.type === 'initial' && s?.service_type);
if (initialWithSvc?.service_type) return initialWithSvc.service_type;
const anyInitial = visible.find(s => s?.type === 'initial');
if (anyInitial?.service_type) return anyInitial.service_type;
const anyWithSvc = visible.find(s => s?.service_type);
return anyWithSvc?.service_type || fallback;
}
export function getLatestVisibleSubmissionForVersion(taskId, submissions = [], versionNumber = 0) {
return getVisibleTaskSubmissions(submissions)
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
.sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime())[0] || null;
}
export function getLatestDeliveryForVersion(taskId, submissions = [], deliveries = [], versionNumber = 0) {
const versionSubmissionIds = new Set(
(submissions || [])
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
.map(submission => submission.id)
);
if (versionSubmissionIds.size === 0) return null;
return (deliveries || [])
.filter(delivery => versionSubmissionIds.has(delivery?.submission_id))
.sort((a, b) => new Date(b.sent_at || 0).getTime() - new Date(a.sent_at || 0).getTime())[0] || null;
}
export function getTaskDerivedState(task, submissions = [], deliveries = []) {
const taskSubs = (submissions || []).filter(submission => submission?.task_id === task?.id);
const visibleTaskSubs = getVisibleTaskSubmissions(taskSubs);
const currentVersion = getCurrentVersionForTask(task, taskSubs);
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
const latestVisibleSubmission = getLatestVisibleSubmissionForVersion(task?.id, taskSubs, currentVersion);
const latestDelivery = getLatestDeliveryForVersion(task?.id, taskSubs, deliveries, currentVersion);
const initialSubmission = visibleTaskSubs.find(submission => submission?.type === 'initial') || null;
const canonicalServiceType =
(isRecognizedServiceType(initialSubmission?.service_type) && initialSubmission?.service_type) ||
visibleTaskSubs.find(submission => isRecognizedServiceType(submission?.service_type))?.service_type ||
(isRecognizedServiceType(deadlineSource?.service_type) && deadlineSource?.service_type) ||
'—';
const deadline = deadlineSource?.deadline || latestVisibleSubmission?.deadline || null;
const isHot = Boolean(deadlineSource?.is_hot || latestVisibleSubmission?.is_hot);
const latestActivityAt = Math.max(
latestVisibleSubmission?.submitted_at ? new Date(latestVisibleSubmission.submitted_at).getTime() : 0,
latestDelivery?.sent_at ? new Date(latestDelivery.sent_at).getTime() : 0,
task?.submitted_at ? new Date(task.submitted_at).getTime() : 0
);
return {
taskSubs,
visibleTaskSubs,
currentVersion,
deadlineSource,
latestVisibleSubmission,
latestDelivery,
initialSubmission,
serviceType: canonicalServiceType,
deadline,
isHot,
latestActivityAt,
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
export async function withTimeout(promise, ms = 12000, label = 'Request') {
export async function withTimeout(promise, ms = 25000, label = 'Request') {
let timerId;
try {
return await Promise.race([
+51
View File
@@ -0,0 +1,51 @@
import { supabase } from './supabase';
export function getCurrentUserCompanyIds(currentUser) {
return [
...(currentUser?.companies?.map((company) => company?.company?.id || company?.id) || []),
...(currentUser?.company_id ? [currentUser.company_id] : []),
...(currentUser?.company?.id ? [currentUser.company.id] : []),
].filter(Boolean).filter((value, index, list) => list.indexOf(value) === index);
}
export async function resolveScopedWorkIds(currentUser, { isClient = false, isExternal = false } = {}) {
let scopedCompanyIds = null;
let scopedProjectIds = null;
let scopedTaskIds = null;
if (isClient) {
scopedCompanyIds = getCurrentUserCompanyIds(currentUser);
if (scopedCompanyIds.length > 0) {
const { data: projectRows } = await supabase
.from('projects')
.select('id, tasks(id)')
.in('company_id', scopedCompanyIds);
scopedProjectIds = (projectRows || []).map((project) => project.id).filter(Boolean);
scopedTaskIds = (projectRows || [])
.flatMap((project) => (project.tasks || []).map((task) => task.id))
.filter(Boolean);
} else {
scopedProjectIds = [];
scopedTaskIds = [];
}
}
if (isExternal) {
const { data: memberRows } = await supabase
.from('project_members')
.select('project_id')
.eq('profile_id', currentUser?.id);
scopedProjectIds = (memberRows || []).map((row) => row.project_id).filter(Boolean);
if (scopedProjectIds.length > 0) {
const { data: taskRows } = await supabase
.from('tasks')
.select('id')
.in('project_id', scopedProjectIds);
scopedTaskIds = (taskRows || []).map((row) => row.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
return { scopedCompanyIds, scopedProjectIds, scopedTaskIds };
}
Executable → Regular
View File
@@ -1,9 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { generateBrandBookEditorPDF } from '../../lib/brandBookEditor';
import { cleanupBrandBookStorage } from '../../lib/deleteHelpers';
import Layout from '../components/Layout';
import LoadingButton from '../components/LoadingButton';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase';
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
const BUCKET = 'brand-books';
@@ -156,6 +159,8 @@ export default function BrandBook() {
const projectLogoRef = useRef();
const clientLogoRef = useRef();
const [filterCompany, setFilterCompany] = useState('');
const [selectedBookIds, setSelectedBookIds] = useState([]);
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
@@ -447,7 +452,7 @@ export default function BrandBook() {
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
setNotification({ type: 'error', msg: 'Please select a company.' });
return;
}
setSaving(true);
@@ -609,7 +614,7 @@ export default function BrandBook() {
const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
setNotification({ type: 'error', msg: 'Please select a company.' });
return;
}
setGenerating(true);
@@ -677,7 +682,7 @@ export default function BrandBook() {
const handleClientLogoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a client first.' }); return; }
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a company first.' }); return; }
setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase();
const path = `${bookInfo.clientId}/logo.${ext}`;
@@ -777,53 +782,43 @@ export default function BrandBook() {
</div>
{companyNames.length > 0 && (
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
<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>
{companyNames.map(name => (
<button
key={name}
className={`btn btn-sm ${filterCompany === name ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setFilterCompany(current => current === name ? '' : name)}
>
{name}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
</div>
)}
{loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
) : filteredBooks.length === 0 ? (
<div className="empty-state">
<h3>{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}</h3>
<p>{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}</p>
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
<div className="card" style={{ minHeight: 160, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
{savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
</div>
{savedBooks.length === 0 && <button className="btn btn-primary" onClick={handleNew}>+ New Brand Book</button>}
</div>
) : (
<div className="table-wrapper">
<table>
<table className="table-sticky-head">
<thead>
<tr>
<th>Name</th>
<th>Revision</th>
<th>Sign Count</th>
<th>Client</th>
<th>Updated</th>
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Company</SortTh>
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
<th></th>
</tr>
</thead>
<tbody>
{filteredBooks.map(book => {
{bbSort(filteredBooks, (book, key) => {
if (key === 'sign_count') return Array.isArray(book.signs) ? book.signs.length : 0;
if (key === 'client') return book.client_name || clients.find(c => c.id === book.client_id)?.name || '';
if (key === 'updated_at') return book.updated_at ? new Date(book.updated_at).getTime() : 0;
return book[key] || '';
}).map(book => {
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name || 'No client';
const updated = book.updated_at
@@ -832,18 +827,18 @@ export default function BrandBook() {
return (
<tr key={book.id} onClick={() => handleLoad(book)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{book.project_name || 'Brand Book'}</td>
<td style={{ fontWeight: 400 }}>{book.project_name || 'Brand Book'}</td>
<td>{`R${String(book.revision || '01').padStart(2, '0')}`}</td>
<td>{signCount}</td>
<td>{clientName}</td>
<td>{updated}</td>
<td onClick={e => e.stopPropagation()}>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
className="btn-icon btn-icon-danger"
onClick={() => handleDelete(book)}
title="Delete"
>
Delete
</button>
</td>
</tr>
@@ -884,7 +879,7 @@ export default function BrandBook() {
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div>
{notification && (
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}>
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, var(--danger-strong))' : 'var(--success, var(--success-strong))' }}>
{notification.msg}
</span>
)}
@@ -898,7 +893,7 @@ export default function BrandBook() {
<div className="card-title">Brand Book Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client *</label>
<label>Company *</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>)}
@@ -918,7 +913,7 @@ export default function BrandBook() {
<div className="form-group" style={{ maxWidth: 180 }}>
<label>Revision</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>R</span>
<span style={{ fontWeight: 400, color: 'var(--text-primary)' }}>R</span>
<input
type="text"
inputMode="numeric"
@@ -943,7 +938,7 @@ export default function BrandBook() {
<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 }}>
{projectLogoPreview && (
<img src={projectLogoPreview} alt="Project logo" style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }} />
<img src={projectLogoPreview} alt="Project logo" style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => projectLogoRef.current?.click()}>
@@ -1008,7 +1003,7 @@ export default function BrandBook() {
right: 0,
zIndex: 20,
border: '1px solid var(--border)',
borderRadius: 8,
borderRadius: 4,
background: 'var(--card-bg)',
boxShadow: '0 16px 36px rgba(0,0,0,0.28)',
overflow: 'hidden',
@@ -1053,7 +1048,7 @@ export default function BrandBook() {
{/* 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: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Company Info</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company reused across all brand books.</div>
</div>
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
@@ -1062,10 +1057,10 @@ export default function BrandBook() {
</div>
<div className="form-group">
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<label>Company 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 }}>
{bookInfo.clientLogoUrl && (
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }} />
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => clientLogoRef.current?.click()} disabled={uploadingClientLogo}>
@@ -1095,7 +1090,7 @@ export default function BrandBook() {
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 20px' }} />
{/* Approval */}
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
<div className="form-group" style={{ maxWidth: 280 }}>
<label>Approved Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={bookInfo.approvedDate} onChange={set('approvedDate')} />
@@ -1187,7 +1182,7 @@ export default function BrandBook() {
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div>
{notification && (
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}>
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, var(--danger-strong))' : 'var(--success, var(--success-strong))' }}>
{notification.msg}
</span>
)}
@@ -1219,23 +1214,23 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t
const hasPhoto = sign._photoPreview || sign._existingPhotoPreview || sign._recommendationPhotoPreview || sign._signDetailPhotoPreview;
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
<span style={{ fontSize: 10, fontWeight: 400, 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>
<span style={{ fontSize: 13, fontWeight: 400, 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={onRemove}
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}></span>
style={{ fontSize: 13, color: 'var(--danger, var(--danger-strong))', padding: '2px 6px', cursor: 'pointer' }}></span>
)}
</div>
</div>
@@ -1340,8 +1335,8 @@ function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter,
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))',
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
@@ -1394,7 +1389,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
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' }} />
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 4, 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>
@@ -1411,8 +1406,8 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
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))',
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : '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',
}}
@@ -1442,8 +1437,8 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
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))',
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : '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',
@@ -1463,14 +1458,14 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
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>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'color-mix(in srgb, var(--accent) 80%, transparent)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div>
)}
<button
type="button"
onClick={() => onRemove(i)}
style={{
position: 'absolute', top: -6, right: -6,
background: '#dc2626', border: 'none', borderRadius: '50%',
background: 'var(--danger-strong)', border: 'none', borderRadius: '50%',
width: 18, height: 18, fontSize: 10, color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
}}
@@ -1514,8 +1509,8 @@ function CombinedMockupPhotoField({
const tileStyle = {
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
@@ -1653,8 +1648,8 @@ function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDra
onClick={() => setShowEditor(true)}
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))',
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
@@ -1736,8 +1731,8 @@ function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnt
onClick={() => preview && setShowEditor(true)}
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))',
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: preview ? 'pointer' : 'default',
display: 'flex',
@@ -2357,23 +2352,19 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999, padding: 20,
}}
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }}>
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Add line dimensions or drag a box to auto-place width and height callouts.</div>
</div>
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}></button>
</div>
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
{[
['line', 'Line'],
['box', 'Box'],
@@ -2429,7 +2420,7 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
)}
</span>
</div>
<div style={{ overflow: 'auto', flex: 1, background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}>
<div style={{ overflow: 'auto', flex: 1, background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}>
{!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>}
<canvas
ref={canvasRef}
@@ -2842,7 +2833,7 @@ function PhotoEditorModal({
ctx.restore();
if (showGuides && item.id === selectedArtworkId) {
const rotateHandle = getRotateHandle(item);
ctx.strokeStyle = '#f5a523';
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
@@ -2860,7 +2851,7 @@ function PhotoEditorModal({
ctx.stroke();
getArtworkHandles(item).forEach(({ x, y }) => {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#f5a523';
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
@@ -2868,13 +2859,13 @@ function PhotoEditorModal({
ctx.stroke();
});
ctx.fillStyle = '#1a1a1a';
ctx.strokeStyle = '#f5a523';
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#f5a523';
ctx.fillStyle = 'var(--accent)';
ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
}
@@ -2904,14 +2895,14 @@ function PhotoEditorModal({
}
if (showGuides && calibrationPoints.length > 0) {
ctx.fillStyle = '#22c55e';
ctx.fillStyle = 'var(--success)';
calibrationPoints.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill();
});
if (calibrationPoints.length === 2) {
ctx.strokeStyle = '#22c55e';
ctx.strokeStyle = 'var(--success)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
@@ -3444,22 +3435,14 @@ function PhotoEditorModal({
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999, padding: 20,
}}
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{
background: 'var(--card-bg)', borderRadius: 12, display: 'flex', flexDirection: 'column',
maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden',
boxShadow: '0 24px 64px rgba(0,0,0,0.55)',
}}>
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
{/* Header */}
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{title}</div>
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{subtitle}</div>
</div>
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}></button>
@@ -3485,7 +3468,7 @@ function PhotoEditorModal({
{/* Toolbar */}
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
{[
['select', 'Select'],
['dimension', 'Line Dim'],
@@ -3604,10 +3587,10 @@ function PhotoEditorModal({
<div style={{ display: 'grid', gridTemplateColumns: '220px minmax(0, 1fr)', minHeight: 0, flex: 1 }}>
<div style={{ borderRight: '1px solid var(--border)', background: 'var(--card-bg)', padding: 12, overflowY: 'auto' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>Layers</div>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>Layers</div>
{dimensions.length > 0 && (
<>
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Dimensions</div>
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Dimensions</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{dimensions.map((item, index) => (
<button
@@ -3621,9 +3604,9 @@ function PhotoEditorModal({
}}
style={{
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
background: item.id === activeDimensionId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
background: item.id === activeDimensionId ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--card-bg-2)',
color: 'var(--text-primary)',
borderRadius: 6,
borderRadius: 4,
padding: '8px 10px',
cursor: 'pointer',
textAlign: 'left',
@@ -3639,7 +3622,7 @@ function PhotoEditorModal({
</div>
</>
)}
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Artwork</div>
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Artwork</div>
{artworks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
Import or drop artwork to create layers.
@@ -3665,9 +3648,9 @@ function PhotoEditorModal({
}}
style={{
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
background: item.id === selectedArtworkId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
background: item.id === selectedArtworkId ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--card-bg-2)',
color: 'var(--text-primary)',
borderRadius: 6,
borderRadius: 4,
padding: '8px 10px',
cursor: 'grab',
textAlign: 'left',
@@ -3692,7 +3675,7 @@ function PhotoEditorModal({
<div
onDragOver={e => e.preventDefault()}
onDrop={handleArtworkDrop}
style={{ overflow: 'auto', background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
style={{ overflow: 'auto', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
>
{!loaded && (
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>
+562
View File
@@ -0,0 +1,562 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { deleteCompanyData } from '../lib/deleteHelpers';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { ensureCompanyFolder, ensureSubcontractorFolder, renameSubcontractorFolder } from '../lib/folderSync';
// Team view
function TeamCompanies() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'companies';
const profileId = searchParams.get('profile');
const profileRole = searchParams.get('role');
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);
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
const [saving, setSaving] = useState(false);
const [userError, setUserError] = useState('');
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
const [filterCompany, setFilterCompany] = useState('');
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('name');
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(); }, []);
const handleCreate = async (e) => {
e.preventDefault();
if (!newForm.name.trim()) return;
setSaving(true);
const { data } = await supabase.from('companies').insert({
name: newForm.name.trim(),
phone: newForm.phone.trim(),
address: newForm.address.trim(),
}).select().single();
setSaving(false);
if (data) {
try {
await ensureCompanyFolder({ companyId: data.id });
} catch (folderError) {
console.error('Company folder sync failed:', folderError);
}
setShowNew(false);
setNewForm({ name: '', phone: '', address: '' });
navigate(`/company/${data.id}`);
}
};
const handleDeleteCompany = async (company) => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all clients, jobs, files, and data for this company. This cannot be undone.`)) return;
await deleteCompanyData(company.id);
setCompanies(prev => prev.filter(c => c.id !== company.id));
load();
};
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
const existingUser = profiles.find(u => u.id === userId);
const oldName = existingUser?.name || '';
const newName = editUserVal.trim();
await supabase.from('profiles').update({ name: newName }).eq('id', userId);
if (existingUser?.role === 'external') {
try {
await renameSubcontractorFolder({ profileId: userId, oldName, newName });
} catch (folderError) {
console.error('Subcontractor folder rename failed:', folderError);
}
}
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: newName } : u));
setEditingUserId(null);
};
const handleDeleteUser = async (user) => {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const 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);
};
const handleCreateUser = async (e) => {
e.preventDefault();
setUserError('');
setSaving(true);
const { data, error } = await supabase.functions.invoke('create-user', {
body: {
name: userForm.name.trim(),
email: userForm.email.trim(),
password: userForm.password,
role: userForm.role,
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
},
});
setSaving(false);
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { setUserError(errMsg); return; }
if (userForm.role === 'external') {
try {
const { data: newProfile } = await supabase
.from('profiles')
.select('id')
.eq('email', userForm.email.trim())
.eq('role', 'external')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (newProfile?.id) {
await ensureSubcontractorFolder({ profileId: newProfile.id });
}
} catch (folderError) {
console.error('Subcontractor folder sync failed:', folderError);
}
}
if (userForm.role === 'team' || userForm.role === 'external') {
}
setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load();
};
if (loading) return <Layout><PageLoader /></Layout>;
const getProfileCompanyIds = (profile) => {
const ids = new Set(
companyMemberships
.filter(m => m.profile_id === profile.id && profile.role === 'client')
.map(m => m.company_id)
);
if (profile.role === 'client' && profile.company_id) ids.add(profile.company_id);
return [...ids];
};
const clientProfiles = profiles.filter(p => p.role === 'client');
const subcontractors = profiles.filter(p => p.role === 'external');
const unassigned = clientProfiles.filter(p => getProfileCompanyIds(p).length === 0);
const editPen = <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div>
<div className="page-title">{tab === 'users' ? 'Users' : 'Companies'}</div>
<div className="page-subtitle">
{tab === 'users' ? (
<>
{clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
<span style={{ marginLeft: 10 }}>· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}</span>
{unassigned.length > 0 && (
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 400 }}>
· {unassigned.length} unassigned
</span>
)}
</>
) : (
<>{companies.length} {companies.length !== 1 ? 'companies' : 'company'}</>
)}
</div>
</div>
{tab === 'companies' && (
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
{showNew ? 'Cancel' : '+ New Company'}
</button>
)}
{tab === 'users' && (
<button className="btn btn-primary btn-sm" onClick={() => {
setShowNewUser(v => !v);
setShowNew(false);
setUserForm(f => ({ ...f, role: userSubTab === 'client' ? 'client' : 'external', company_id: '' }));
}}>
{showNewUser ? 'Cancel' : userSubTab === 'client' ? '+ New User' : '+ New Subcontractor'}
</button>
)}
</div>
{/* Clients (companies) — only on companies tab */}
{tab === 'companies' && <>
{showNew && (
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
<div className="card-title">New Client</div>
<form onSubmit={handleCreate}>
<div className="form-group">
<label>Company Name *</label>
<input type="text" placeholder="Acme Corp" value={newForm.name}
onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
</div>
<div className="grid-2">
<div className="form-group">
<label>Phone</label>
<input type="text" placeholder="+1 (555) 000-0000" value={newForm.phone}
onChange={e => setNewForm(f => ({ ...f, phone: e.target.value }))} />
</div>
<div className="form-group">
<label>Address</label>
<input type="text" placeholder="123 Main St, City, State" value={newForm.address}
onChange={e => setNewForm(f => ({ ...f, address: e.target.value }))} />
</div>
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
{saving ? 'Creating...' : 'Create Company'}
</button>
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
</div>
</form>
</div>
)}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? (
<div className="card-empty-center">No companies</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
<SortTh col="clients" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Users</SortTh>
<th>Primary Contact</th>
<SortTh col="phone" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Phone</SortTh>
<SortTh col="address" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Address</SortTh>
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
</tr>
</thead>
<tbody>
{coSort(companies, (company, key) => {
if (key === 'clients') return clientProfiles.filter(p => getProfileCompanyIds(p).includes(company.id)).length;
return company[key] || '';
}).map(company => {
const companyProfiles = clientProfiles.filter(p => getProfileCompanyIds(p).includes(company.id));
return (
<tr key={company.id} onClick={() => navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>{company.name}</td>
<td>{companyProfiles.length}</td>
<td>{companyProfiles[0]?.name || '—'}</td>
<td>{company.phone || '—'}</td>
<td>{company.address || '—'}</td>
<td onClick={e => e.stopPropagation()}>
<button className="btn-icon btn-icon-danger" title="Delete Company"
onClick={() => handleDeleteCompany(company)}></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</>}
{/* Users — only on users tab */}
{tab === 'users' && <>
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexShrink: 0 }}>
<select className="filter-select" value={userSubTab} onChange={e => { setUserSubTab(e.target.value); setShowNewUser(false); }}>
<option value="client">Users</option>
<option value="external">Subcontractors</option>
</select>
</div>
{showNewUser && userForm.role === 'client' && (
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
<div className="card-title">New User</div>
<form onSubmit={handleCreateUser}>
<div className="grid-2">
<div className="form-group">
<label>Full Name *</label>
<input type="text" placeholder="Jane Smith" value={userForm.name}
onChange={e => setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
</div>
<div className="form-group">
<label>Email *</label>
<input type="email" placeholder="jane@acme.com" value={userForm.email}
onChange={e => setUserForm(f => ({ ...f, email: e.target.value }))} required />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Password *</label>
<input type="password" placeholder="Temporary password" value={userForm.password}
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
</div>
<div className="form-group">
<label>Assign to Company</label>
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
<option value="">No company yet</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Creating...' : 'Create User'}</button>
<button type="button" className="btn btn-outline" onClick={() => setShowNewUser(false)}>Cancel</button>
</div>
</form>
</div>
)}
{showNewUser && userForm.role === 'external' && (
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
<div className="card-title">New Subcontractor</div>
<form onSubmit={handleCreateUser}>
<div className="grid-2">
<div className="form-group">
<label>Full Name *</label>
<input type="text" placeholder="Jane Smith" value={userForm.name}
onChange={e => setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
</div>
<div className="form-group">
<label>Email *</label>
<input type="email" placeholder="jane@acme.com" value={userForm.email}
onChange={e => setUserForm(f => ({ ...f, email: e.target.value }))} required />
</div>
</div>
<div className="form-group" style={{ maxWidth: 260 }}>
<label>Password *</label>
<input type="password" placeholder="Temporary password" value={userForm.password}
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
</div>
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Creating...' : 'Create Subcontractor'}</button>
<button type="button" className="btn btn-outline" onClick={() => setShowNewUser(false)}>Cancel</button>
</div>
</form>
</div>
)}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{userSubTab === 'client' && <>
{unassigned.length > 0 && (
<div style={{ marginBottom: 12, padding: 14, background: 'color-mix(in srgb, var(--danger-strong) 6%, transparent)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}>
<div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{unassigned.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-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: 400, 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' }}>
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>{editPen}</button>
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
{deletingUserId === user.id ? '...' : '✕'}
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{clientProfiles.length === 0 ? (
<div className="card-empty-center">No users</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
<SortTh col="email" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Email</SortTh>
<SortTh col="company" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Company</SortTh>
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
</tr>
</thead>
<tbody>
{clSort(clientProfiles, (user, key) => {
if (key === 'company') return getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean).join(', ');
return user[key] || '';
}).map(user => {
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
return (
<tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
<td style={{ fontWeight: 400 }}>
{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>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap' }}>
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>{editPen}</button>
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
{deletingUserId === user.id ? '...' : '✕'}
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>}
{userSubTab === 'external' && <>
{subcontractors.length === 0 ? (
<div className="card-empty-center">No subcontractors</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Email</SortTh>
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
</tr>
</thead>
<tbody>
{subSort(subcontractors, (u, key) => u[key] || '').map(user => (
<tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
<td style={{ fontWeight: 400 }}>
{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>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap' }}>
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>{editPen}</button>
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
{deletingUserId === user.id ? '...' : '✕'}
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>}
</div>
</>}
</Layout>
);
}
// Client view (2+ companies same list UI, filtered)
function ClientCompanyList() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const companies = currentUser?.companies || [];
const { sortKey, sortDir, toggle, sort } = useSortable('name');
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Companies</div>
<div className="page-subtitle">{companies.length} {companies.length !== 1 ? 'companies' : 'company'}</div>
</div>
</div>
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? (
<div className="card-empty-center">No companies</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
<SortTh col="phone" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Phone</SortTh>
<SortTh col="address" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Address</SortTh>
</tr>
</thead>
<tbody>
{sort(companies, (c, key) => c[key] || '').map(company => (
<tr key={company.id} onClick={() => navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>{company.name}</td>
<td>{company.phone || '—'}</td>
<td>{company.address || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Layout>
);
}
// Entry point
export default function CompaniesPage() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const companies = currentUser?.companies || [];
if (currentUser?.role === 'team') return <TeamCompanies />;
// Client: 1 company redirect straight to profile
if (companies.length === 1) {
navigate(`/company/${companies[0].id}`, { replace: true });
return null;
}
// Client: 2+ companies filtered list
return <ClientCompanyList />;
}
@@ -1,15 +1,20 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../../lib/deleteHelpers';
import { syncSeafileFolders } from '../../lib/seafileFolders';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
import { logActivity } from '../lib/activityLog';
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
export default function CompanyDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const isTeam = currentUser?.role === 'team';
const [company, setCompany] = useState(null);
const [projects, setProjects] = useState([]);
@@ -17,6 +22,8 @@ export default function CompanyDetail() {
const [users, setUsers] = useState([]);
const [availableUsers, setAvailableUsers] = useState([]);
const [prices, setPrices] = useState([]);
const [signFamilies, setSignFamilies] = useState([]);
const [savingSignFamilyPrice, setSavingSignFamilyPrice] = useState(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('users');
const [savingPrice, setSavingPrice] = useState(null);
@@ -31,6 +38,10 @@ export default function CompanyDetail() {
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []);
async function load() {
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', id).single(),
@@ -67,8 +78,15 @@ export default function CompanyDetail() {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
setCompany(c => ({ ...c, name: nameVal.trim() }));
const oldName = company.name;
const newName = nameVal.trim();
await supabase.from('companies').update({ name: newName }).eq('id', id);
try {
await renameCompanyFolder({ companyId: id, oldName, newName });
} catch (folderError) {
console.error('Company folder rename failed:', folderError);
}
setCompany(c => ({ ...c, name: newName }));
setEditingName(false);
setSavingName(false);
};
@@ -110,7 +128,6 @@ export default function CompanyDetail() {
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);
};
@@ -139,14 +156,22 @@ export default function CompanyDetail() {
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 deleteCompanyData(id);
navigate('/companies');
navigate('/company');
};
const handleDeleteProject = async (project) => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files in this project will be permanently deleted.`)) return;
const projectTaskIds = tasks.filter(t => t.project_id === project.id).map(t => t.id);
await cleanupTaskStorage(projectTaskIds);
await supabase.from('projects').delete().eq('id', project.id);
if (!window.confirm(`Delete project "${project.name}"? All jobs will be removed and the project folder will be moved to Archive.`)) return;
try {
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-project?id=${project.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
} catch (err) {
alert(`Failed to delete project: ${err.message}`);
return;
}
setProjects(prev => prev.filter(p => p.id !== project.id));
setTasks(prev => prev.filter(t => t.project_id !== project.id));
};
@@ -161,6 +186,12 @@ export default function CompanyDetail() {
status: 'active',
}).select().single();
if (data) {
try {
await ensureProjectFolder({ projectId: data.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
@@ -198,7 +229,37 @@ export default function CompanyDetail() {
setSavingPrice(null);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const getSignFamilyPrice = (signFamily, priceType) =>
prices.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type)?.price ?? '';
const handleSignFamilyPriceChange = (signFamily, priceType, value) => {
setPrices(prev => {
const existing = prev.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type);
if (existing) return prev.map(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type ? { ...p, price: value } : p);
return [...prev, { sign_family: signFamily, price_type: priceType, price: value, company_id: id }];
});
};
const handleSignFamilyPriceSave = async (signFamily) => {
setSavingSignFamilyPrice(signFamily);
for (const priceType of ['new', 'revision']) {
const priceVal = getSignFamilyPrice(signFamily, priceType);
const existing = prices.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type && p.id);
if (existing) {
const { error } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
if (error) { setSavingSignFamilyPrice(null); alert('Failed to save price. Please try again.'); return; }
} else if (priceVal !== '') {
const { data, error } = await supabase.from('company_prices').insert({
company_id: id, sign_family: signFamily, price_type: priceType, price: Number(priceVal),
}).select().single();
if (error) { setSavingSignFamilyPrice(null); alert('Failed to save price. Please try again.'); return; }
if (data) setPrices(prev => [...prev.filter(p => !(p.sign_family === signFamily && p.price_type === priceType && !p.service_type && !p.id)), data]);
}
}
setSavingSignFamilyPrice(null);
};
if (loading) return <Layout><PageLoader /></Layout>;
if (!company) return <Layout><p>Company not found.</p></Layout>;
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
@@ -206,11 +267,11 @@ export default function CompanyDetail() {
return (
<Layout>
<button className="back-link" onClick={() => navigate('/companies')}> Back to Companies</button>
<button className="back-link" onClick={() => navigate('/company')}> Back to Companies</button>
<div className="page-header">
<div>
{editingName ? (
{isTeam && editingName ? (
<form onSubmit={handleCompanyNameSave} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="text"
@@ -218,7 +279,7 @@ export default function CompanyDetail() {
onChange={e => setNameVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 260 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
@@ -226,21 +287,22 @@ export default function CompanyDetail() {
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{company.name}</div>
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(company.name); setEditingName(true); }}>Edit</button>
{isTeam && <button className="btn-icon" title="Edit" onClick={() => { setNameVal(company.name); setEditingName(true); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>}
</div>
)}
<div className="page-subtitle">
{users[0]?.name && <>{users[0].name}</>}
{users[0]?.name && (company.phone || company.address) && ' · '}
{company.phone && <>{company.phone}</>}
{company.phone && company.address && ' · '}
{company.address && <>{company.address}</>}
{!company.phone && !company.address && 'No contact info'}
{!users[0]?.name && !company.phone && !company.address && 'No contact info'}
</div>
</div>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
{isTeam && <button
className="btn-icon btn-icon-danger"
onClick={handleDeleteCompany}
>Delete Company</button>
title="Delete Company"></button>}
</div>
<div className="stats-grid" style={{ marginBottom: 28 }}>
@@ -267,22 +329,16 @@ export default function CompanyDetail() {
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{['users', 'projects', 'pricing'].map(t => (
<div style={{ display: 'flex', gap: 4, marginBottom: 10, flexWrap: 'wrap', minHeight: 'var(--btn-height)' }}>
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
<button
key={t}
onClick={() => setTab(t)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '8px 16px', fontSize: 13, fontWeight: 600,
color: tab === t ? 'var(--accent)' : 'var(--text-muted)',
borderBottom: tab === t ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -1, textTransform: 'capitalize', fontFamily: 'inherit',
}}
className={`section-tab-btn${tab === t ? ' is-active' : ''}`}
>
{t}
{t.charAt(0).toUpperCase() + t.slice(1)}
{t === 'users' && availableUsers.length > 0 && (
<span style={{ marginLeft: 6, fontSize: 10, background: 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}>
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}>
{availableUsers.length}
</span>
)}
@@ -296,7 +352,7 @@ export default function CompanyDetail() {
<div className="card">
<div className="card-title">Assigned Users</div>
{users.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No users assigned to this company yet.</p>
<div className="card-empty-center">No users</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{users.map((user, i) => (
@@ -309,7 +365,7 @@ export default function CompanyDetail() {
<div style={{
width: 32, height: 32, borderRadius: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 700, color: '#111', flexShrink: 0,
fontSize: 12, fontWeight: 400, color: '#111', flexShrink: 0,
}}>
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
@@ -329,30 +385,31 @@ export default function CompanyDetail() {
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
<div style={{ fontWeight: 400, 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>
</div>
{editingUserId !== user.id && (
{isTeam && editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
className="btn btn-outline btn-sm"
className="btn-icon"
title="Edit"
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
>Edit</button>
><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button
className="btn btn-outline btn-sm"
onClick={() => handleRemoveUser(user.id)}
>Unassign</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
className="btn-icon btn-icon-danger"
title="Delete"
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>
{deletingUserId === user.id ? '...' : 'Delete'}
{deletingUserId === user.id ? '...' : ''}
</button>
</div>
)}
@@ -362,7 +419,7 @@ export default function CompanyDetail() {
)}
</div>
{availableUsers.length > 0 && (
{isTeam && availableUsers.length > 0 && (
<div className="card">
<div className="card-title">Available Users</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
@@ -370,7 +427,7 @@ export default function CompanyDetail() {
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{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 key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -387,7 +444,7 @@ export default function CompanyDetail() {
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontWeight: 400, 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>
</>
@@ -398,13 +455,13 @@ export default function CompanyDetail() {
<button className="btn btn-primary btn-sm" onClick={() => handleAssignUser(user.id)} disabled={assigning}>
Assign to {company.name}
</button>
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
className="btn-icon btn-icon-danger"
title="Delete"
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
>{deletingUserId === user.id ? '...' : ''}</button>
</div>
)}
</div>
@@ -418,11 +475,11 @@ export default function CompanyDetail() {
{/* Projects Tab */}
{tab === 'projects' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
{isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
{showNewProject ? 'Cancel' : '+ New Project'}
</button>
</div>
</div>}
{showNewProject && (
<div className="card">
@@ -447,10 +504,7 @@ export default function CompanyDetail() {
)}
{projects.length === 0 ? (
<div className="empty-state">
<h3>No projects yet</h3>
<p>Create a project to start adding jobs.</p>
</div>
<div className="card card-empty-center">No projects</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => {
@@ -458,10 +512,10 @@ 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} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, 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={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
{active > 0 && <> · <span style={{ color: 'var(--accent)' }}>{active} active</span></>}
@@ -471,12 +525,12 @@ export default function CompanyDetail() {
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
</Link>
<button
{isTeam && <button
type="button"
onClick={() => handleDeleteProject(project)}
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, var(--danger-strong))', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
title="Delete project"
></button>
></button>}
</div>
);
})}
@@ -495,7 +549,7 @@ export default function CompanyDetail() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<div />
{['New', 'Revision'].map(label => (
<div key={label} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
<div key={label} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
))}
<div />
</div>
@@ -527,6 +581,48 @@ export default function CompanyDetail() {
</div>
))}
</div>
<div style={{ marginTop: 32 }}>
<div className="card-title" style={{ fontSize: 14, marginBottom: 8 }}>Sign Family Prices</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
Set prices per sign family for this company.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<div />
{['New', 'Revision'].map(label => (
<div key={label} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
))}
<div />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signFamilies.map(sf => (
<div key={sf} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, alignItems: 'center' }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{sf}</div>
{['new', 'revision'].map(priceType => (
<div key={priceType} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={getSignFamilyPrice(sf, priceType)}
onChange={e => handleSignFamilyPriceChange(sf, priceType, e.target.value)}
style={{ margin: 0, width: '100%', textAlign: 'right' }}
/>
</div>
))}
<button
className="btn btn-outline btn-sm"
onClick={() => handleSignFamilyPriceSave(sf)}
disabled={savingSignFamilyPrice === sf}
>
{savingSignFamilyPrice === sf ? '...' : 'Save'}
</button>
</div>
))}
</div>
</div>
</div>
)}
</Layout>
@@ -1,8 +1,7 @@
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';
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 = [
@@ -13,6 +12,14 @@ const OUTPUT_FORMATS = [
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
const MAX_FILES = 100;
// Lazy-load heic-to (WASM, ~2.7MB) only when a conversion actually runs,
// so visiting the page doesn't pull the whole library up front.
let _heicLibPromise = null;
function getHeicLib() {
if (!_heicLibPromise) _heicLibPromise = import('heic-to/csp');
return _heicLibPromise;
}
function getExtension(name = '') {
return name.split('.').pop()?.toLowerCase() || '';
}
@@ -96,6 +103,7 @@ async function convertRasterBlob(blob, format, quality) {
}
async function convertHeicFile(file, format, quality) {
const { heicTo } = await getHeicLib();
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
try {
return await heicTo({ blob: file, type: format.mime, quality });
@@ -129,7 +137,9 @@ async function convertHeicFile(file, format, quality) {
async function convertFile(file, format, quality) {
const looksHeic = isHeicFile(file);
const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false;
const confirmedHeic = looksHeic
? await getHeicLib().then(({ isHeic }) => isHeic(file)).catch(() => false)
: false;
if (looksHeic || confirmedHeic) {
return convertHeicFile(file, format, quality);
}
@@ -287,7 +297,7 @@ export default function Converters() {
}}
style={{
border: '2px dashed var(--border)',
borderRadius: 10,
borderRadius: 4,
padding: '28px 18px',
textAlign: 'center',
cursor: 'pointer',
@@ -295,7 +305,7 @@ export default function Converters() {
color: 'var(--text-muted)',
}}
>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)', marginBottom: 6 }}>
Drop images here or click to upload
</div>
<div style={{ fontSize: 13 }}>
@@ -368,7 +378,7 @@ export default function Converters() {
</div>
{!files.length ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No files added yet.</div>
<div className="card-empty-center">No files</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{files.map((file, index) => {
@@ -381,7 +391,7 @@ export default function Converters() {
key={id}
style={{
border: '1px solid var(--border)',
borderRadius: 10,
borderRadius: 4,
padding: 14,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
@@ -390,7 +400,7 @@ export default function Converters() {
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{file.name}</div>
<div style={{ fontSize: 14, fontWeight: 400, 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>
@@ -398,7 +408,7 @@ export default function Converters() {
<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 }}>
<div style={{ fontSize: 12, color: 'var(--success, var(--success-strong))', marginTop: 8 }}>
Ready as {outputName}
</div>
)}
Executable → Regular
+4 -4
View File
@@ -14,7 +14,7 @@ export default function Login() {
useEffect(() => {
if (currentUser) {
navigate(currentUser.role === 'client' ? '/my-dashboard' : '/dashboard', { replace: true });
navigate('/dashboard', { replace: true });
}
}, [currentUser, navigate]);
@@ -59,14 +59,14 @@ export default function Login() {
required
/>
</div>
{successMessage && <p style={{ color: '#22c55e', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</p>}
{successMessage && <p style={{ color: 'var(--success)', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: '#a8a8a8' }}>
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: 'var(--text-secondary)' }}>
Contact Fourge Branding to get access.
</p>
</div>
+19 -19
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import PageLoader from '../components/PageLoader';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
@@ -74,58 +75,57 @@ export default function PayInvoice() {
return (
<div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
{loading ? <PageLoader /> : null}
<div style={{ width: '100%', maxWidth: 480 }}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
</div>
{loading ? (
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
) : !invoice ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Invoice not found</div>
{!invoice ? (
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
</div>
) : success || invoice.status === 'paid' ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 32, marginBottom: 12 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Payment received</div>
<div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div>
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div>
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--success-strong)', marginTop: 16 }}>{totalLabel}</div>
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
</div>
) : (
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 13, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
<div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
<div>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
<div style={{ fontWeight: 600, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
<div style={{ fontWeight: 400, color: 'var(--surface-sunken)' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
<div style={{ fontWeight: 600, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
<div style={{ fontWeight: 400, color: 'var(--surface-sunken)' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#141414' }}>{totalLabel}</div>
<div style={{ fontSize: 28, fontWeight: 400, color: 'var(--surface-sunken)' }}>{totalLabel}</div>
</div>
{cancelled && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: 'var(--danger-strong)', marginBottom: 16 }}>
Payment was cancelled. You can try again below.
</div>
)}
{error && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: 'var(--danger-strong)', marginBottom: 16 }}>
{error}
</div>
)}
@@ -134,9 +134,9 @@ export default function PayInvoice() {
onClick={handlePay}
disabled={paying}
style={{
width: '100%', padding: '14px', borderRadius: 8, border: 'none',
background: paying ? '#999' : '#141414', color: '#fff',
fontSize: 16, fontWeight: 700, cursor: paying ? 'not-allowed' : 'pointer',
width: '100%', padding: '14px', borderRadius: 4, border: 'none',
background: paying ? '#999' : 'var(--surface-sunken)', color: '#fff',
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
}}
>
{paying ? 'Redirecting to payment...' : `Pay ${totalLabel}`}
+706
View File
@@ -0,0 +1,706 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../components/Layout';
import SortTh from '../components/SortTh';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle } from '../lib/popupStyles';
function smoothCurve(pts) {
if (pts.length < 2) return '';
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i];
const cpX = (x0 + x1) / 2;
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
}
return d;
}
function MiniAreaChart({ data, color = 'var(--accent)', gradId = 'ag1' }) {
const W = 90, H = 42;
if (!data || data.length < 2) return null;
const max = Math.max(...data, 1);
const pts = data.map((v, i) => [(i / (data.length - 1)) * W, 4 + (1 - v / max) * (H - 8)]);
const line = smoothCurve(pts);
const area = `${line} L${pts[pts.length-1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H} Z`;
return (
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity="0.3" />
<stop offset="95%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#${gradId})`} />
<path d={line} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function formatTaskStatus(status) {
if (status === 'in_progress') return 'In Progress';
if (status === 'on_hold') return 'On Hold';
if (status === 'client_review') return 'In Review';
return status ? status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : '—';
}
export default function ProfilePage() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const { id: profileId } = useParams();
const [loadingProfile, setLoadingProfile] = useState(false);
const [profileError, setProfileError] = useState('');
const [viewedProfile, setViewedProfile] = useState(null);
const [viewedCompanies, setViewedCompanies] = useState([]);
const [primaryCompanyAddress, setPrimaryCompanyAddress] = useState('');
const [editOpen, setEditOpen] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [editError, setEditError] = useState('');
const [editForm, setEditForm] = useState({
name: '',
email: '',
website: '',
linkedin: '',
});
const [avatarUploading, setAvatarUploading] = useState(false);
const [activityItems, setActivityItems] = useState([]);
const [profileStats, setProfileStats] = useState(null);
const [profileTaskItems, setProfileTaskItems] = useState([]);
const isSelfView = !profileId || profileId === currentUser?.id;
useEffect(() => {
let cancelled = false;
async function loadViewedProfile() {
if (!profileId || profileId === currentUser?.id) {
setLoadingProfile(false);
setViewedProfile(null);
setViewedCompanies([]);
setPrimaryCompanyAddress('');
setProfileError('');
return;
}
setLoadingProfile(true);
setProfileError('');
try {
const [{ data: profile, error }] = await Promise.all([
supabase.from('profiles').select('*').eq('id', profileId).single(),
]);
if (error || !profile) {
setProfileError('Unable to load profile.');
return;
}
const [{ data: primaryCompany }, { data: memberships }] = await Promise.all([
profile.company_id
? supabase.from('companies').select('id, name, address').eq('id', profile.company_id).maybeSingle()
: Promise.resolve({ data: null }),
supabase
.from('company_members')
.select('company_id, company:companies(id, name, address)')
.eq('profile_id', profileId),
]);
if (cancelled) return;
const names = new Set();
if (primaryCompany?.name) names.add(primaryCompany.name);
const primaryAddress = primaryCompany?.address || '';
(memberships || []).forEach((row) => {
const n = row?.company?.name;
if (n) names.add(n);
});
setViewedProfile(profile);
setViewedCompanies([...names]);
setPrimaryCompanyAddress(primaryAddress);
} catch (err) {
if (!cancelled) setProfileError('Unable to load profile.');
} finally {
if (!cancelled) setLoadingProfile(false);
}
}
loadViewedProfile();
return () => {
cancelled = true;
};
}, [profileId, currentUser?.id]);
useEffect(() => {
let cancelled = false;
const uid = isSelfView ? currentUser?.id : profileId;
if (!uid) return;
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const now = new Date();
const weeksBack = 10;
const monthsBack = 8;
const weekStart = new Date(now); weekStart.setDate(weekStart.getDate() - weeksBack * 7);
const monthStart = new Date(now); monthStart.setMonth(monthStart.getMonth() - monthsBack);
Promise.all([
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).in('status', doneStatuses),
supabase.from('tasks').select('completed_at').eq('assigned_to', uid).in('status', doneStatuses).not('completed_at', 'is', null).gte('completed_at', weekStart.toISOString()),
supabase.from('project_members').select('project_id').eq('profile_id', uid),
supabase.from('tasks').select('project_id, submitted_at').eq('assigned_to', uid).not('project_id', 'is', null).gte('submitted_at', monthStart.toISOString()),
]).then(([completedTotal, completedRecent, members, tasksByMonth]) => {
if (cancelled) return;
// weekly chart: count completed tasks per week (oldest newest)
const weekBuckets = Array(weeksBack).fill(0);
(completedRecent.data || []).forEach(t => {
const msAgo = now - new Date(t.completed_at);
const weekIdx = weeksBack - 1 - Math.floor(msAgo / (7 * 86400000));
if (weekIdx >= 0 && weekIdx < weeksBack) weekBuckets[weekIdx]++;
});
// monthly chart: distinct active project_ids per month
const monthBuckets = Array(monthsBack).fill(0);
const monthSets = Array.from({ length: monthsBack }, () => new Set());
(tasksByMonth.data || []).forEach(t => {
const msAgo = now - new Date(t.submitted_at);
const mIdx = monthsBack - 1 - Math.floor(msAgo / (30.44 * 86400000));
if (mIdx >= 0 && mIdx < monthsBack) monthSets[mIdx].add(t.project_id);
});
monthSets.forEach((s, i) => { monthBuckets[i] = s.size; });
const projectIds = (members.data || []).map(m => m.project_id);
const fetchActive = projectIds.length > 0
? supabase.from('projects').select('id', { count: 'exact', head: true }).in('id', projectIds).not('status', 'in', '("completed","cancelled")')
: Promise.resolve({ count: 0 });
fetchActive.then(active => {
if (cancelled) return;
setProfileStats({
tasksCompleted: completedTotal.count ?? 0,
tasksChart: weekBuckets,
activeProjects: active.count ?? 0,
projectsChart: monthBuckets,
});
});
});
return () => { cancelled = true; };
}, [isSelfView, currentUser?.id, profileId]);
useEffect(() => {
let cancelled = false;
const uid = isSelfView ? currentUser?.id : profileId;
if (!uid) return;
async function loadProfileTasks() {
const { data } = await supabase
.from('tasks')
.select('id, title, status, submitted_at')
.eq('assigned_to', uid)
.in('status', ['in_progress', 'on_hold', 'client_review'])
.order('submitted_at', { ascending: false })
.limit(20);
if (!cancelled) setProfileTaskItems(data || []);
}
loadProfileTasks();
return () => { cancelled = true; };
}, [isSelfView, currentUser?.id, profileId]);
useEffect(() => {
let cancelled = false;
const uid = isSelfView ? currentUser?.id : profileId;
if (!uid) return;
async function loadActivity() {
const [{ data: actorLogs }, { data: assignedTasks }] = await Promise.all([
supabase
.from('activity_log')
.select('id, created_at, action, task_id, task_title, project_name, project_id')
.eq('actor_id', uid)
.order('created_at', { ascending: false })
.limit(20),
supabase
.from('tasks')
.select('id')
.eq('assigned_to', uid),
]);
const assignedTaskIds = (assignedTasks || []).map((t) => t.id).filter(Boolean);
let outcomeLogs = [];
if (assignedTaskIds.length > 0) {
const { data } = await supabase
.from('activity_log')
.select('id, created_at, action, task_id, task_title, project_name, project_id')
.in('action', ['task_approved', 'revision_requested'])
.in('task_id', assignedTaskIds)
.order('created_at', { ascending: false })
.limit(20);
outcomeLogs = data || [];
}
if (cancelled) return;
const merged = [...(actorLogs || []), ...outcomeLogs];
const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values());
deduped.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setActivityItems(deduped.slice(0, 10));
}
loadActivity();
return () => { cancelled = true; };
}, [isSelfView, currentUser?.id, profileId]);
const profile = useMemo(
() => isSelfView ? { ...(currentUser || {}), ...(viewedProfile || {}) } : viewedProfile,
// eslint-disable-next-line react-hooks/exhaustive-deps
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.avatar_url, viewedProfile]
);
const profileScope = useMemo(() => {
const names = new Set();
if (isSelfView) {
(currentUser?.companies || []).forEach((row) => {
const name = row?.company?.name;
if (name) names.add(name);
});
if (currentUser?.company?.name) names.add(currentUser.company.name);
} else {
(viewedCompanies || []).forEach((name) => { if (name) names.add(name); });
}
const address = isSelfView
? (currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || '')
: (primaryCompanyAddress || '');
return {
companyNames: [...names],
companyAddress: address,
};
}, [isSelfView, currentUser, viewedCompanies, primaryCompanyAddress]);
const companyNames = profileScope.companyNames;
const companyAddress = profileScope.companyAddress;
const companyDisplayName = useMemo(() => {
const role = profile?.role;
if (role === 'team' || role === 'external') return 'Fourge Branding';
if (companyNames.length > 0) return companyNames.join(', ');
return 'No Company';
}, [companyNames, profile?.role]);
const memberSince = useMemo(() => {
const d = profile?.created_at ? new Date(profile.created_at) : null;
return d && !Number.isNaN(d.getTime())
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
}, [profile?.created_at]);
const handleAvatarUpload = async (e) => {
const file = e.target.files?.[0];
if (!file || !currentUser?.id) return;
setAvatarUploading(true);
try {
const ext = file.name.split('.').pop();
const path = `${currentUser.id}/avatar.${ext}`;
const { error: upErr } = await supabase.storage.from('avatars').upload(path, file, { upsert: true });
if (upErr) { setEditError(upErr.message); return; }
const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(path);
const bustedUrl = `${publicUrl}?t=${Date.now()}`;
const { data, error: dbErr } = await supabase.from('profiles').update({ avatar_url: bustedUrl }).eq('id', currentUser.id).select('*').single();
if (dbErr) { setEditError(dbErr.message); return; }
setViewedProfile(data);
} finally {
setAvatarUploading(false);
}
};
const dashCardStyle = {
background: 'var(--card-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '18px 21px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
};
const initials = (profile?.name || '')
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
useEffect(() => {
if (!profile) return;
setEditForm({
name: profile.name || '',
email: profile.email || '',
website: profile.website || '',
linkedin: profile.linkedin || '',
});
setEditError('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile?.name, profile?.email, profile?.website, profile?.linkedin]);
const setEditField = (field) => (e) => setEditForm((prev) => ({ ...prev, [field]: e.target.value }));
const handleProfileSave = async (e) => {
e.preventDefault();
if (!currentUser?.id) return;
setSavingProfile(true);
setEditError('');
try {
const payload = {
name: editForm.name.trim(),
email: editForm.email.trim(),
website: editForm.website.trim(),
linkedin: editForm.linkedin.trim(),
};
const { data, error } = await supabase
.from('profiles')
.update(payload)
.eq('id', currentUser.id)
.select('*')
.single();
if (error) {
setEditError(error.message || 'Unable to update profile.');
return;
}
setViewedProfile(data || null);
setEditOpen(false);
} finally {
setSavingProfile(false);
}
};
const ACTION_ICON = {
task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
};
const ActionIcon = ({ actionKey, size = 27 }) => {
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
</div>
);
};
const ACTION_LABEL = {
task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold',
task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved',
project_created: 'Project created', request_submitted: 'Request submitted', revision_requested: 'Task rejected',
};
const toSentenceTitle = (value = '') => {
if (!value) return '';
return value.charAt(0).toUpperCase() + value.slice(1);
};
const profileTitleStyle = { fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 };
const profileSubStyle = { fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 };
const profileBodyStyle = { fontSize: 13, color: 'var(--text-primary)' };
const metaLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8 };
const metaValueStyle = { fontSize: 13, color: 'var(--text-primary)', marginTop: 2 };
const modalLabelStyle = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
const modalInputStyle = {
fontSize: 13,
padding: '0 5px',
textAlign: 'left',
lineHeight: 1,
};
const modalHelperStyle = { fontSize: 12, color: 'var(--text-muted)', marginTop: 6 };
const modalButtonStyle = {
lineHeight: 1,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
const ProfileTasksInProgressCard = ({ tasks = [] }) => {
const { sortKey, sortDir, toggle, sort } = useSortable('task');
const [showAll, setShowAll] = useState(false);
const rows = sort(tasks.slice(0, 10), (task, key) => {
if (key === 'task') return task.title || '';
if (key === 'status') return formatTaskStatus(task.status);
return '';
});
const visibleRows = showAll ? rows : rows.slice(0, 5);
return (
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
{rows.length > 5 && (
<button type="button" className="dashboard-inline-link" style={{ fontSize: 11, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }} onClick={() => setShowAll((value) => !value)}>
{showAll ? 'Show less' : 'Show all'}
</button>
)}
</div>
{rows.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '65%' }} />
<col style={{ width: '35%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>
Task
</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'right', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 5px 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>
Status
</SortTh>
</tr>
</thead>
<tbody>
{visibleRows.map((task) => (
<tr key={task.id} style={{ background: 'transparent' }}>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${task.id}`)}>{task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'right' }}>
{formatTaskStatus(task.status)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
const ProfileActivityFeed = ({ items }) => (
<div style={{ ...dashCardStyle }}>
<div style={{ marginBottom: items.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
{items.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No recent activity</div>
) : items.map((e, i) => (
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
<ActionIcon actionKey={e.action} />
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: 'var(--text-muted)' }}>{toSentenceTitle(ACTION_LABEL[e.action] || e.action)}</span>
{e.task_title && e.task_id && (
<><span style={{ color: 'var(--text-muted)' }}> </span>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/tasks/${e.task_id}`)}>{e.task_title}</button></>
)}
{e.task_title && !e.task_id && <span style={{ color: 'var(--text-primary)' }}> {e.task_title}</span>}
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{new Date(e.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
))}
</div>
);
return (
<Layout>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{loadingProfile && <div style={dashCardStyle}>Loading profile...</div>}
{!loadingProfile && profileError && <div style={{ ...dashCardStyle, color: 'var(--danger)' }}>{profileError}</div>}
{!loadingProfile && !profileError && (
<div className="profile-top-grid">
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{
...dashCardStyle,
display: 'flex',
alignItems: 'flex-start',
gap: 20,
position: 'relative',
overflow: 'hidden',
backgroundImage: `
radial-gradient(circle at 14% 88%, color-mix(in srgb, var(--accent) 10%, transparent) 0 2px, transparent 2px 16px),
radial-gradient(circle at 22% 80%, color-mix(in srgb, var(--accent) 8%, transparent) 0 2px, transparent 2px 16px),
radial-gradient(circle at 30% 72%, color-mix(in srgb, var(--accent) 7%, transparent) 0 2px, transparent 2px 16px),
radial-gradient(circle at 38% 64%, color-mix(in srgb, var(--accent) 6%, transparent) 0 2px, transparent 2px 16px),
radial-gradient(circle at 46% 56%, color-mix(in srgb, var(--accent) 5%, transparent) 0 2px, transparent 2px 16px),
radial-gradient(circle at 54% 48%, color-mix(in srgb, var(--accent) 4%, transparent) 0 2px, transparent 2px 16px),
radial-gradient(circle at 62% 40%, color-mix(in srgb, var(--accent) 3%, transparent) 0 2px, transparent 2px 16px),
radial-gradient(circle at 70% 32%, color-mix(in srgb, var(--accent) 2%, transparent) 0 2px, transparent 2px 16px)
`,
}}>
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
{isSelfView && (
<button
type="button"
className="btn btn-outline"
onClick={() => setEditOpen(true)}
>
Edit Profile
</button>
)}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10, marginTop: 'auto' }}>
{memberSince && (
<div style={{ textAlign: 'right' }}>
<div style={metaLabelStyle}>Member Since</div>
<div style={metaValueStyle}>{memberSince}</div>
</div>
)}
{profile?.role && (
<div style={{ textAlign: 'right' }}>
<div style={metaLabelStyle}>Role</div>
<div style={metaValueStyle}>
{profile.role === 'team' ? 'Team' : profile.role === 'external' ? 'Subcontractor' : profile.role === 'client' ? 'Client' : profile.role}
</div>
</div>
)}
</div>
</div>
<div style={{ width: 140, height: 140, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid var(--avatar-inner-ring)', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1, overflow: 'hidden' }}>
{profile?.avatar_url
? <img src={profile.avatar_url} alt={profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (initials || '?')}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={profileTitleStyle}>{profile?.name || '—'}</div>
<div style={profileSubStyle}>
{companyDisplayName}
</div>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{companyAddress && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><path d="M12 22s-8-4.5-8-11.8A8 8 0 0112 2a8 8 0 018 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/></svg>
<span style={profileBodyStyle}>{companyAddress}</span>
</div>
)}
{profile?.email && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
<span style={profileBodyStyle}>{profile.email}</span>
</div>
)}
{profile?.website && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, position: 'relative', top: -2 }}><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 010 20M12 2a15.3 15.3 0 000 20"/></svg>
<a href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`} target="_blank" rel="noreferrer" style={{ ...profileBodyStyle, textDecoration: 'none' }}>{profile.website.replace(/^https?:\/\/(www\.)?/, '')}</a>
</div>
)}
{profile?.linkedin && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-muted)" style={{ flexShrink: 0, position: 'relative', top: -2 }}><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>
<a href={profile.linkedin.startsWith('http') ? profile.linkedin : `https://${profile.linkedin}`} target="_blank" rel="noreferrer" style={{ ...profileBodyStyle, textDecoration: 'none' }}>{profile.linkedin.replace(/^https?:\/\/(www\.)?/, '')}</a>
</div>
)}
</div>
</div>
</div>
{profileStats && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
{/* Tasks Completed — weekly */}
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Tasks Completed</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.tasksCompleted}</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'color-mix(in srgb, var(--positive) 15%, transparent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--positive)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} />
</div>
</div>
</div>
<MiniAreaChart data={profileStats.tasksChart} color="var(--positive)" gradId="tcGrad" />
</div>
{/* Active Projects — monthly */}
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Clients</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'color-mix(in srgb, var(--accent) 15%, transparent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' }} />
</div>
</div>
</div>
<MiniAreaChart data={profileStats.projectsChart} color="var(--accent)" gradId="apGrad" />
</div>
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<ProfileTasksInProgressCard tasks={profileTaskItems} />
<ProfileActivityFeed items={activityItems} />
</div>
</div>
)}
</div>
{isSelfView && editOpen && (
<div
style={popupOverlayStyle}
onClick={() => { if (!savingProfile) setEditOpen(false); }}
>
<div
style={{
...dashCardStyle,
width: 'min(434px, 100%)',
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ ...metaLabelStyle, marginBottom: 14 }}>Edit Profile</div>
<form onSubmit={handleProfileSave}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
<div style={{ width: 72, height: 72, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid var(--avatar-inner-ring)', outline: '2px solid var(--accent)', outlineOffset: 0, flexShrink: 0, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, color: 'var(--text-primary)', fontWeight: 500 }}>
{profile?.avatar_url
? <img src={profile.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (initials || '?')}
</div>
<div>
<label htmlFor="avatar-upload" style={{ cursor: 'pointer', display: 'inline-block' }}>
<span className="btn btn-outline" style={{ ...modalButtonStyle, display: 'inline-flex', alignItems: 'center' }}>
{avatarUploading ? 'Uploading…' : 'Change Photo'}
</span>
</label>
<input id="avatar-upload" type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarUpload} disabled={avatarUploading} />
<div style={modalHelperStyle}>JPG, PNG or WebP</div>
</div>
</div>
<div className="form-group">
<label style={modalLabelStyle}>Name</label>
<input type="text" value={editForm.name} onChange={setEditField('name')} style={modalInputStyle} />
</div>
<div className="form-group">
<label style={modalLabelStyle}>Email</label>
<input type="email" value={editForm.email} onChange={setEditField('email')} style={modalInputStyle} />
</div>
<div className="form-group">
<label style={modalLabelStyle}>Website</label>
<input type="text" value={editForm.website} onChange={setEditField('website')} placeholder="example.com" style={modalInputStyle} />
</div>
<div className="form-group">
<label style={modalLabelStyle}>LinkedIn</label>
<input type="text" value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" style={modalInputStyle} />
</div>
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
<div className="modal-action-row" style={{ marginTop: 6 }}>
<button type="submit" className="btn btn-outline" disabled={savingProfile} style={modalButtonStyle}>
{savingProfile ? 'Saving...' : 'Save'}
</button>
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile} style={modalButtonStyle}>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</Layout>
);
}
+680
View File
@@ -0,0 +1,680 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import SortTh from '../components/SortTh';
import FilterDropdown from '../components/FilterDropdown';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { logActivity } from '../lib/activityLog';
import { createInitialSubmissionForRequest, createTaskForRequest } from '../lib/requestSubmission';
import { ensureRequestFolder, renameProjectFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email';
import RequestForm from '../components/RequestForm';
import { useSortable } from '../hooks/useSortable';
import { fmtShortDate } from '../lib/dates';
import { popupOverlayStyle } from '../lib/popupStyles';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { getTaskDerivedState } from '../lib/taskVersions';
import {
TASK_TABLE_TH_STYLE,
TASK_TABLE_TD_BASE,
TASK_MODAL_TITLE_STYLE,
TASK_MODAL_SHELL_STYLE,
} from '../lib/taskUi';
const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
const MODAL_IN = { 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' };
export default function ProjectDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team';
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
const [project, setProject] = useState(null);
const [company, setCompany] = useState(null);
const [tasks, setTasks] = useState([]);
const [members, setMembers] = useState([]);
const [externalProfiles, setExtProfs] = useState([]);
const [loading, setLoading] = useState(true);
const [submissions, setSubmissions] = useState([]);
const [deliveries, setDeliveries] = useState([]);
const [activeTab, setActiveTab] = useState('tasks');
const [filterStatus, setFilterStatus] = useState('all');
const [filterRevision, setFilterRevision] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterAssigned, setFilterAssigned] = useState('all');
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
const [showClientTask, setShowClientTask] = useState(false);
const [clientTaskKey, setClientTaskKey] = useState(0);
const [clientTaskSaving, setClientTaskSaving] = useState(false);
const [clientTaskError, setClientTaskError] = useState('');
const [clientTaskRequestKey, setClientTaskRequestKey] = useState(() => crypto.randomUUID());
const [addingMember, setAddingMember] = useState(false);
const [selectedExts, setSelectedExts] = useState(new Set());
const [savingMembers, setSavingMembers] = useState(false);
const [subSortKey, setSubSortKey] = useState('name');
const [subSortDir, setSubSortDir] = useState('asc');
const toggleSubSort = (col) => { if (subSortKey === col) setSubSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSubSortKey(col); setSubSortDir('asc'); } };
const [subProjectCounts, setSubProjectCounts] = useState({});
const [activityLog, setActivityLog] = useState([]);
const [companyClients, setCompanyClients] = useState([]);
const { sortKey, sortDir, toggle, sort } = useSortable('submitted_at');
useEffect(() => {
async function load() {
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
if (!p) { setLoading(false); return; }
setProject(p);
const [{ data: co }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', p.company_id).single(),
supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false }),
]);
setCompany(co);
setTasks(t || []);
const taskIds = (t || []).map(tk => tk.id);
if (taskIds.length > 0) {
const { data: subs } = await supabase.from('submissions').select('id, task_id, type, version_number, service_type, deadline, is_hot, submitted_at').in('task_id', taskIds).order('submitted_at', { ascending: false });
setSubmissions(subs || []);
if ((subs || []).length > 0) {
const { data: delRows } = await supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', (subs || []).map(sub => sub.id));
setDeliveries(delRows || []);
} else {
setDeliveries([]);
}
} else {
setDeliveries([]);
}
const [{ data: users }, { data: pm }, { data: viaMembers }] = await Promise.all([
supabase.from('profiles').select('id, name, avatar_url, email').eq('company_id', p.company_id).eq('role', 'client'),
supabase.from('project_members').select('*, profile:profiles(id, name, avatar_url, email, role)').eq('project_id', id),
supabase.from('company_members').select('profile:profiles(id, name, avatar_url, email, role)').eq('company_id', p.company_id),
]);
setMembers(pm || []);
const seen = new Set();
const merged = [];
(users || []).forEach(u => { if (!seen.has(u.id)) { seen.add(u.id); merged.push(u); } });
(viaMembers || []).forEach(m => { if (m.profile?.role === 'client' && !seen.has(m.profile.id)) { seen.add(m.profile.id); merged.push(m.profile); } });
setCompanyClients(merged);
const { data: act, error: actErr } = await supabase.from('activity_log').select('id, created_at, actor_name, action, task_title').eq('project_id', id).order('created_at', { ascending: false }).limit(50);
if (actErr) console.error('activity_log fetch:', actErr);
setActivityLog((act || []).filter(e => ['task_started', 'task_on_hold', 'task_approved'].includes(e.action)));
if (isTeam) {
const { data: ext } = await supabase.from('profiles').select('id, name, avatar_url, email').eq('role', 'external').order('name');
setExtProfs(ext || []);
if ((ext || []).length > 0) {
const { data: pmRows } = await supabase.from('project_members').select('profile_id').in('profile_id', ext.map(p => p.id));
const counts = {};
(pmRows || []).forEach(r => { counts[r.profile_id] = (counts[r.profile_id] || 0) + 1; });
setSubProjectCounts(counts);
}
}
setLoading(false);
}
load();
}, [id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSaveName = async (e) => {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
const oldName = project.name;
const newName = nameVal.trim();
await supabase.from('projects').update({ name: newName }).eq('id', id);
try {
await renameProjectFolder({ projectId: id, oldName, newName });
} catch (folderError) {
console.error('Project folder rename failed:', folderError);
}
setProject(p => ({ ...p, name: newName }));
setEditingName(false);
setSavingName(false);
};
const handleDeleteProject = async () => {
if (!window.confirm(`Delete project "${project.name}"?`)) return;
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-project?id=${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
navigate(isClient ? '/tasks' : `/company/${company?.id}`);
};
const handleDeleteTask = async (taskId, e) => {
e.stopPropagation();
if (!window.confirm('Delete this task?')) return;
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-task?id=${taskId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const handleAddMember = async () => {
setSavingMembers(true);
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
const toAdd = [...selectedExts].filter(pid => !currentIds.has(pid));
const toRemove = [...currentIds].filter(pid => !selectedExts.has(pid));
if (toAdd.length > 0) {
const { data } = await supabase.from('project_members').insert(toAdd.map(pid => ({ project_id: id, profile_id: pid }))).select('*, profile:profiles(id, name, avatar_url, email, role)');
if (data) setMembers(prev => [...prev, ...data]);
}
if (toRemove.length > 0) {
for (const pid of toRemove) {
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', pid);
}
setMembers(prev => prev.filter(m => !toRemove.includes(m.profile_id)));
}
setSelectedExts(new Set());
setAddingMember(false);
setSavingMembers(false);
};
const handleClientTask = async (formData, files) => {
if (clientTaskSaving) return;
setClientTaskSaving(true); setClientTaskError('');
try {
const { task } = await createTaskForRequest({ projectId: project.id, title: formData.title.trim(), requestKey: clientTaskRequestKey });
if (!task) throw new Error('Failed to create task.');
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: clientTaskRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
signCount: formData.signCount, signs: formData.signs,
deadline: formData.deadline, description: formData.description,
submittedBy: currentUser.id, submittedByName: currentUser.name,
});
try {
await ensureRequestFolder({ projectId: project.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (submission && files.length > 0) {
for (const file of files) {
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
if (up) {
await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
try {
await uploadRequestFileToSurvey({
projectId: project.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: project.id, projectName: project.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', { clientName: currentUser.name, clientEmail: currentUser.email, company: company?.name || '', serviceType: formData.serviceType, projectName: project.name, projectId: project.id, deadline: formData.deadline, description: formData.description, taskId: task.id }).catch(() => {});
const { data: newTasks } = await supabase.from('tasks').select('*, assignee:profiles!assigned_to(avatar_url)').eq('project_id', id).order('submitted_at', { ascending: false });
setTasks(newTasks || []);
setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskRequestKey(crypto.randomUUID());
} catch (err) { setClientTaskError(err.message); }
finally { setClientTaskSaving(false); }
};
if (loading) return <Layout><PageLoader /></Layout>;
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
const rows = tasks.map(task => {
const derived = getTaskDerivedState(task, submissions, deliveries);
return {
id: task.id, rowKey: task.id, title: task.title, status: task.status,
version: derived.currentVersion,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
serviceType: derived.serviceType, deadline: derived.deadline, isHot: derived.isHot,
submittedAt: derived.latestActivityAt ? new Date(derived.latestActivityAt).toISOString() : (task.submitted_at || ''),
};
});
const sortedRows = sort(rows, (r, key) => {
if (key === 'title') return r.title || '';
if (key === 'assigned') return r.assignedName || '';
if (key === 'revision') return r.version;
if (key === 'status') return r.status || '';
if (key === 'serviceType') return r.serviceType || '';
if (key === 'deadline') return r.deadline || '';
if (key === 'priority') return r.isHot ? 0 : 1;
if (key === 'submitted_at') return r.submittedAt;
return '';
});
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const taskTabs = [
{ id: 'tasks', label: 'Tasks' },
{ id: 'completed', label: 'Completed' },
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
];
const tabBaseRows = activeTab === 'completed'
? sortedRows.filter(r => doneStatuses.has(r.status))
: sortedRows.filter(r => !doneStatuses.has(r.status));
let visibleRows = tabBaseRows;
if (filterStatus !== 'all') visibleRows = visibleRows.filter(r => r.status === filterStatus);
if (filterRevision === 'new') visibleRows = visibleRows.filter(r => Number(r.version || 0) === 0);
else if (filterRevision === 'revision') visibleRows = visibleRows.filter(r => Number(r.version || 0) >= 1);
if (filterType !== 'all') visibleRows = visibleRows.filter(r => (r.serviceType || '') === filterType);
if (filterAssigned === 'unassigned') visibleRows = visibleRows.filter(r => !r.assignedTo);
else if (filterAssigned !== 'all') visibleRows = visibleRows.filter(r => r.assignedTo === filterAssigned);
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
? [
{ value: 'all', label: 'All Statuses' },
{ value: 'client_approved', label: 'Approved' },
{ value: 'invoiced', label: 'Invoiced' },
{ value: 'paid', label: 'Paid' },
]
: [
{ value: 'all', label: 'All Statuses' },
{ value: 'not_started', label: 'Not Started' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'client_review', label: 'In Review' },
];
const REVISION_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'new', label: 'New (R00)' },
{ value: 'revision', label: 'Revisions (R1+)' },
];
const TYPE_FILTER_OPTIONS = [
{ value: 'all', label: 'All Types' },
...[...new Set(tabBaseRows.map(r => r.serviceType).filter(Boolean))].sort((a, b) => a.localeCompare(b)).map(t => ({ value: t, label: t })),
];
const ASSIGNED_FILTER_OPTIONS = [
{ value: 'all', label: 'All Assignees' },
{ value: 'unassigned', label: 'Unassigned' },
...[...new Map(tabBaseRows.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, name]) => ({ value: id, label: name })),
];
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
return (
<Layout>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
{/* Left 70% */}
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
{/* Header card */}
<div className="card" style={CARD}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
{editingName && isTeam ? (
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
<input autoFocus required value={nameVal} onChange={e => setNameVal(e.target.value)} style={{ ...MODAL_IN, fontSize: 20, padding: '4px 10px', flex: 1 }} />
<button type="submit" className="btn btn-outline" disabled={savingName}>Save</button>
<button type="button" className="btn btn-outline" onClick={() => setEditingName(false)}>Cancel</button>
</form>
) : (
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{project.name}</div>
)}
{isClient && (
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-outline" onClick={() => setShowClientTask(true)}>+ Task</button>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
<StatusBadge status={project.status} />
</div>
{(() => {
const approved = tasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length;
const pct = tasks.length > 0 ? Math.round((approved / tasks.length) * 100) : 0;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 30, flexWrap: 'wrap' }}>
{!isClient && company && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>
<div>
<div style={CARD_META_LABEL}>Company</div>
{isTeam
? <Link to={`/company/${company.id}`} className="table-link" style={{ fontSize: 13 }}>{company.name}</Link>
: <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{company.name}</div>
}
</div>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<div>
<div style={CARD_META_LABEL}>Started</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate(project.created_at)}</div>
</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 5, minWidth: 160 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div style={CARD_META_LABEL}>Completion</div>
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{pct}%</div>
</div>
<div style={{ width: '100%', height: 5, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)', borderRadius: 3, transition: 'width 400ms ease' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{approved} of {tasks.length} approved</div>
</div>
</div>
);
})()}
</div>
{/* Tabs + content card */}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{(() => {
const tabCounts = {
tasks: rows.filter(r => !doneStatuses.has(r.status)).length,
completed: rows.filter(r => doneStatuses.has(r.status)).length,
members: members.filter(m => m.profile?.role === 'external').length,
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
{taskTabs.map(tab => {
const count = tabCounts[tab.id] ?? 0;
const active = activeTab === tab.id;
return (
<button key={tab.id} onClick={() => { setActiveTab(tab.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${active ? ' is-active' : ''}`}>
{tab.label}
{tab.id !== 'all' && count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
</button>
);
})}
{activeTab === 'members' && isTeam && (
<button className="btn btn-outline" style={{ marginLeft: 'auto' }} onClick={() => { const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id)); setSelectedExts(currentIds); setAddingMember(true); }}>Manage</button>
)}
</div>
);
})()}
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{activeTab !== 'members' && (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '14%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '16%' }} />
</colgroup>
<thead>
<tr>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>Status<span style={{ marginLeft: 4, opacity: sortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
</span>
</th>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>R#<span style={{ marginLeft: 4, opacity: sortKey === 'revision' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>Assigned<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
</span>
</th>
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>Task Type<span style={{ marginLeft: 4, opacity: sortKey === 'serviceType' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
</tr>
</thead>
<tbody>
{visibleRows.length === 0 ? (
<tr><td colSpan={6} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
) : visibleRows.map(row => {
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
return (
<tr key={row.rowKey}>
<td style={{ ...TASK_TABLE_TD_BASE }}><StatusBadge status={row.status} /></td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{row.assignedName && row.assignedTo
? <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>
<ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} style={{ verticalAlign: 'middle' }} />
</Link>
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{activeTab === 'members' && isTeam && (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
{(() => {
const subs = members.filter(m => m.profile?.role === 'external');
if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
const sorted = [...subs].sort((a, b) => {
if (subSortKey === 'projects') {
const av = subProjectCounts[a.profile_id] || 0;
const bv = subProjectCounts[b.profile_id] || 0;
return subSortDir === 'asc' ? av - bv : bv - av;
}
const av = subSortKey === 'name' ? (a.profile?.name || '') : (a.profile?.email || '');
const bv = subSortKey === 'name' ? (b.profile?.name || '') : (b.profile?.email || '');
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
return (
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '40%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
</colgroup>
<thead>
<tr>
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
</tr>
</thead>
<tbody>
{sorted.map(m => {
const projCount = subProjectCounts[m.profile_id] || 0;
return (
<tr key={m.id}>
<td style={{ ...TASK_TABLE_TD_BASE }}>
<ProfileAvatar profile={m.profile} name={m.profile?.name} size={26} fontSize={10} />
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)' }}>{m.profile?.email || '—'}</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>{projCount}</td>
<td style={{ ...TASK_TABLE_TD_BASE }} />
</tr>
);
})}
</tbody>
</table>
);
})()}
</div>
)}
</div>
</div>
</div>
{/* Right 30% */}
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div className="card" style={CARD}>
<div style={LABEL}>Main Contact</div>
{companyClients.length === 0
? <div className="card-empty-center">No contacts</div>
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{companyClients.map(person => {
return (
<div key={person.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<ProfileAvatar profile={person} name={person.name} size={32} fontSize={12} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{person.name}</div>
{person.email && <div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.email}</div>}
</div>
</div>
);
})}
</div>
)
}
</div>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Activity</div>
{(() => {
const SHOW = { task_started: { label: 'started', color: 'var(--info)', bg: 'color-mix(in srgb, var(--info) 15%, transparent)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: 'var(--danger)', bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: 'var(--positive)', bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', path: 'M4 13l5 5L20 7' } };
const filtered = activityLog;
if (filtered.length === 0) return <div className="card-empty-center">No activity</div>;
return (
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map(e => {
const cfg = SHOW[e.action];
const d = new Date(e.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
return (
<div key={e.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: cfg.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={cfg.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d={cfg.path} /></svg>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{e.actor_name || 'Fourge'}</span>
<span style={{ color: 'var(--text-muted)' }}> {cfg.label} </span>
{e.task_title && <span style={{ color: 'var(--text-primary)' }}>{e.task_title}</span>}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{dateStr} · {timeStr}</div>
</div>
</div>
);
})}
</div>
);
})()}
</div>
</div>
</div>
{showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
<RequestForm
key={clientTaskKey}
companies={company ? [company] : []}
initialCompanyId={company?.id || ''}
initialValues={{ companyId: company?.id || '', project: project.name }}
currentUser={currentUser}
showRequester={false}
lockedFields={['company', 'project']}
onSubmit={handleClientTask}
onCancel={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}
saving={clientTaskSaving}
error={clientTaskError}
submitLabel="Save"
/>
</div>
</div>
)}
{addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{project.name}</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
{externalProfiles.length === 0
? <div className="card-empty-center">No subcontractors</div>
: <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '36%' }} />
<col style={{ width: '49%' }} />
<col style={{ width: '10%' }} />
</colgroup>
<thead>
<tr>
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px', textAlign: 'center' }}>Assigned</th>
</tr>
</thead>
<tbody>
{[...externalProfiles].sort((a, b) => {
const av = subSortKey === 'name' ? (a.name || '') : (a.email || '');
const bv = subSortKey === 'name' ? (b.name || '') : (b.email || '');
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
}).map(p => {
const checked = selectedExts.has(p.id);
return (
<tr key={p.id} onClick={() => setSelectedExts(prev => { const s = new Set(prev); s.has(p.id) ? s.delete(p.id) : s.add(p.id); return s; })} style={{ cursor: 'pointer' }}>
<td style={{ padding: '5px 0', border: 'none', background: 'transparent' }}>
<ProfileAvatar profile={p} name={p.name} size={26} fontSize={10} />
</td>
<td style={{ padding: '5px 5px 5px 12px', border: 'none', background: 'transparent', fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td>
<td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td>
<td style={{ padding: '5px 0', border: 'none', background: 'transparent', textAlign: 'center' }}>
<div style={{ width: 18, height: 18, borderRadius: 4, border: `2px solid ${checked ? 'var(--accent)' : 'var(--border)'}`, background: checked ? 'var(--accent)' : 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'all 140ms' }}>
{checked && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
}
</div>
<div className="modal-action-row" style={{ padding: '0 21px 18px' }}>
<button className="btn btn-outline" disabled={savingMembers} onClick={handleAddMember}>{savingMembers ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>Cancel</button>
</div>
</div>
</div>
)}
</Layout>
);
}
-90
View File
@@ -1,90 +0,0 @@
import { useState } from 'react';
import Layout from '../components/Layout';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
export default function Settings() {
const { currentUser } = useAuth();
const [passwords, setPasswords] = useState({ next: '', confirm: '' });
const [passwordSaved, setPasswordSaved] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [savingPw, setSavingPw] = useState(false);
const setPw = (field) => (e) => setPasswords(p => ({ ...p, [field]: e.target.value }));
const handlePasswordSave = async (e) => {
e.preventDefault();
setPasswordError('');
setPasswordSaved(false);
if (passwords.next !== passwords.confirm) { setPasswordError('New passwords do not match.'); return; }
if (passwords.next.length < 6) { setPasswordError('Password must be at least 6 characters.'); return; }
setSavingPw(true);
try {
const { error } = await supabase.auth.updateUser({ password: passwords.next });
if (error) { setPasswordError(error.message); return; }
setPasswords({ next: '', confirm: '' });
setPasswordSaved(true);
setTimeout(() => setPasswordSaved(false), 3000);
} finally {
setSavingPw(false);
}
};
const initials = (currentUser?.name || '')
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Settings</div>
<div className="page-subtitle">Your account info and password.</div>
</div>
</div>
<div style={{ maxWidth: 520, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div className="sidebar-avatar" style={{ width: 56, height: 56, fontSize: 20, flexShrink: 0 }}>
{initials || '?'}
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 15 }}>{currentUser?.name || '—'}</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{currentUser?.email}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, textTransform: 'capitalize' }}>
{currentUser?.role}{currentUser?.company?.name ? ` · ${currentUser.company.name}` : ''}
</div>
</div>
</div>
<div className="card">
<div className="card-title">Change Password</div>
<form onSubmit={handlePasswordSave}>
<div className="form-group">
<label>New Password *</label>
<input type="password" placeholder="Min. 6 characters" value={passwords.next} onChange={setPw('next')} required />
</div>
<div className="form-group">
<label>Confirm New Password *</label>
<input type="password" placeholder="Repeat new password" value={passwords.confirm} onChange={setPw('confirm')} required />
</div>
{passwordError && (
<div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {passwordError}</div>
)}
{passwordSaved && (
<div className="notification notification-success" style={{ marginBottom: 12 }}> Password updated.</div>
)}
<button type="submit" className="btn btn-primary" disabled={savingPw}>
{savingPw ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
</Layout>
);
}
@@ -1,7 +1,7 @@
import { useRef, useState } from 'react';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { generateSurveyMakerPdf } from '../../lib/surveyMakerPdf';
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']);
@@ -90,7 +90,7 @@ export default function SurveyMaker() {
</div>
{notification && (
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, #16a34a)', fontSize: 13 }}>
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, var(--success-strong))', fontSize: 13 }}>
{notification.msg}
</div>
)}
@@ -154,9 +154,9 @@ function SignCard({ sign, index, onChange, onPhoto, onRemove, canRemove }) {
const contextInputRef3 = useRef(null);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, display: 'grid', gap: 14 }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 4, 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)' }}>
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>
{sign.signName || `Sign ${index + 1}`}
</div>
{canRemove && (
@@ -276,10 +276,10 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
borderRadius: 4,
minHeight: small ? 110 : 120,
cursor: 'pointer',
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--card-bg-2)',
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--card-bg-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -289,7 +289,7 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
}}
>
{preview ? (
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 6 }} />
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 4 }} />
) : (
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
{dragging ? 'Drop photo here' : label}
File diff suppressed because it is too large Load Diff
+837
View File
@@ -0,0 +1,837 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import ProfileAvatar from '../components/ProfileAvatar';
import StatusBadge from '../components/StatusBadge';
import RequestForm from '../components/RequestForm';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
import { getTaskDerivedState } from '../lib/taskVersions';
import { logActivity } from '../lib/activityLog';
import { fmtShortDate } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email';
import SortTh from '../components/SortTh';
import FilterDropdown from '../components/FilterDropdown';
import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle } from '../lib/popupStyles';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
import {
TASK_TABLE_TH_STYLE,
TASK_TABLE_TD_BASE,
TASK_MODAL_TITLE_STYLE,
TASK_MODAL_LABEL_STYLE,
TASK_MODAL_INPUT_STYLE,
TASK_MODAL_BUTTON_STYLE,
TASK_MODAL_SHELL_STYLE,
PROJECT_MODAL_SHELL_STYLE,
} from '../lib/taskUi';
const TASK_STAT_ICONS = {
total: '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>',
todo: '<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>',
progress: '<polyline points="4,14 10,8 14,12 20,6"/><line x1="20" y1="6" x2="20" y2="11"/><line x1="20" y1="6" x2="15" y2="6"/>',
hold: '<rect x="7" y="6" width="3" height="12" rx="1"/><rect x="14" y="6" width="3" height="12" rx="1"/>',
review: '<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.5"/>',
done: '<polyline points="4,12 9,17 20,6"/>',
};
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div>
</div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 5 }}>{sub}</div>
</div>
);
}
function TasksStatsRow({ tasks = [] }) {
const toDo = tasks.filter(t => t?.status === 'not_started').length;
const inProgress = tasks.filter(t => t?.status === 'in_progress').length;
const onHold = tasks.filter(t => t?.status === 'on_hold').length;
const inReview = tasks.filter(t => t?.status === 'client_review').length;
const completed = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length;
const total = tasks.length;
const open = Math.max(total - completed, 0);
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return (
<div className="tasks-stat-grid">
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={TASK_STAT_ICONS.total} />
</div>
);
}
function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) {
const [projSortKey, setProjSortKey] = useState('status');
const [projSortDir, setProjSortDir] = useState('asc');
const [projTab, setProjTab] = useState('active');
const toggleProjSort = (col) => {
if (projSortKey === col) setProjSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setProjSortKey(col); setProjSortDir('asc'); }
};
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
const projectTabs = [
{ id: 'all', label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'completed', label: 'Completed' },
];
const visibleProjects = projects.filter(p => {
if (projTab === 'all') return true;
const done = completedStatuses.has((p?.status || '').toLowerCase());
return projTab === 'completed' ? done : !done;
});
const projectTabCounts = {
all: projects.length,
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
};
const sortedProjects = [...visibleProjects].sort((a, b) => {
if (projSortKey === 'status') {
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
const rb = completedStatuses.has((b.status || '').toLowerCase()) ? 1 : 0;
if (ra !== rb) return projSortDir === 'asc' ? ra - rb : rb - ra;
}
const av = (a.name || '').toLowerCase();
const bv = (b.name || '').toLowerCase();
return projSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
const projectCompletionPct = (project) => {
const pt = statsTasks.filter(t => t?.project_id === project?.id);
if (pt.length > 0) {
return Math.round((pt.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length / pt.length) * 100);
}
const s = (project?.status || '').toLowerCase();
if (s === 'completed' || s === 'done') return 100;
if (s === 'cancelled' || s === 'archived') return 0;
return 35;
};
return (
<div className="tasks-shell" style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<TasksStatsRow tasks={statsTasks} />
<div className="tasks-body-row" style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="tasks-body-col" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{children}
</div>
</div>
</div>
);
}
export default function RequestsPage() {
const { currentUser } = useAuth();
const isTeam = currentUser?.role === 'team';
const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client';
// Company column only meaningful when the user spans multiple companies (team sees all).
const showCompanyCol = isTeam || (currentUser?.companies?.length || 0) > 1;
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
const teamCached = isTeam ? readPageCache('team_requests') : null;
const extCached = isExternal ? readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000) : null;
const [projects, setProjects] = useState(() => teamCached?.projects || extCached?.projects || []);
const [tasks, setTasks] = useState(() => teamCached?.tasks || extCached?.tasks || []);
const [submissions, setSubmissions] = useState(() => teamCached?.submissions || extCached?.submissions || []);
const [deliveries, setDeliveries] = useState([]);
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
const [loading, setLoading] = useState(() => {
if (isTeam) return !teamCached;
if (isExternal) return !extCached;
return true;
});
const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('tasks');
const [filterCompany, setFilterCompany] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterRevision, setFilterRevision] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterAssigned, setFilterAssigned] = useState('all');
const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
const [showAddForm, setShowAddForm] = useState(false);
const [addFormKey, setAddFormKey] = useState(0);
const [addSaving, setAddSaving] = useState(false);
const [addError, setAddError] = useState('');
const [uploadProgress, setUploadProgress] = useState({ active: false, label: '', percent: 0 });
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
const [showAddProjectForm, setShowAddProjectForm] = useState(false);
const [addProjectSaving, setAddProjectSaving] = useState(false);
const [addProjectError, setAddProjectError] = useState('');
const [addProjectForm, setAddProjectForm] = useState({ name: '', companyId: '' });
const [companyFilterMenuOpen, setCompanyFilterMenuOpen] = useState(false);
const companyFilterMenuRef = useRef(null);
const [projectFilterMenuOpen, setProjectFilterMenuOpen] = useState(false);
const projectFilterMenuRef = useRef(null);
useEffect(() => {
if (!companyFilterMenuOpen) return;
function onDocClick(e) {
if (companyFilterMenuRef.current && !companyFilterMenuRef.current.contains(e.target)) setCompanyFilterMenuOpen(false);
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [companyFilterMenuOpen]);
useEffect(() => {
if (!projectFilterMenuOpen) return;
function onDocClick(e) {
if (projectFilterMenuRef.current && !projectFilterMenuRef.current.contains(e.target)) setProjectFilterMenuOpen(false);
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [projectFilterMenuOpen]);
useEffect(() => {
let cancelled = false;
async function loadTasksPage() {
if (!currentUser?.id) { setLoading(false); return; }
try {
setError(''); setLoadError(false);
// Scope is enforced server-side by RLS (team sees all; client = has_company_access;
// external = project_members). No need to pre-fetch scoped IDs that was a
// redundant serial round trip. Query directly and let RLS filter.
const tasksQ = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
.order('submitted_at', { ascending: false });
const projectsQ = supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)');
const subsQ = supabase.from('submissions')
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
.order('submitted_at', { ascending: false });
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout(
Promise.all([subsQ, tasksQ, projectsQ, supabase.from('companies').select('id, name')]),
15000, 'Tasks page load'
);
if (cancelled) return;
const allSubs = subs || [];
// Resolve submitter names + deliveries with light follow-up queries (faster than
// a nested PostgREST embed, which is very slow on this compute tier).
const submitterIds = [...new Set(allSubs.map(s => s.submitted_by).filter(Boolean))];
const [{ data: profileRows }, { data: delRows }] = await withTimeout(
Promise.all([
submitterIds.length > 0
? supabase.from('profiles').select('id, name').in('id', submitterIds)
: Promise.resolve({ data: [] }),
allSubs.length > 0
? supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', allSubs.map(s => s.id))
: Promise.resolve({ data: [] }),
]),
15000, 'Tasks page details'
);
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, profileRows || []);
const deliveryRows = delRows || [];
setProjects(p || []);
setTasks(t || []);
setSubmissions(hydratedSubs);
setDeliveries(deliveryRows);
setCompanies(co || []);
if (isTeam) {
writePageCache('team_requests', { submissions: hydratedSubs, tasks: t || [], projects: p || [], companies: co || [] });
} else if (isExternal) {
writePageCache(`ext-requests:${currentUser.id}`, { projects: p || [], tasks: t || [], submissions: hydratedSubs });
}
} catch (err) {
console.error('Tasks page load failed:', err);
setError(err.message || 'Failed to load tasks.');
setLoadError(true);
} finally {
if (!cancelled) setLoading(false);
}
}
loadTasksPage();
return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleAddRequest = async (formData, files, existingProjects) => {
if (addSaving) return;
setAddSaving(true); setAddError('');
try {
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
const taskCompany = companies?.find(c => c.id === formData.companyId);
const { submission: sub } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
signCount: formData.signCount, signs: formData.signs,
deadline: formData.deadline,
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
});
if (!sub) throw new Error('Failed to create submission.');
try {
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (files.length > 0) {
const taskCompany = companies?.find(c => c.id === formData.companyId);
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) {
await supabase.from('tasks').delete().eq('id', task.id);
setUploadProgress({ active: false, label: '', percent: 0 });
throw new Error(`Upload failed: ${uploadError.message}`);
}
if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: sub.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) {
await supabase.from('tasks').delete().eq('id', task.id);
setUploadProgress({ active: false, label: '', percent: 0 });
throw new Error(`File record failed: ${fileErr.message}`);
}
try {
await uploadRequestFileToSurvey({
projectId: resolvedProject.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
setUploadProgress({ active: false, label: '', percent: 0 });
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: formData.requestedByName || currentUser.name,
clientEmail: currentUser.email || 'hello@fourgebranding.com',
company: taskCompany?.name || '',
serviceType: formData.serviceType,
projectName: resolvedProject.name,
projectId: resolvedProject.id,
deadline: formData.deadline,
description: formData.description,
taskId: task.id,
}).catch(() => {});
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)').order('submitted_at', { ascending: false }),
]);
const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
const { data: submitterProfiles } = submitterIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
: { data: [] };
setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || [])); setTasks(newTasks || []);
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); }
finally { setAddSaving(false); }
};
const handleClientRequest = async (formData, files, existingProjects) => {
if (addSaving) return;
setAddSaving(true); setAddError('');
try {
const clientCos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
const selectedCompany = clientCos.find(c => c.id === formData.companyId);
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, signFamily: formData.signFamily,
signCount: formData.signCount, signs: formData.signs,
deadline: formData.deadline,
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
});
try {
await ensureRequestFolder({ projectId: resolvedProject.id, taskTitle: formData.title.trim() });
} catch (folderError) {
console.error('Request folder sync failed:', folderError);
}
if (submission && files.length > 0) {
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/Survey/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
try {
await uploadRequestFileToSurvey({
projectId: resolvedProject.id,
taskTitle: formData.title.trim(),
storagePath: path,
fileName: file.name,
});
} catch (mirrorError) {
console.error('Survey folder mirror failed:', mirrorError);
}
}
}
setUploadProgress({ active: false, label: '', percent: 0 });
}
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name, clientEmail: currentUser.email,
company: selectedCompany?.name || '', serviceType: formData.serviceType,
projectName: formData.project, projectId: resolvedProject.id, deadline: formData.deadline,
description: formData.description, taskId: task.id,
}).catch(() => {});
const refreshCompanyIds = clientCos.map(c => c.id).filter(Boolean);
const { data: refreshProjects } = await supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)').in('company_id', refreshCompanyIds);
const refreshProjectIds = (refreshProjects || []).map(p => p.id);
const { data: newTasks } = await supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
.in('project_id', refreshProjectIds.length > 0 ? refreshProjectIds : ['__none__'])
.order('submitted_at', { ascending: false });
const refreshTaskIds = (newTasks || []).map(t => t.id);
const { data: newSubs } = await supabase.from('submissions')
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
.in('task_id', refreshTaskIds.length > 0 ? refreshTaskIds : ['__none__'])
.order('submitted_at', { ascending: false });
const submitterIds = [...new Set((newSubs || []).map((submission) => submission.submitted_by).filter(Boolean))];
const { data: submitterProfiles } = submitterIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', submitterIds)
: { data: [] };
setProjects(refreshProjects || []); setTasks(newTasks || []); setSubmissions(mergeSubmissionDisplayNames(newSubs || [], submitterProfiles || []));
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
} catch (err) { setAddError(err.message); }
finally { setAddSaving(false); }
};
const handleAddProject = async (e) => {
e.preventDefault();
if (addProjectSaving) return;
setAddProjectSaving(true); setAddProjectError('');
try {
const name = addProjectForm.name.trim();
if (!name) throw new Error('Project name required.');
if (!addProjectForm.companyId) throw new Error('Company required.');
const { data: newProject, error: insertError } = await supabase
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
.select('id, name, status, company_id, company:companies(id, name)').single();
if (insertError) throw insertError;
try {
await ensureProjectFolder({ projectId: newProject.id });
} catch (folderError) {
console.error('Project folder sync failed:', folderError);
}
setProjects(prev => [...prev, newProject]);
setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' });
} catch (err) { setAddProjectError(err.message || 'Failed to create project.'); }
finally { setAddProjectSaving(false); }
};
// Companies available for filter menu and forms
const filterableCompanies = useMemo(() => {
if (isClient) {
const cos = currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []);
return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
}
return companies.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}, [isClient, companies, currentUser]); // eslint-disable-line react-hooks/exhaustive-deps
// Normalize all tasks to a common row shape for unified table render
const allRows = useMemo(() => {
if (isClient) {
return tasks.map(task => {
const derived = getTaskDerivedState(task, submissions, deliveries);
return {
id: task.id, rowKey: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType: derived.serviceType, deadline: derived.initialSubmission?.deadline || derived.deadline || null,
version: derived.currentVersion, isHot: false,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: derived.latestActivityAt,
};
});
}
return tasks.map(task => {
const derived = getTaskDerivedState(task, submissions, deliveries);
if (derived.visibleTaskSubs.length === 0 || !derived.deadlineSource) return null;
return {
id: task.id, rowKey: task.id, title: task.title, status: task.status, projectId: task.project_id,
serviceType: derived.serviceType, deadline: derived.deadline,
version: derived.currentVersion,
isHot: derived.isHot,
assignedName: task.assigned_name || null, assigneeAvatar: task.assignee?.avatar_url || null, assignedTo: task.assigned_to || null,
submittedAt: derived.latestActivityAt,
};
}).filter(Boolean);
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
const filteredRows = useMemo(() => {
return allRows
.filter(row => {
const project = projects.find(p => p.id === row.projectId);
if (filterCompany && project?.company_id !== filterCompany) return false;
if (filterProject && row.projectId !== filterProject) return false;
return true;
})
.sort((a, b) => b.submittedAt - a.submittedAt);
}, [allRows, projects, filterCompany, filterProject]);
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const tabs = [
{ id: 'tasks', label: 'Tasks' },
{ id: 'completed', label: 'Completed' },
];
const tabCounts = {
tasks: filteredRows.filter(r => !doneStatuses.has(r.status)).length,
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
};
const tabRowsBase = activeTab === 'completed'
? filteredRows.filter(r => doneStatuses.has(r.status))
: filteredRows.filter(r => !doneStatuses.has(r.status));
let tabRows = tabRowsBase;
if (filterStatus !== 'all') tabRows = tabRows.filter(r => r.status === filterStatus);
if (filterRevision === 'new') tabRows = tabRows.filter(r => Number(r.version || 0) === 0);
else if (filterRevision === 'revision') tabRows = tabRows.filter(r => Number(r.version || 0) >= 1);
if (filterType !== 'all') tabRows = tabRows.filter(r => (r.serviceType || '') === filterType);
if (filterAssigned === 'unassigned') tabRows = tabRows.filter(r => !r.assignedTo);
else if (filterAssigned !== 'all') tabRows = tabRows.filter(r => r.assignedTo === filterAssigned);
const REVISION_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'new', label: 'New (R00)' },
{ value: 'revision', label: 'Revisions (R1+)' },
];
const PROJECT_FILTER_OPTIONS = [
{ value: '', label: 'All Projects' },
...projects
.filter(p => !filterCompany || p.company_id === filterCompany)
.slice()
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
.map(p => ({ value: p.id, label: p.name })),
];
const TYPE_FILTER_OPTIONS = [
{ value: 'all', label: 'All Types' },
...[...new Set(tabRowsBase.map(r => r.serviceType).filter(Boolean))]
.sort((a, b) => a.localeCompare(b))
.map(t => ({ value: t, label: t })),
];
const COMPANY_FILTER_OPTIONS = [
{ value: '', label: 'All Companies' },
...filterableCompanies.map(c => ({ value: c.id, label: c.name })),
];
const ASSIGNED_FILTER_OPTIONS = [
{ value: 'all', label: 'All Assignees' },
{ value: 'unassigned', label: 'Unassigned' },
...[...new Map(tabRowsBase.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, name]) => ({ value: id, label: name })),
];
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
? [
{ value: 'all', label: 'All Statuses' },
{ value: 'client_approved', label: 'Approved' },
{ value: 'invoiced', label: 'Invoiced' },
{ value: 'paid', label: 'Paid' },
]
: [
{ value: 'all', label: 'All Statuses' },
{ value: 'not_started', label: 'Not Started' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'client_review', label: 'In Review' },
];
const sortedRows = sort(tabRows, (row, key) => {
if (key === 'title') return row.title || '';
if (key === 'project') return projects.find(p => p.id === row.projectId)?.name || '';
if (key === 'company') return projects.find(p => p.id === row.projectId)?.company?.name || '';
if (key === 'serviceType') return row.serviceType || '';
if (key === 'revision') return row.version ?? 0;
if (key === 'deadline') return row.deadline || '';
if (key === 'status') return TASK_STATUS_SORT_RANK[row.status] ?? 99;
if (key === 'submitted_at') return row.submittedAt || 0;
if (key === 'assigned') return row.assignedName || '';
if (key === 'priority') return row.isHot ? 0 : 1;
return '';
});
const projectOptions = useMemo(() => {
if (!isExternal) return [];
return [...new Map(
allRows.map(r => projects.find(p => p.id === r.projectId)).filter(Boolean).map(p => [p.id, p])
).values()];
}, [isExternal, allRows, projects]); // eslint-disable-line react-hooks/exhaustive-deps
const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId);
return (
<tr key={row.rowKey}>
<td style={{ ...TASK_TABLE_TD_BASE }}>
<StatusBadge status={row.status} />
</td>
<td className="tcol-rev" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{`R${String(row.version).padStart(2, '0')}`}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
</td>
<td className="tcol-project" style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{(() => {
if (row.assignedName && row.assignedTo) {
const portrait = <ProfileAvatar name={row.assignedName} avatarUrl={row.assigneeAvatar} size={26} fontSize={10} />;
return <Link to={`/profile/${row.assignedTo}`} style={{ display: 'inline-flex' }}>{portrait}</Link>;
}
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()}
</td>
<td className="tcol-type" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{row.serviceType}
</td>
<td className="tcol-due" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{fmtShortDate(row.deadline, 'Not specified')}
</td>
{showCompanyCol && (
<td className="tcol-company" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
{project?.company?.name || '—'}
</td>
)}
</tr>
);
};
if (loading) return <Layout><PageLoader /></Layout>;
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
const filteredProjectsShell = filterCompany ? projects.filter(p => p.company_id === filterCompany) : projects;
return (
<Layout>
<TasksPageShell
statsTasks={filteredStatTasks}
projects={filteredProjectsShell}
onAddProject={(isTeam || isClient) ? () => {
setAddProjectForm(f => ({ ...f, companyId: !isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : f.companyId }));
setShowAddProjectForm(true); setAddProjectError('');
} : null}
>
{/* Add project modal */}
{showAddProjectForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
</div>
<form onSubmit={handleAddProject}>
<div className="form-group">
<label style={TASK_MODAL_LABEL_STYLE}>Company *</label>
<select value={addProjectForm.companyId} onChange={e => setAddProjectForm(f => ({ ...f, companyId: e.target.value }))} required style={TASK_MODAL_INPUT_STYLE}>
<option value="">Select company...</option>
{filterableCompanies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
<div className="form-group">
<label style={TASK_MODAL_LABEL_STYLE}>Project Name *</label>
<input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
</div>
{addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>}
<div className="modal-action-row" style={{ marginTop: 20 }}>
<button type="submit" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} disabled={addProjectSaving}>{addProjectSaving ? 'Saving...' : 'Save'}</button>
<button type="button" className="btn btn-outline" style={TASK_MODAL_BUTTON_STYLE} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>Cancel</button>
</div>
</form>
</div>
</div>
)}
{/* Add task modal */}
{showAddForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
</div>
<RequestForm
key={addFormKey}
companies={isTeam ? companies : filterableCompanies}
initialCompanyId={!isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : ''}
currentUser={currentUser}
showRequester={isTeam}
onSubmit={isTeam ? handleAddRequest : handleClientRequest}
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
saving={addSaving}
error={addError}
submitLabel="Save"
/>
</div>
</div>
)}
{/* Controls bar: tabs left, filters + actions right */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, rowGap: 8, marginBottom: 10, flexShrink: 0, flexWrap: 'wrap' }}>
{tabs.map(t => (
<button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label}
{t.id !== 'all' && tabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
{tabCounts[t.id]}
</span>
)}
</button>
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
{(isTeam || isClient) && (
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
)}
</div>
</div>
{/* Task table */}
<div className="tasks-table-card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden tasks-table-scroll" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '11%' }} />
<col className="tcol-rev" style={{ width: '7%' }} />
<col style={{ width: '24%' }} />
<col className="tcol-project" style={{ width: '15%' }} />
<col style={{ width: '9%' }} />
<col className="tcol-type" style={{ width: '12%' }} />
<col className="tcol-due" style={{ width: '10%' }} />
{showCompanyCol && <col className="tcol-company" style={{ width: '12%' }} />}
</colgroup>
<thead>
<tr>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Status
<span style={{ marginLeft: 4, opacity: sortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
</span>
</th>
<th className="tcol-rev" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>
R#
<span style={{ marginLeft: 4, opacity: sortKey === 'revision' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
<th className="tcol-project" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('project')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Client
<span style={{ marginLeft: 4, opacity: sortKey === 'project' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterProject} onChange={setFilterProject} options={PROJECT_FILTER_OPTIONS} />
</span>
</th>
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Assigned
<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
</span>
</th>
<th className="tcol-type" style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Task Type
<span style={{ marginLeft: 4, opacity: sortKey === 'serviceType' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="deadline" className="tcol-due" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
{showCompanyCol && (
<th className="tcol-company" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Company
<span style={{ marginLeft: 4, opacity: sortKey === 'company' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'company' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterCompany} onChange={(v) => { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} />
</span>
</th>
)}
</tr>
</thead>
<tbody>
{sortedRows.length === 0 ? (
<tr><td colSpan={showCompanyCol ? 8 : 7} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
) : sortedRows.map(renderRow)}
</tbody>
</table>
</div>
</div>
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
</TasksPageShell>
{uploadProgress.active && (
<div style={{ ...popupOverlayStyle, zIndex: 1200 }}>
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Uploading</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{uploadProgress.label}</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${uploadProgress.percent}%`, transition: 'width 200ms ease' }} />
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{uploadProgress.percent}%</div>
</div>
</div>
)}
</Layout>
);
}
-177
View File
@@ -1,177 +0,0 @@
import { useState, useEffect } from 'react';
import { Link } 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 { withTimeout } from '../../lib/withTimeout';
function TaskRow({ task, project }) {
return (
<Link
to={`/my-requests/${task.id}`}
className="interactive-row"
style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '10px 14px', borderBottom: '1px solid var(--border)',
textDecoration: 'none', cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{task.title}</span>
<StatusBadge status={task.status} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{project?.name || '—'}</span>
</Link>
);
}
function TaskColumn({ title, tasks, projects, emptyMessage }) {
return (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ padding: '12px 14px', borderBottom: tasks.length > 0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)' }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{title}</span>
{tasks.length > 0 && (
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
{tasks.length}
</span>
)}
</div>
{tasks.length === 0 ? (
<div style={{ padding: '14px', fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
) : (
tasks.map(task => (
<TaskRow key={task.id} task={task} project={projects.find(p => p.id === task.project_id)} />
))
)}
</div>
);
}
export default function ClientDashboard() {
const { currentUser } = useAuth();
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
const [allTasks, setAllTasks] = useState([]);
const [allProjects, setAllProjects] = useState([]);
const [allInvoices, setAllInvoices] = useState([]);
const [loading, setLoading] = useState(hasCompany);
useEffect(() => {
if (!hasCompany) { setLoading(false); return; }
async function load() {
try {
const [{ data: activeTasks }, { data: invoices }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, project_id').in('status', ['client_review', 'in_progress', 'on_hold', 'not_started']).order('submitted_at', { ascending: false }),
supabase.from('invoices').select('total, status, company_id').eq('status', 'sent'),
]), 12000, 'Client dashboard load');
const tasks = activeTasks || [];
setAllTasks(tasks);
setAllInvoices(invoices || []);
if (tasks.length > 0) {
const projectIds = [...new Set(tasks.map(t => t.project_id).filter(Boolean))];
const { data: proj } = await supabase.from('projects').select('id, name, company_id').in('id', projectIds);
setAllProjects(proj || []);
}
} catch (error) {
console.error('ClientDashboard load failed:', error);
} finally {
setLoading(false);
}
}
load();
}, [hasCompany]);
const filterByCompany = (tasks) => {
if (companies.length <= 1 || !activeCompanyId) return tasks;
return tasks.filter(t => {
const proj = allProjects.find(p => p.id === t.project_id);
return proj?.company_id === activeCompanyId;
});
};
const visibleTasks = filterByCompany(allTasks);
const visibleProjects = companies.length <= 1
? allProjects
: allProjects.filter(p => p.company_id === activeCompanyId);
const visibleInvoices = companies.length <= 1 || !activeCompanyId
? allInvoices
: allInvoices.filter(i => i.company_id === activeCompanyId);
const reviewTasks = visibleTasks.filter(t => t.status === 'client_review');
const inProgressTasks = visibleTasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status));
const outstandingInvoices = visibleInvoices.reduce((sum, inv) => sum + Number(inv.total || 0), 0);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
<div className="page-subtitle">Track active work and the items that need your attention.</div>
</div>
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
</div>
<div className="stats-grid">
<div className="stat-card stat-card-highlight">
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
<div className="stat-label">Awaiting Review</div>
</div>
<div className="stat-card">
<div className="stat-value">{inProgressTasks.filter(t => ['in_progress', 'on_hold'].includes(t.status)).length}</div>
<div className="stat-label">In Progress</div>
</div>
<div className="stat-card">
<div className="stat-value">{inProgressTasks.filter(t => t.status === 'not_started').length}</div>
<div className="stat-label">Not Started</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ fontSize: 22 }}>${outstandingInvoices.toFixed(2)}</div>
<div className="stat-label">Outstanding Invoices</div>
</div>
</div>
{companies.length > 1 && (
<div style={{ marginTop: 20, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
{companies.map((company, index) => (
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
<button
type="button"
onClick={() => setActiveCompanyId(company.id)}
style={{
background: 'none', border: 'none', padding: 0, margin: 0,
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
}}
>
{company.name}
</button>
</span>
))}
</div>
)}
<div className="grid-2" style={{ marginTop: 16 }}>
<TaskColumn
title="Awaiting Your Review"
tasks={reviewTasks}
projects={visibleProjects}
emptyMessage="No items need your review."
/>
<TaskColumn
title="In Progress"
tasks={inProgressTasks}
projects={visibleProjects}
emptyMessage="No items currently in progress."
/>
</div>
</Layout>
);
}
+322
View File
@@ -0,0 +1,322 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
import InvoicePopupTable from '../../components/InvoicePopupTable';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const invoiceStatusLabel = (status) => {
if (status === 'sent') return 'Invoiced';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
};
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div>
</div>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
{sub ? <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div> : null}
</div>
);
}
function ClientFinanceTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null;
return (
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
))}
</div>
);
}
function ClientInvoiceModal({ invoice, onClose }) {
const navigate = useNavigate();
const [downloading, setDownloading] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
if (!invoice) return null;
const items = invoice.items || [];
const company = invoice.company || null;
const isOverdue = invoice.status !== 'paid' && invoice.due_date && new Date(invoice.due_date) < new Date();
const total = Number(invoice.total || 0);
const handleDownloadInvoice = async () => {
if (downloading) return;
setDownloading('invoice');
try {
await generateInvoicePDF(invoice, company, items);
} finally {
setDownloading('');
}
};
const handleDownloadReceipt = async () => {
if (downloading) return;
setDownloading('receipt');
try {
await generateReceiptPDF(invoice, company, items);
} finally {
setDownloading('');
}
};
const openPay = () => {
onClose?.();
navigate(`/pay/${encodeURIComponent(invoice.invoice_number)}`);
};
const baseItems = [...items].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(baseItems.map(item => ({
...item,
_inferredType: item.submission_id
? { label: 'Revision', badgeClass: 'badge-client_revision' }
: { label: 'New', badgeClass: 'badge-initial' },
})), (item, key) => {
if (key === 'type') return item._inferredType?.label || '';
if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0);
if (key === 'line_total') return Number(item.quantity || 0) * Number(item.unit_price || 0);
return '';
});
const F = POPUP_FIELD_LABEL;
return (
<InvoiceDetailPopup
title={invoice.invoice_number}
subtitle={invoice.bill_to || company?.name}
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
onClose={onClose}
blockClose={Boolean(downloading)}
metaContent={<>
<div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Status</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></div></div>
<div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div>
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
{company?.id && <div><div style={F}>Bill To</div><div style={{ fontSize: 13 }}><Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>{company.name}</Link></div></div>}
</>}
footerActions={<>
{invoice.status === 'sent' && <button className="btn btn-outline" onClick={openPay}>Pay Invoice</button>}
<LoadingButton className="btn btn-outline" loading={downloading === 'invoice'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadInvoice}>Download Invoice</LoadingButton>
{invoice.status === 'paid' && <LoadingButton className="btn btn-outline" loading={downloading === 'receipt'} disabled={Boolean(downloading)} loadingText="Generating…" onClick={handleDownloadReceipt}>Download Receipt</LoadingButton>}
<button className="btn btn-outline" onClick={onClose}>Close</button>
</>}
>
<InvoicePopupTable sortKey={sortKey} sortDir={sortDir} onSort={toggle} items={tableItems} notes={invoice.notes} />
</InvoiceDetailPopup>
);
}
export default function MyInvoices() {
const { currentUser } = useAuth();
const companyIds = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
.map(company => company?.id)
.filter(Boolean);
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
const [activeInvoice, setActiveInvoice] = useState(null);
const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear()));
const [companyFilter, setCompanyFilter] = useState('all');
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const filterMenuRef = useRef(null);
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date', 'desc');
useEffect(() => {
async function load() {
if (companyIds.length === 0) {
setInvoices([]);
setLoading(false);
return;
}
const { data } = await supabase
.from('invoices')
.select('*, company:companies(id, name), items:invoice_items(*)')
.in('company_id', companyIds)
.order('created_at', { ascending: false });
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
setLoading(false);
}
load();
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!filterMenuOpen) return;
function onDocClick(e) {
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target)) setFilterMenuOpen(false);
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [filterMenuOpen]);
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const chartYear = Number(yearFilter) || new Date().getFullYear();
const filteredInvoices = invoices.filter(inv => {
if (yearFilter !== 'all' && inv.invoice_date?.slice(0, 4) !== yearFilter) return false;
if (companyFilter !== 'all' && inv.company_id !== companyFilter) return false;
return true;
});
const chartData = useMemo(() => MONTHS.map((month, mi) => {
const paidAmt = filteredInvoices.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
const outAmt = filteredInvoices.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
}), [filteredInvoices, chartYear]);
const outstanding = filteredInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paid = filteredInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const overdueCount = filteredInvoices.filter(inv => inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date()).length;
const paidCount = filteredInvoices.filter(i => i.status === 'paid').length;
const sortedInvoices = sort(filteredInvoices, (inv, key) => {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
if (key === 'total') return Number(inv.total || 0);
if (key === 'company') return inv.company?.name || '';
return inv[key] || '';
});
return (
<Layout loading={loading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
</div>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--positive)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--positive)" stopOpacity={0}/></linearGradient>
<linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--info)" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
<Tooltip content={<ClientFinanceTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Paid" stroke="var(--positive)" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="var(--info)" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
<div style={{ marginLeft: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }} ref={filterMenuRef}>
<button className="btn btn-outline" onClick={() => setFilterMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter invoices">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
</button>
{filterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{invoiceYears.map(year => (
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
))}
{availableCompanyOptions.length > 1 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{availableCompanyOptions.map(company => (
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
))}
</>
)}
</div>
)}
</div>
</div>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{filteredInvoices.length === 0 ? (
<div className="card-empty-center">No invoices</div>
) : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '20%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
<SortTh col="invoice_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Issued</SortTh>
<SortTh col="due_date" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Due</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
</tr>
</thead>
<tbody>
{sortedInvoices.map(inv => {
const isOverdue = inv.status !== 'paid' && inv.due_date && new Date(inv.due_date) < new Date();
return (
<tr key={inv.id}>
<td style={{ padding: '5px 0' }}>
<button type="button" className="table-link" onClick={() => setActiveInvoice(inv)}>{inv.invoice_number}</button>
</td>
<td style={{ padding: '5px 0', fontSize: 13 }}>
{inv.company?.id ? (
<Link to={`/company/${inv.company.id}`} className="table-link">
{inv.company?.name || inv.bill_to || '—'}
</Link>
) : (
inv.company?.name || inv.bill_to || '—'
)}
</td>
<td style={{ padding: '5px 0', fontSize: 12, color: 'var(--text-muted)' }}>{inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
<td style={{ padding: '5px 0', fontSize: 12, color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
<td style={{ padding: '5px 0' }}>
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
</td>
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'sent' ? 'var(--accent)' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
<ClientInvoiceModal invoice={activeInvoice} onClose={() => setActiveInvoice(null)} />
</Layout>
);
}
-194
View File
@@ -1,194 +0,0 @@
import { useState, useEffect } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
export default function MyCompany() {
const { currentUser } = useAuth();
const companies = currentUser?.companies || [];
const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
const company = companies.find(c => c.id === selectedId) || companies[0] || null;
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(!!company?.id);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!company?.id) return;
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
setEditing(false);
setLoading(true);
async function load() {
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
]);
const memberMap = new Map();
(primaryMembers || []).forEach(m => memberMap.set(m.id, m));
(memberRows || []).forEach(row => {
if (row.profile) memberMap.set(row.profile.id, row.profile);
});
setMembers([...memberMap.values()]);
setLoading(false);
}
load();
}, [company?.id]);
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
const { error } = await supabase.from('companies').update({
name: form.name.trim(),
phone: form.phone.trim(),
address: form.address.trim(),
}).eq('id', company.id);
setSaving(false);
if (error) { alert('Failed to save. Please try again.'); return; }
setEditing(false);
};
if (!company) return (
<Layout>
<div className="page-header"><div className="page-title">My Company</div></div>
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
</Layout>
);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
const companyDetails = [
{ label: 'Company Name', value: form.name || company.name || '—' },
{ label: 'Phone', value: company.phone || '—' },
{ label: 'Address', value: company.address || '—' },
{ label: 'Members', value: String(members.length) },
];
return (
<Layout>
<div className="page-header">
<div>
{companies.length > 1 ? (
<div style={{ marginBottom: 4 }}>
<select
value={selectedId}
onChange={e => setSelectedId(e.target.value)}
style={{
fontSize: 22, fontWeight: 700, background: 'var(--card-bg)',
border: '1px solid var(--border)', borderRadius: 6,
color: 'var(--text-primary)', cursor: 'pointer',
padding: '4px 8px', fontFamily: 'inherit',
}}
>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
) : (
<div className="page-title">{form.name || company.name}</div>
)}
<div className="page-subtitle">
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
</div>
</div>
{!editing && (
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
)}
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
{companyDetails.map(detail => (
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
<div className="stat-label">{detail.label}</div>
</div>
))}
</div>
{editing && (
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
<div className="card-title">Edit Company Info</div>
<form onSubmit={handleSave}>
<div className="form-group">
<label>Company Name *</label>
<input
type="text"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
required
/>
</div>
<div className="grid-2">
<div className="form-group">
<label>Phone</label>
<input
type="text"
placeholder="+1 (555) 000-0000"
value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
/>
</div>
<div className="form-group">
<label>Address</label>
<input
type="text"
placeholder="123 Main St, City, State"
value={form.address}
onChange={e => setForm(f => ({ ...f, address: e.target.value }))}
/>
</div>
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button type="button" className="btn btn-outline" onClick={() => {
setEditing(false);
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
}}>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card">
<div className="card-title">People</div>
{members.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{members.map((member, i) => (
<div
key={member.id}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 0',
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<div style={{
width: 36, height: 36, borderRadius: 6, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, color: '#111', flexShrink: 0,
}}>
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
{member.name}
{member.id === currentUser.id && (
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
</div>
</div>
))}
</div>
)}
</div>
</Layout>
);
}
-131
View File
@@ -1,131 +0,0 @@
import { useState, useEffect } from 'react';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { generateInvoicePDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
export default function MyInvoices() {
const { currentUser } = useAuth();
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
const [invoices, setInvoices] = useState([]);
const [loading, setLoading] = useState(true);
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
useEffect(() => {
async function load() {
const { data } = await supabase
.from('invoices')
.select('*, company:companies(name), items:invoice_items(*)')
.order('created_at', { ascending: false });
setInvoices((data || []).filter(inv => inv.status !== 'draft'));
setLoading(false);
}
load();
}, []);
const handleDownload = async (invoice) => {
if (generatingInvoiceId) return;
setGeneratingInvoiceId(invoice.id);
try {
await generateInvoicePDF(invoice, invoice.company, invoice.items || []);
} finally {
setGeneratingInvoiceId('');
}
};
const visible = companies.length > 1 && activeCompanyId
? invoices.filter(inv => inv.company_id === activeCompanyId)
: invoices;
const outstanding = visible.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0);
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Invoices</div>
<div className="page-subtitle">{visible.length} invoice{visible.length !== 1 ? 's' : ''}</div>
</div>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card stat-card-highlight">
<div className="stat-value" style={{ fontSize: 22 }}>${outstanding.toFixed(2)}</div>
<div className="stat-label">Outstanding</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ fontSize: 22 }}>${paid.toFixed(2)}</div>
<div className="stat-label">Paid</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ fontSize: 22, color: overdueCount > 0 ? 'var(--danger)' : undefined }}>{overdueCount}</div>
<div className="stat-label">Overdue</div>
</div>
</div>
{companies.length > 1 && (
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
{companies.map((company, index) => (
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
<button
type="button"
onClick={() => setActiveCompanyId(company.id)}
style={{
background: 'none', border: 'none', padding: 0, margin: 0,
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
}}
>
{company.name}
</button>
</span>
))}
</div>
)}
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : visible.length === 0 ? (
<div className="empty-state">
<h3>No invoices yet</h3>
<p>Your invoices will appear here once they are sent.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{visible.map(inv => {
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
return (
<div key={inv.id} className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg)', padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', marginBottom: 4 }}>
{inv.invoice_number}
<span style={{ fontWeight: 400, fontSize: 12, color: 'var(--text-muted)', marginLeft: 10 }}>
Issued {new Date(inv.invoice_date).toLocaleDateString()}
{inv.items?.length > 0 && ` · ${inv.items.length} item${inv.items.length !== 1 ? 's' : ''}`}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span>
{isOverdue && <span style={{ fontSize: 12, color: 'var(--danger)' }}>Overdue</span>}
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</span>
</div>
</div>
<LoadingButton className="btn btn-outline btn-sm" loading={generatingInvoiceId === inv.id} disabled={Boolean(generatingInvoiceId)} loadingText="Generating..." onClick={() => handleDownload(inv)} style={{ flexShrink: 0 }}>
Download PDF
</LoadingButton>
</div>
);
})}
</div>
)}
</Layout>
);
}
-201
View File
@@ -1,201 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } 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 { withTimeout } from '../../lib/withTimeout';
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
export default function MyProjectDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const [project, setProject] = useState(null);
const [tasks, setTasks] = useState([]);
const [submissions, setSubmissions] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
useEffect(() => {
async function load() {
try {
const { data: p } = await withTimeout(
supabase.from('projects').select('*').eq('id', id).single(),
12000,
'Project detail load'
);
if (!p) return;
setProject(p);
const { data: t } = await withTimeout(
supabase
.from('tasks')
.select('*')
.eq('project_id', id)
.order('submitted_at', { ascending: false }),
12000,
'Project tasks load'
);
setTasks(t || []);
if (t && t.length > 0) {
const { data: subs } = await withTimeout(
supabase
.from('submissions')
.select('id, task_id, submitted_by, submitted_by_name, version_number, type')
.in('task_id', t.map(task => task.id))
.order('version_number'),
12000,
'Project submissions load'
);
setSubmissions(subs || []);
} else {
setSubmissions([]);
}
} catch (error) {
console.error('MyProjectDetail load failed:', error);
} finally {
setLoading(false);
}
}
load();
}, [id]);
const handleSaveName = async (e) => {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
if (!error) {
setProject(p => ({ ...p, name: nameVal.trim() }));
setEditingName(false);
} else {
alert('Failed to save name.');
}
setSavingName(false);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!project) return <Layout><p>Project not found.</p></Layout>;
const filteredTasks = filter === 'mine'
? tasks.filter(task => {
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
return initial?.submitted_by === currentUser.id;
})
: tasks;
return (
<Layout>
<button className="back-link" onClick={() => navigate('/my-projects')}> Back to Projects</button>
<div className="page-header">
<div>
{editingName ? (
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="text"
value={nameVal}
onChange={e => setNameVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{project.name}</div>
<button
className="btn btn-outline btn-sm"
onClick={() => { setNameVal(project.name); setEditingName(true); }}
>
Edit
</button>
</div>
)}
<div className="page-subtitle">
{tasks.length} request{tasks.length !== 1 ? 's' : ''} · Started {new Date(project.created_at).toLocaleDateString()}
</div>
</div>
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">
+ Add Request
</Link>
</div>
<div className="card page-toolbar">
<div className="page-toolbar-grid">
<div className="page-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
<div className="page-toolbar-filters">
<button
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setFilter('all')}
>
All Requests
</button>
<button
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setFilter('mine')}
>
Mine Only
</button>
</div>
</div>
</div>
</div>
{filteredTasks.length === 0 ? (
<div className="empty-state">
<h3>{filter === 'mine' ? "You haven't submitted any requests to this project" : 'No requests yet'}</h3>
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>
Add Request
</Link>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredTasks.map(task => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
const latestSub = taskSubs[taskSubs.length - 1];
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
const isMine = initialSub?.submitted_by === currentUser.id;
return (
<Link key={task.id} to={`/my-requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
<div className="request-card-header">
<div>
<div className="request-card-title">
{task.title}{' '}
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>
{rLabel(task.current_version)}
</span>
{isMine && (
<span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 600 }}>
Mine
</span>
)}
</div>
<div className="request-card-meta" style={{ marginTop: 4 }}>
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
</div>
</div>
<StatusBadge status={task.status} />
</div>
</Link>
);
})}
</div>
)}
</Layout>
);
}
-243
View File
@@ -1,243 +0,0 @@
import { useState, useEffect } from 'react';
import { Link } 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 { withTimeout } from '../../lib/withTimeout';
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
const [open, setOpen] = useState(false);
const filteredTasks = filter === 'mine'
? tasks.filter(task => {
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
return initial?.submitted_by === currentUserId;
})
: tasks;
if (filter === 'mine' && filteredTasks.length === 0) return null;
return (
<div className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', marginBottom: 8 }}>
{/* Project header — clickable to collapse */}
<button
className="interactive-panel-toggle"
onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '12px 16px',
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
borderBottom: open ? '1px solid var(--border)' : 'none',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Link
to={`/my-projects/${project.id}`}
onClick={e => e.stopPropagation()}
style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', textDecoration: 'none' }}
>
{project.name}
</Link>
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
{filteredTasks.length} request{filteredTasks.length !== 1 ? 's' : ''}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<StatusBadge status={project.status} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
</div>
</button>
{open && (
<div style={{ background: 'var(--card-bg)' }}>
{filteredTasks.length === 0 ? (
<div style={{ padding: '16px', fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
No requests in this project yet.
</div>
) : (
filteredTasks.map((task, i) => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
const latestSub = taskSubs[taskSubs.length - 1];
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
const isMine = initialSub?.submitted_by === currentUserId;
return (
<Link
key={task.id}
to={`/my-requests/${task.id}`}
className="interactive-row"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none',
gap: 8, textDecoration: 'none', cursor: 'pointer',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
{task.title}
</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{rLabel(task.current_version)}
</span>
{isMine && (
<span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 600 }}>
Mine
</span>
)}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
</div>
</div>
<StatusBadge status={task.status} />
</Link>
);
})
)}
</div>
)}
</div>
);
}
export default function MyProjects() {
const { currentUser } = useAuth();
const [projects, setProjects] = useState([]);
const [tasks, setTasks] = useState([]);
const [submissions, setSubmissions] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all'); // 'all' | 'mine'
const companies = (currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
const [activeCompanyId, setActiveCompanyId] = useState(() => companies[0]?.id || null);
useEffect(() => {
async function load() {
try {
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
supabase.from('projects').select('*').order('created_at', { ascending: false }),
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
]), 12000, 'Projects load');
setProjects(p || []);
setTasks(t || []);
if (t && t.length > 0) {
const { data: subs } = await withTimeout(
supabase
.from('submissions')
.select('id, task_id, submitted_by, submitted_by_name, version_number, type')
.in('task_id', t.map(task => task.id))
.order('version_number'),
12000,
'Project submissions load'
);
setSubmissions(subs || []);
} else {
setSubmissions([]);
}
} catch (error) {
console.error('MyProjects load failed:', error);
setProjects([]);
setTasks([]);
setSubmissions([]);
} finally {
setLoading(false);
}
}
load();
}, []);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Projects</div>
<div className="page-subtitle">All work for your company.</div>
</div>
<Link to="/new-project" className="btn btn-primary">+ New Project</Link>
</div>
<div className="card page-toolbar">
<div className="page-toolbar-grid">
<div className="page-toolbar-section">
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
<div className="page-toolbar-filters">
<button
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setFilter('all')}
>
All Requests
</button>
<button
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setFilter('mine')}
>
Mine Only
</button>
</div>
</div>
</div>
</div>
{companies.length > 1 && (
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
{companies.map((company, index) => (
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
<button
type="button"
onClick={() => setActiveCompanyId(company.id)}
style={{
background: 'none', border: 'none', padding: 0, margin: 0,
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
}}
>
{company.name}
</button>
</span>
))}
</div>
)}
{projects.length === 0 ? (
<div className="empty-state">
<h3>No projects yet</h3>
<p>Submit a request and a project will be created automatically.</p>
<Link to="/new-project" className="btn btn-primary" style={{ marginTop: 16 }}>+ New Project</Link>
</div>
) : (() => {
const visibleProjects = companies.length > 1
? projects.filter(p => p.company_id === activeCompanyId)
: projects;
if (visibleProjects.length === 0) return (
<div className="empty-state">
<h3>No projects for this company</h3>
</div>
);
return visibleProjects.map(project => (
<ProjectGroup
key={project.id}
project={project}
tasks={tasks.filter(t => t.project_id === project.id)}
submissions={submissions}
currentUserId={currentUser.id}
filter={filter}
/>
));
})()}
</Layout>
);
}
-228
View File
@@ -1,228 +0,0 @@
import { useState, useEffect } from 'react';
import { Link } 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 { withTimeout } from '../../lib/withTimeout';
export default function MyRequests() {
const { currentUser } = useAuth();
const [projects, setProjects] = useState([]);
const [tasks, setTasks] = useState([]);
const [submissions, setSubmissions] = useState([]);
const [invoices, setInvoices] = useState([]);
const [invoiceItems, setInvoiceItems] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('active');
useEffect(() => {
async function load() {
try {
const { data: mySubs } = await withTimeout(
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'),
10000, 'My submissions'
);
if (!mySubs || mySubs.length === 0) return;
const myTaskIds = mySubs.map(s => s.task_id);
const [{ data: t }, { data: allSubs }, { data: inv }, { data: itemRows }] = await withTimeout(
Promise.all([
supabase.from('tasks').select('*, project:projects(id, name, created_at, status)').in('id', myTaskIds),
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').in('task_id', myTaskIds).order('version_number'),
supabase.from('invoices').select('id, status'),
supabase.from('invoice_items').select('task_id, invoice_id').in('task_id', myTaskIds),
]),
12000, 'My requests data'
);
const tasks = t || [];
setTasks(tasks);
setSubmissions(allSubs || []);
setInvoices(inv || []);
setInvoiceItems(itemRows || []);
const projectMap = {};
tasks.forEach(task => {
const p = task.project;
if (p && !projectMap[p.id]) projectMap[p.id] = { ...p, id: p.id };
});
setProjects(Object.values(projectMap));
} catch (err) {
console.error('MyRequests load failed:', err);
} finally {
setLoading(false);
}
}
load();
}, [currentUser.id]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
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 activeCount = tasks.filter(task => task.status !== 'client_approved').length;
const reviewCount = tasks.filter(task => task.status === 'client_review').length;
const completedCount = tasks.filter(task => task.status === 'client_approved' && !isFullyClosedTask(task)).length;
const fullyClosedCount = tasks.filter(task => isFullyClosedTask(task)).length;
const activeTasks = tasks.filter(task => task.status !== 'client_review' && task.status !== 'client_approved');
const reviewTasks = tasks.filter(task => task.status === 'client_review');
const completedTasks = tasks.filter(task => task.status === 'client_approved' && !isFullyClosedTask(task));
const closedTasks = tasks.filter(task => isFullyClosedTask(task));
const renderTaskRow = (task, showClosedStatus = false, isLast = false) => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
const latestSub = taskSubs[taskSubs.length - 1];
const hasRevision = taskSubs.length > 1 && latestSub?.submitted_by_name !== initialSub?.submitted_by_name;
return (
<Link
key={task.id}
to={`/my-requests/${task.id}`}
className="interactive-row"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: isLast ? 'none' : '1px solid var(--border)', textDecoration: 'none' }}
>
<div>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
{task.title}{' '}
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>
{'R' + String(task.current_version || 0).padStart(2, '0')}
</span>
</span>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
{task.submitted_at && `${new Date(task.submitted_at).toLocaleDateString()} · `}Submitted by {initialSub?.submitted_by_name || 'Unknown'}
{hasRevision && ` · Last updated by ${latestSub.submitted_by_name}`}
</div>
</div>
<div className="flex items-center gap-3">
{showClosedStatus ? (
<span className="badge badge-client_approved">Paid & Closed</span>
) : (
<StatusBadge status={task.status} />
)}
</div>
</Link>
);
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">My Requests</div>
<div className="page-subtitle">Requests you have submitted.</div>
</div>
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
</div>
<div className="stats-grid" style={{ marginBottom: 24 }}>
<div className="stat-card stat-card-highlight">
<div className="stat-value">{projects.length}</div>
<div className="stat-label">Projects</div>
</div>
<div className="stat-card">
<div className="stat-value">{activeCount}</div>
<div className="stat-label">Active Requests</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: reviewCount > 0 ? 'var(--accent)' : undefined }}>{reviewCount}</div>
<div className="stat-label">Awaiting Review</div>
</div>
<div className="stat-card">
<div className="stat-value">{completedCount}</div>
<div className="stat-label">Completed</div>
</div>
<div className="stat-card">
<div className="stat-value">{fullyClosedCount}</div>
<div className="stat-label">Fully Closed</div>
</div>
</div>
{projects.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No requests yet</h3>
<p>Submit a new request to get started.</p>
<Link to="/new-request" className="btn btn-primary" style={{ marginTop: 16 }}>Submit Request</Link>
</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: activeTasks.length },
{ id: 'client-review', label: 'Client Review', count: reviewTasks.length },
{ id: 'completed', label: 'Completed', count: completedTasks.length },
{ id: 'closed', label: 'Fully Closed', count: closedTasks.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>
</div>
{[
{ id: 'active', emptyTitle: 'No active requests', tasks: activeTasks, closed: false },
{ id: 'client-review', emptyTitle: 'No requests in review', tasks: reviewTasks, closed: false },
{ id: 'completed', emptyTitle: 'No completed requests', tasks: completedTasks, closed: false },
{ id: 'closed', emptyTitle: 'No fully closed requests', tasks: closedTasks, closed: true },
].filter(section => section.id === activeTab).map(section => {
const sectionProjects = projects.filter(project => section.tasks.some(task => task.project?.id === project.id));
if (sectionProjects.length === 0) {
return (
<div key={section.id} className="empty-state">
<h3>{section.emptyTitle}</h3>
{section.closed && <p>Requests move here once they are completed, invoiced, and paid.</p>}
</div>
);
}
return (
<div key={section.id}>
{sectionProjects.map(project => {
const projectTasks = section.tasks.filter(task => task.project?.id === project.id);
return (
<div key={project.id} className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
<div className="interactive-panel-toggle" style={{ cursor: 'default', padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
<div className="request-card-title">{project.name}</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{projectTasks.map((task, index) => renderTaskRow(task, section.closed, index === projectTasks.length - 1))}
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</Layout>
);
}
-109
View File
@@ -1,109 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
export default function NewProject() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const companyOptions = currentUser.companies?.length
? currentUser.companies
: (currentUser.company ? [currentUser.company] : []);
const [selectedCompanyId, setSelectedCompanyId] = useState(companyOptions[0]?.id || '');
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
if (!companyOptions.length) {
return (
<Layout>
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Account Not Yet Active</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
</p>
</div>
</Layout>
);
}
const handleSubmit = async (e) => {
e.preventDefault();
if (saving) return;
const trimmedName = name.trim();
if (!trimmedName) return;
setSaving(true);
setError(null);
const { data, error: insertError } = await supabase
.from('projects')
.insert({ company_id: selectedCompanyId, name: trimmedName })
.select('id')
.single();
if (insertError) {
setError(insertError.message);
setSaving(false);
return;
}
navigate(`/my-projects/${data.id}`);
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">New Project</div>
<div className="page-subtitle">Create a project to organise your work.</div>
</div>
</div>
<div className="card" style={{ maxWidth: 480 }}>
<form onSubmit={handleSubmit}>
{companyOptions.length > 1 && (
<div className="form-group">
<label>Company *</label>
<select
value={selectedCompanyId}
onChange={e => setSelectedCompanyId(e.target.value)}
required
>
{companyOptions.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. Brand Refresh 2026"
value={name}
onChange={e => setName(e.target.value)}
autoFocus
required
/>
</div>
{error && (
<div className="notification notification-error" style={{ marginBottom: 16 }}>
{error}
</div>
)}
<div className="action-buttons">
<button type="submit" className="btn btn-primary btn-lg" disabled={saving || !name.trim()}>
{saving ? 'Creating...' : 'Create Project'}
</button>
<button type="button" className="btn btn-outline" onClick={() => navigate('/my-projects')}>
Cancel
</button>
</div>
</form>
</div>
</Layout>
);
}

Some files were not shown because too many files have changed in this diff Show More