Compare commits

..

71 Commits

Author SHA1 Message Date
Krao Hasanee 26ed7790b1 Merge branch 'fix/folder-reconcile' 2026-07-21 14:21:29 -04:00
Krao Hasanee 4c78b624f8 feat: add folder reconcile endpoint to backfill missing FileBrowser folders
Folder sync failed silently whenever FileBrowser was unreachable: every
call site swallows the error with console.error, so projects and tasks
were created in the portal with no matching folder on the drive.

Add api/folder-reconcile.js, a secret-protected endpoint that walks
companies/projects/tasks/subcontractors and creates only the folders that
are missing. It never deletes or renames. Folders on disk with no portal
record are reported as orphans for manual review, since they predate the
portal and hold real client work.

Extract shared FileBrowser helpers into api/_lib/filebrowser.js. Note that
this build returns directory listings under a `folders` key rather than
`items`; reading the wrong key yields an empty list and makes the whole
tree look missing, so listDirectories handles both shapes.

Reconcile lists before writing and creates leaves directly rather than
re-walking each path from the root, which keeps a full run at a few
seconds instead of timing out at the 300s function limit.

Schedule hourly via pg_cron: Vercel Hobby caps crons at once per day.
Requires app.folder_reconcile_secret to be set on the database separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:18:53 -04:00
Krao Hasanee 2cfbbec9dc cleanup: remove dead code, drop unused deps, refactor derived-state effects
Lint went from 84 problems to 0. Build passes.

Dead code:
- TeamInvoices: drop the orphaned Subcontractor PO block (updateSubcontractorPO
  plus send/ready-to-pay/paid/reopen/cancel/delete/download/CPA-export handlers
  and ~25 derived vars). That feature moved to the routed
  TeamCreateSubcontractorPO / TeamSubcontractorPODetail pages in the Layout2
  redesign; these were leftovers. 2100 -> 1880 lines.
- Tasks: drop unrendered project sort/tab/completion logic in TasksPageShell.
- Remove 59 unused vars, imports, and handlers across 12 other files.

Removed wasted queries:
- TeamInvoices ran a nested subcontractor_payments join (profiles, projects,
  companies, PO items, tasks) on every page load into state nothing read.
- TeamInvoices and ExternalMyInvoices each fetched a task-type map on invoice
  open and discarded it; the ExternalMyInvoices one blocked the detail popup.
- RequestForm queried sign_families on mount into unread state.

Dependencies:
- Drop heic2any (zero imports). heic-to is the one in use and already lazy-loads.

Effects and exports:
- RequestForm: remove 4 effects. Sign-row sizing and company-change resets move
  into their change handlers; single-company selection is derived during render
  rather than written back into form state.
- FilterDropdown: measure position on click instead of in an effect, which also
  removes a one-frame stale-position flicker.
- BrandBook: latest-ref pattern so the 900ms address debounce is not reset by
  handler identity.
- ClientMyInvoices: hoist MONTHS to module scope.
- Split non-component exports: POPUP_FIELD_LABEL moves to lib/popupStyles.js;
  resolveProfileAvatar and useLiveClock are internal-only; getGreeting deleted
  as nothing referenced it.

Companies.jsx keeps one scoped eslint-disable with a reason: load() only
setStates after awaiting its queries, so set-state-in-effect is a false positive
there, and load() is shared with the create/delete handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:13:01 -04:00
Krao Hasanee 1f52b6e96c fix: sub billing only applies to work an external sub delivered
Report was flagging every unbilled delivered version as "Not Invoiced"
for sub billing, even when a team member delivered it — team work is
billed to the client directly and is never sub-invoiced. Now traces
each version's actual deliverer and shows N/A when it's a team member,
regardless of who delivered earlier versions on the same task.

Also: SubcontractorInvoiceForm no longer masks fetch errors as "No
completed tasks available to invoice" — the real error now shows in
the Completed Tasks panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:15:04 -04:00
Krao Hasanee 2c1ff61b29 fix: popups no longer close on backdrop click, only via explicit close/cancel
Clicking the dimmed area outside a modal (+Task, +Invoice, expense,
review, etc.) accidentally dismissed it and lost in-progress input.
Every popup already has an explicit Cancel/Close/✕ button, so the
backdrop click handler is removed; also drops the now-dead
onClose/blockClose prop plumbing that only existed to support it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:45:37 -04:00
Krao Hasanee bf70646a54 fix: sub invoice eligibility follows delivery author, not task assignment
Completed Tasks list scoped by task.assigned_to meant a reassigned task
made the original sub's already-delivered versions permanently unbillable
by anyone. Now scoped by deliveries.sent_by directly, so each version
stays billable by whoever actually delivered it, independent of who
currently owns the task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:04:24 -04:00
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
166 changed files with 18380 additions and 11287 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 -63
View File
@@ -1,73 +1,40 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(*)", "Bash(npm run *)",
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)", "Bash(awk 'NR>=547 && /^}/ {print NR\": \"$0; c++; if\\(c>=1\\) exit}' src/pages/Companies.jsx)",
"Bash(vercel --version)", "Bash(sed -n '545,548p' src/pages/Companies.jsx)",
"Bash(vercel --prod)", "Bash(sed -i '' '545,715d' src/pages/Companies.jsx)",
"Bash(supabase --version)", "Bash(sed -n '542,548p' src/pages/Companies.jsx)",
"Bash(brew install:*)", "Bash(sed -n '76,80p' src/pages/team/TeamSubInvoiceDetail.jsx)",
"Read(//usr/local/bin/**)", "Bash(sed -n '147p' src/pages/Profile.jsx)",
"Read(//Users/kraohasanee/.local/bin/**)", "Bash(awk '{print $5, $9}')",
"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(git add *)", "Bash(git add *)",
"Bash(git commit -m ' *)", "Bash(git commit -q -m ' *)",
"Bash(git push *)", "Bash(git push *)",
"Bash(cat .env.local)", "Bash(vercel --prod --yes)",
"Bash(cat .env)", "Bash(sed -n '1,15p' src/pages/external/ExternalMyInvoices.jsx)",
"Bash(vercel env *)", "Bash(sed -n '1,15p' src/pages/client/ClientMyInvoices.jsx)",
"Bash(python3 -m json.tool)", "Bash(git commit -q -m 'fix: restore popupOverlayStyle import in ExternalMyInvoices *)",
"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(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", "mcp__plugin_supabase_supabase__execute_sql",
"mcp__plugin_supabase_supabase__apply_migration", "Bash(sed -n '88,100p' src/pages/Tasks.jsx)",
"Bash(git commit *)", "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", "mcp__plugin_supabase_supabase__list_projects",
"WebFetch(domain:github.com)", "Bash(grep -rni \"task_number\\\\|task #\\\\|task#\\\\|#\\\\${\\\\|taskNumber\\\\|seq\" src/pages/Tasks.jsx src/pages/TaskDetail.jsx src/pages/ProjectDetail.jsx)",
"Skill(supabase:supabase)" "mcp__plugin_supabase_supabase__apply_migration",
"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
dist-ssr dist-ssr
*.local *.local
.env.backfill
# Editor directories and files # Editor directories and files
.vscode/* .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).
+143
View File
@@ -0,0 +1,143 @@
import { createClient } from '@supabase/supabase-js';
export const FB_SOURCE = 'srv';
export const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
export const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
export 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('/')}`;
}
export function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
export function safeName(value, fallback = '') {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
export 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),
};
}
export function getToken(config) {
const token = String(config.token || '').trim();
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
return token;
}
export 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;
}
}
function isAlreadyExistsError(error) {
const message = String(error?.message || '').toLowerCase();
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
}
// Creates a single directory whose parent is already known to exist.
// Much cheaper than ensureDirectory, which re-walks the path from the root.
export async function createDirectory(config, fullPath) {
try {
await fbFetch(config, 'POST', '/api/resources', {
params: { path: normalizePath(fullPath), isDir: 'true' },
});
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
// Runs tasks with bounded concurrency, preserving input order in the result.
export async function pooled(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await worker(items[index], index);
}
});
await Promise.all(runners);
return results;
}
export 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;
}
}
}
// Returns directory names at `path`, or null when the path itself does not exist.
export async function listDirectories(config, path) {
let payload;
try {
payload = await fbFetch(config, 'GET', '/api/resources', { params: { path: normalizePath(path) } });
} catch (error) {
if (error?.status === 404) return null;
throw error;
}
// This FileBrowser returns child directories under `folders`; older builds use a mixed `items` array.
if (Array.isArray(payload?.folders)) return payload.folders.map(folder => folder.name);
const items = payload?.items || [];
return items.filter(item => item.type === 'directory' || item.isDir).map(item => item.name);
}
export 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 },
});
}
-179
View File
@@ -1,179 +0,0 @@
import { createClient } from '@supabase/supabase-js';
const FB_SOURCE = 'files';
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');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
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'),
configured: Boolean(url),
};
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${config.token}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `FileBrowser ${res.status}`);
}
return res;
}
async function mkdir(config, path) {
await fbFetch(config, 'POST', '/api/resources', {
params: { path, isDir: 'true' },
}).catch(() => {});
}
function json(res, status, body) {
return res.status(status).json(body);
}
export default async function handler(req, res) {
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
if (secret) {
const incoming = req.headers['x-webhook-secret'] || '';
if (incoming.trim() !== secret.trim()) return json(res, 401, { error: 'Unauthorized' });
}
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceRoleKey) return json(res, 500, { error: 'Supabase env not configured' });
const admin = createClient(supabaseUrl, serviceRoleKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
const config = getConfig();
if (!config.configured || !config.token) return json(res, 200, { ok: true, skipped: 'FileBrowser not configured' });
// Fetch all submission files with task/project/company context
const { data: rows, error } = await admin
.from('submission_files')
.select(`
id, name, storage_path, size,
submission:submissions!inner(
id, version_number,
task:tasks!inner(
id, title,
project:projects!inner(
id, name,
company:companies!inner(name)
)
)
)
`);
if (error) return json(res, 500, { error: error.message });
// Group by task + version_number, skip groups with no files
const groups = new Map();
for (const row of rows || []) {
const sub = row.submission;
const task = sub?.task;
const project = task?.project;
const company = project?.company;
if (!task || !project || !company) continue;
const key = `${task.id}::${sub.version_number}`;
if (!groups.has(key)) {
groups.set(key, {
companyName: company.name,
projectName: project.name,
taskTitle: task.title,
versionNumber: sub.version_number,
files: [],
});
}
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
}
const results = { processed: 0, skipped: 0, errors: [] };
for (const group of groups.values()) {
if (group.files.length === 0) { results.skipped++; continue; }
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
const companyDir = joinPath(config.clientRoot, safeName(group.companyName));
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
const requestInfoDir = joinPath(taskDir, 'Request Info');
const revDir = joinPath(requestInfoDir, revFolder);
// Ensure all parent dirs exist
await mkdir(config, companyDir);
await mkdir(config, joinPath(companyDir, 'Projects'));
await mkdir(config, projectDir);
await mkdir(config, taskDir);
await mkdir(config, requestInfoDir);
await mkdir(config, revDir);
for (const file of group.files) {
try {
// Get signed URL from Supabase Storage
const { data: signed, error: signedError } = await admin.storage
.from('submissions')
.createSignedUrl(file.storage_path, 60);
if (signedError || !signed?.signedUrl) {
results.errors.push(`signed url failed: ${file.storage_path}`);
continue;
}
// Download file from Supabase Storage
const fileRes = await fetch(signed.signedUrl);
if (!fileRes.ok) {
results.errors.push(`download failed: ${file.name}`);
continue;
}
const fileBuffer = await fileRes.arrayBuffer();
// Upload to FileBrowser
const fbFilePath = joinPath(revDir, file.name);
await fbFetch(config, 'POST', '/api/resources', {
params: { path: fbFilePath, override: 'true' },
headers: { 'Content-Type': 'application/octet-stream' },
body: fileBuffer,
});
results.processed++;
} catch (err) {
results.errors.push(`${file.name}: ${err.message}`);
}
}
}
return json(res, 200, { ok: true, ...results });
}
-145
View File
@@ -1,130 +1,5 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
const FB_SOURCE = 'files';
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');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function basename(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
function parentDir(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
parts.pop();
return `/${parts.join('/')}`;
}
function getFbConfig() {
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'),
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'),
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
};
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${config.token}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
const err = new Error(text || `FileBrowser ${res.status}`);
err.status = res.status;
throw err;
}
const text = await res.text();
try { return text ? JSON.parse(text) : null; } catch { return text; }
}
async function fbExists(config, path) {
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
catch (e) { if (e.status === 404) return false; throw e; }
}
async function fbMkdir(config, path) {
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
}
async function mergeMove(config, fbSrc, fbDstParent) {
const name = basename(fbSrc);
const fbDst = joinPath(fbDstParent, name);
const destExists = await fbExists(config, fbDst);
if (!destExists) {
await fbFetch(config, 'PATCH', '/api/resources', {
params: {},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'move',
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
overwrite: false,
}),
});
} else {
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
const dirs = (data?.folders || []).map(f => f.name);
const files = (data?.files || []).map(f => f.name);
await fbMkdir(config, fbDst);
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
for (const file of files) {
await fbFetch(config, 'PATCH', '/api/resources', {
params: {},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'move',
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
overwrite: true,
}),
}).catch(() => {});
}
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
}
}
async function archiveProject(companyName, projectName) {
const config = getFbConfig();
if (!config.configured || !companyName || !projectName) return;
const co = safeName(companyName);
const proj = safeName(projectName);
// Ensure archive dirs exist
await fbMkdir(config, config.archiveRoot);
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co));
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects'));
// Merge-move project folder into archive
const srcPath = joinPath(config.clientRoot, co, 'Projects', proj);
const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects');
await mergeMove(config, srcPath, dstParentPath);
}
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' }); if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
@@ -152,22 +27,11 @@ export default async function handler(req, res) {
auth: { persistSession: false, autoRefreshToken: false }, auth: { persistSession: false, autoRefreshToken: false },
}); });
// Fetch project + company name before deletion (needed for archive path)
const { data: projRecord } = await admin
.from('projects')
.select('id, name, company:companies(name)')
.eq('id', projectId)
.single();
if (!projRecord) return res.status(404).json({ error: 'Project not found' });
// For clients, confirm they can see this project (RLS gate)
if (profile.role === 'client') { if (profile.role === 'client') {
const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single(); 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' }); if (projErr || !proj) return res.status(404).json({ error: 'Project not found or access denied' });
} }
// Cleanup storage files
const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId); const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId);
const taskIds = (tasks || []).map(t => t.id); const taskIds = (tasks || []).map(t => t.id);
@@ -190,17 +54,8 @@ export default async function handler(req, res) {
} }
} }
// Delete project (DB cascade handles tasks/submissions/etc.)
const { error } = await admin.from('projects').delete().eq('id', projectId); const { error } = await admin.from('projects').delete().eq('id', projectId);
if (error) return res.status(500).json({ error: error.message }); if (error) return res.status(500).json({ error: error.message });
// Archive FileBrowser project folder (server-side, so errors are logged)
try {
await archiveProject(projRecord.company?.name, projRecord.name);
} catch (e) {
console.error('[delete-project] archive failed:', e.message);
// Don't fail — DB delete succeeded
}
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
} }
+2 -140
View File
@@ -1,126 +1,5 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
const FB_SOURCE = 'files';
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');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function basename(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
function getFbConfig() {
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'),
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'),
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
};
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${config.token}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
const err = new Error(text || `FileBrowser ${res.status}`);
err.status = res.status;
throw err;
}
const text = await res.text();
try { return text ? JSON.parse(text) : null; } catch { return text; }
}
async function fbExists(config, path) {
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
catch (e) { if (e.status === 404) return false; throw e; }
}
async function fbMkdir(config, path) {
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
}
async function mergeMove(config, fbSrc, fbDstParent) {
const name = basename(fbSrc);
const fbDst = joinPath(fbDstParent, name);
const destExists = await fbExists(config, fbDst);
if (!destExists) {
await fbFetch(config, 'PATCH', '/api/resources', {
params: {},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'move',
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
overwrite: false,
}),
});
} else {
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
const dirs = (data?.folders || []).map(f => f.name);
const files = (data?.files || []).map(f => f.name);
await fbMkdir(config, fbDst);
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
for (const file of files) {
await fbFetch(config, 'PATCH', '/api/resources', {
params: {},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'move',
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
overwrite: true,
}),
}).catch(() => {});
}
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
}
}
async function archiveTask(companyName, projectName, taskTitle) {
const config = getFbConfig();
if (!config.configured || !companyName || !projectName || !taskTitle) return;
const co = safeName(companyName);
const proj = safeName(projectName);
const task = safeName(taskTitle);
// Ensure archive dirs exist
await fbMkdir(config, config.archiveRoot);
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co));
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects'));
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj));
// Merge-move task folder into archive
const srcPath = joinPath(config.clientRoot, co, 'Projects', proj, task);
const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj);
await mergeMove(config, srcPath, dstParentPath);
}
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' }); if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
@@ -148,16 +27,9 @@ export default async function handler(req, res) {
auth: { persistSession: false, autoRefreshToken: false }, auth: { persistSession: false, autoRefreshToken: false },
}); });
// Fetch task + project + company before deletion using explicit joins const { data: taskRecord } = await admin.from('tasks').select('id').eq('id', taskId).single();
const { data: taskRecord } = await admin.from('tasks').select('id, title, project_id').eq('id', taskId).single();
if (!taskRecord) return res.status(404).json({ error: 'Task not found' }); if (!taskRecord) return res.status(404).json({ error: 'Task not found' });
const { data: projectRecord } = await admin.from('projects').select('id, name, company_id').eq('id', taskRecord.project_id).single();
const { data: companyRecord } = projectRecord?.company_id
? await admin.from('companies').select('id, name').eq('id', projectRecord.company_id).single()
: { data: null };
// Cleanup storage files
const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId); const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId);
const subIds = (subs || []).map(s => s.id); const subIds = (subs || []).map(s => s.id);
@@ -175,18 +47,8 @@ export default async function handler(req, res) {
} }
} }
// Delete task (DB cascade handles submissions/etc.)
const { error } = await admin.from('tasks').delete().eq('id', taskId); const { error } = await admin.from('tasks').delete().eq('id', taskId);
if (error) return res.status(500).json({ error: error.message }); if (error) return res.status(500).json({ error: error.message });
// Archive FileBrowser task folder return res.status(200).json({ ok: true });
let archiveError = null;
try {
await archiveTask(companyRecord?.name, projectRecord?.name, taskRecord.title);
} catch (e) {
archiveError = e.message;
console.error('[delete-task] archive failed:', e.message);
}
return res.status(200).json({ ok: true, archiveError });
} }
+282 -344
View File
@@ -1,6 +1,10 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
const FB_SOURCE = 'files'; 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) { function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json'); res.status(status).setHeader('Content-Type', 'application/json');
@@ -24,17 +28,6 @@ function joinPath(...parts) {
return normalizePath(parts.join('/')); return normalizePath(parts.join('/'));
} }
function parentDir(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
parts.pop();
return `/${parts.join('/')}`;
}
function basename(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
function safeName(value, fallback = '') { function safeName(value, fallback = '') {
const cleaned = String(value || '') const cleaned = String(value || '')
.trim() .trim()
@@ -44,44 +37,55 @@ function safeName(value, fallback = '') {
return cleaned || fallback; return cleaned || fallback;
} }
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() { function getConfig() {
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
return { return {
url, url,
token: process.env.FILEBROWSER_TOKEN || '', token: process.env.FILEBROWSER_TOKEN || '',
teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'),
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'), subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
externalClientsRoot: normalizePath(process.env.FILEBROWSER_CLIENTS_ROOT || '/fourgebranding/Clients'),
configured: Boolean(url), configured: Boolean(url),
}; };
} }
function getToken(config) { function getToken(config) {
if (!config.token) throw new Error('FILEBROWSER_TOKEN not configured'); const token = String(config.token || '').trim();
return config.token; if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
return token;
} }
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const token = getToken(config);
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const url = `${config.url}${endpoint}?${qs}`; const url = `${config.url}${endpoint}?${qs}`;
const token = getToken(config);
const res = await fetch(url, { const response = await fetch(url, {
method, method,
headers: { Authorization: `Bearer ${token}`, ...headers }, headers: { Authorization: `Bearer ${token}`, ...headers },
body, body,
}); });
if (!res.ok) { if (!response.ok) {
const text = await res.text(); const text = await response.text().catch(() => '');
const err = new Error(text || `FileBrowser ${res.status}`); const error = new Error(text || `FileBrowser ${response.status}`);
err.status = res.status; error.status = response.status;
throw err; throw error;
} }
const text = await res.text(); const text = await response.text();
try { return text ? JSON.parse(text) : null; } catch { return text; } try {
return text ? JSON.parse(text) : null;
} catch {
return text;
}
} }
async function createCallerClient(authHeader) { async function createCallerClient(authHeader) {
@@ -101,17 +105,16 @@ async function requirePortalUser(authHeader) {
const { data: profile, error: profileError } = await callerClient const { data: profile, error: profileError } = await callerClient
.from('profiles') .from('profiles')
.select('id, name, role, company:companies(id, name)') .select('id, role, company:companies(id, name)')
.eq('id', userData.user.id) .eq('id', userData.user.id)
.single(); .single();
if (profileError) return { ok: false, status: 500, message: profileError.message }; if (profileError) return { ok: false, status: 500, message: profileError.message };
if (!['team', 'external', 'client'].includes(profile?.role)) { if (!['team', 'client'].includes(profile?.role)) {
return { ok: false, status: 403, message: 'Forbidden' }; return { ok: false, status: 403, message: 'Forbidden' };
} }
// Mirror AuthContext: load all companies (FK + company_members) const clientCompanies = [];
let clientCompanies = [];
if (profile.role === 'client') { if (profile.role === 'client') {
const seen = new Set(); const seen = new Set();
if (profile.company?.id) { if (profile.company?.id) {
@@ -122,340 +125,275 @@ async function requirePortalUser(authHeader) {
.from('company_members') .from('company_members')
.select('company:companies(id, name)') .select('company:companies(id, name)')
.eq('profile_id', userData.user.id); .eq('profile_id', userData.user.id);
for (const m of memberships || []) { for (const row of memberships || []) {
if (m.company?.id && !seen.has(m.company.id)) { if (row.company?.id && !seen.has(row.company.id)) {
clientCompanies.push(m.company); seen.add(row.company.id);
seen.add(m.company.id); clientCompanies.push(row.company);
} }
} }
} }
return { ok: true, callerClient, profile: { ...profile, clientCompanies, email: userData.user.email } };
}
async function getExternalProjects(callerClient, userId) {
const { data, error } = await callerClient
.from('project_members')
.select('project:projects(id, name, company:companies(name))')
.eq('profile_id', userId);
if (error) throw new Error(error.message);
return (data || []).map(r => r.project).filter(Boolean);
}
function resolveUserRoot(config, profile) {
if (profile.role === 'team') return config.teamRoot;
if (profile.role === 'client') {
const companyFolder = safeName(profile.company?.name, profile.id);
return joinPath(config.clientRoot, companyFolder);
}
if (profile.role === 'external') return config.externalSubsRoot;
return '/';
}
function resolveClientPath(config, vPath, companies) {
const parts = normalizePath(vPath).split('/').filter(Boolean);
if (parts.length === 0) return { virtual: true };
const companyFolder = parts[0];
const match = companies.find(c => safeName(c.name, '') === companyFolder);
if (!match) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const rest = parts.slice(1);
const base = joinPath(config.clientRoot, companyFolder);
const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base;
return { virtual: false, fbPath };
}
function buildClientVirtualEntries(companies) {
return companies.map(c => {
const name = safeName(c.name, c.id);
return { name, type: 'dir', size: 0, mtime: null, path: `/${name}` };
});
}
function resolveExternalPath(config, vPath, profile, projects) {
const myFolder = safeName(profile.name, profile.id);
const parts = normalizePath(vPath).split('/').filter(Boolean);
if (parts.length === 0) return { virtual: true };
// Their personal Team folder
if (parts[0] === myFolder) {
const fbPath = joinPath(config.externalSubsRoot, ...parts);
return { virtual: false, fbPath };
}
// Assigned client projects — flattened: Projects/{project}/...
if (parts[0] === 'Projects') {
if (parts.length < 2) return { virtual: true };
const [, projectFolder, ...rest] = parts;
const match = projects.find(p => safeName(p.name, '') === projectFolder);
if (!match) {
const err = new Error('Access denied to this project');
err.status = 403;
throw err;
}
const company = safeName(match.company?.name, '');
const base = joinPath(config.externalClientsRoot, company, 'Projects', projectFolder);
const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base;
return { virtual: false, fbPath };
}
const err = new Error('Access denied');
err.status = 403;
throw err;
}
function buildExternalVirtualEntries(vPath, profile, projects) {
const myFolder = safeName(profile.name, profile.id);
const parts = normalizePath(vPath).split('/').filter(Boolean);
if (parts.length === 0) {
const entries = [{ name: myFolder, type: 'dir', size: 0, mtime: null, path: `/${myFolder}` }];
if (projects.length > 0) entries.push({ name: 'Projects', type: 'dir', size: 0, mtime: null, path: '/Projects' });
return entries;
}
if (parts[0] === 'Projects' && parts.length === 1) {
const seen = new Set();
return projects
.map(p => safeName(p.name, ''))
.filter(name => name && !seen.has(name) && seen.add(name))
.map(name => ({ name, type: 'dir', size: 0, mtime: null, path: `/Projects/${name}` }));
}
return [];
}
function normalizeQuantumItems(data, virtualPath) {
const dirs = (data?.folders || []).map(item => ({ ...item, _type: 'directory' }));
const files = (data?.files || []).map(item => ({ ...item, _type: item.type || 'file' }));
const items = [...dirs, ...files].map(item => ({
name: item.name,
type: (item._type === 'directory' || item.type === 'directory') ? 'dir' : 'file',
size: (item._type === 'directory' || item.type === 'directory') ? 0 : (item.size || 0),
mtime: item.modified || null,
path: joinPath(virtualPath, item.name),
}));
return items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
if (a.name === '00 Project Files') return -1;
if (b.name === '00 Project Files') return 1;
return a.name.localeCompare(b.name);
});
}
function toListResponse(vPath, entries, { readOnly = false } = {}) {
return { return {
configured: true, ok: true,
path: vPath, profile: { ...profile, clientCompanies },
canGoUp: vPath !== '/',
parentPath: parentDir(vPath),
entries,
readOnly,
}; };
} }
export default async function handler(req, res) { 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 { 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 || !config.token) {
return json(res, 200, {
configured: false,
error: 'FileBrowser not configured.',
requiredEnv: ['FILEBROWSER_URL', 'FILEBROWSER_TOKEN'],
});
}
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
const requestedPath = req.query.path || req.body?.path || '/';
let externalProjects = [];
if (auth.profile.role === 'external') {
externalProjects = await getExternalProjects(auth.callerClient, auth.profile.id);
}
function toFbPath(vPath = requestedPath) {
if (auth.profile.role === 'external') {
return resolveExternalPath(config, normalizePath(vPath), auth.profile, externalProjects);
}
if (auth.profile.role === 'client') {
return resolveClientPath(config, normalizePath(vPath), auth.profile.clientCompanies);
}
const root = resolveUserRoot(config, auth.profile);
return { virtual: false, fbPath: joinPath(root, normalizePath(vPath)) };
}
if (req.method === 'GET' && action === 'config') {
return json(res, 200, { configured: true, role: auth.profile.role, url: config.url });
}
if (req.method === 'GET' && action === 'list') {
const vPath = normalizePath(requestedPath);
if (auth.profile.role === 'external') {
const resolved = resolveExternalPath(config, vPath, auth.profile, externalProjects);
if (resolved.virtual) {
return json(res, 200, toListResponse(vPath, buildExternalVirtualEntries(vPath, auth.profile, externalProjects), { readOnly: true }));
}
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } });
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
}
if (auth.profile.role === 'client') {
const resolved = resolveClientPath(config, vPath, auth.profile.clientCompanies);
if (resolved.virtual) {
return json(res, 200, toListResponse(vPath, buildClientVirtualEntries(auth.profile.clientCompanies)));
}
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } });
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
}
const root = resolveUserRoot(config, auth.profile);
const fbPath = joinPath(root, vPath);
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbPath } });
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
}
if (req.method === 'GET' && action === 'download') {
const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
const token = getToken(config);
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`;
return json(res, 200, { url: downloadUrl, token });
}
if (req.method === 'POST' && action === 'upload-token') {
const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
const token = getToken(config);
return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath });
}
if (req.method === 'POST' && action === 'mkdir') {
const folderName = safeName(req.body?.name, '');
if (!folderName) return json(res, 400, { error: 'Folder name required' });
const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot create folder in virtual directory' });
await fbFetch(config, 'POST', '/api/resources', { await fbFetch(config, 'POST', '/api/resources', {
params: { path: joinPath(resolved.fbPath, folderName), isDir: 'true' }, params: { path: current, isDir: 'true' },
}); });
return json(res, 200, { success: true }); } catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
} }
if (req.method === 'DELETE' && action === 'delete') { async function renameDirectory(config, fromPath, toPath) {
const resolved = toFbPath(); const sourcePath = normalizePath(fromPath);
if (resolved.virtual) return json(res, 400, { error: 'Cannot delete virtual directory' }); const targetPath = normalizePath(toPath);
if (!basename(resolved.fbPath)) return json(res, 400, { error: 'Cannot delete root' }); if (sourcePath === targetPath) return { renamed: false, skipped: true, path: targetPath };
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: resolved.fbPath } }); try {
return json(res, 200, { success: true });
}
if (req.method === 'POST' && action === 'rename') {
const newName = safeName(req.body?.name, '');
if (!newName) return json(res, 400, { error: 'New name required' });
const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot rename virtual directory' });
const newFbPath = joinPath(parentDir(resolved.fbPath), newName);
await fbFetch(config, 'PATCH', '/api/resources', { await fbFetch(config, 'PATCH', '/api/resources', {
params: {},
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
action: 'rename', action: 'rename',
items: [{ fromSource: FB_SOURCE, fromPath: resolved.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], items: [{ fromSource: FB_SOURCE, fromPath: sourcePath, toSource: FB_SOURCE, toPath: targetPath }],
overwrite: false, overwrite: false,
}), }),
}); });
return json(res, 200, { success: true }); return { renamed: true, path: targetPath };
}
if (req.method === 'POST' && action === 'archive-move') {
// Moves srcPath into dstParentPath. If destination already exists, merges contents recursively.
const srcVPath = req.body?.srcPath;
const dstParentVPath = req.body?.dstParentPath;
if (!srcVPath || !dstParentVPath) return json(res, 400, { error: 'srcPath and dstParentPath required' });
const resolvedSrc = toFbPath(srcVPath);
const resolvedDstParent = toFbPath(dstParentVPath);
if (resolvedSrc.virtual || resolvedDstParent.virtual) return json(res, 400, { error: 'Cannot operate on virtual directories' });
async function fbExists(path) {
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
catch (e) { if (e.status === 404) return false; throw e; }
}
async function fbMkdir(path) {
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
}
async function mergeMove(fbSrc, fbDstParent) {
const name = basename(fbSrc);
const fbDst = joinPath(fbDstParent, name);
const destExists = await fbExists(fbDst);
if (!destExists) {
await fbFetch(config, 'PATCH', '/api/resources', {
params: {}, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], overwrite: false }),
});
} else {
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
const dirs = (data?.folders || []).map(f => f.name);
const files = (data?.files || []).map(f => f.name);
await fbMkdir(fbDst);
for (const dir of dirs) await mergeMove(joinPath(fbSrc, dir), fbDst);
for (const file of files) {
await fbFetch(config, 'PATCH', '/api/resources', {
params: {}, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], overwrite: true }),
}).catch(() => {});
}
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
}
}
await mergeMove(resolvedSrc.fbPath, resolvedDstParent.fbPath);
return json(res, 200, { success: true });
}
if (req.method === 'POST' && action === 'move') {
const srcPath = req.body?.srcPath;
const dstPath = req.body?.dstPath;
if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' });
const resolvedSrc = toFbPath(srcPath);
const resolvedDst = toFbPath(dstPath);
if (resolvedSrc.virtual || resolvedDst.virtual) return json(res, 400, { error: 'Cannot move virtual directories' });
const itemName = basename(resolvedSrc.fbPath);
const newFbPath = joinPath(resolvedDst.fbPath, itemName);
await fbFetch(config, 'PATCH', '/api/resources', {
params: {},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'move',
items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }],
overwrite: false,
}),
});
return json(res, 200, { success: true });
}
return json(res, 405, { error: 'Method not allowed' });
} catch (error) { } catch (error) {
return json(res, error.status || 500, { error: error.message || 'Unexpected 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.' });
} }
} }
+225
View File
@@ -0,0 +1,225 @@
import {
PROJECT_DEFAULT_SUBFOLDERS,
REQUEST_DEFAULT_SUBFOLDERS,
createAdminClient,
createDirectory,
ensureDirectory,
getConfig,
joinPath,
listDirectories,
pooled,
safeName,
} from './_lib/filebrowser.js';
export const config = { maxDuration: 300 };
// Leave headroom under maxDuration so a partial run still returns its report.
const TIME_BUDGET_MS = 235000;
const CONCURRENCY = 8;
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 isAuthorized(req) {
const expected = String(process.env.FOLDER_RECONCILE_SECRET || '').trim();
if (!expected) return false;
const header = req.headers['x-reconcile-secret'];
const provided = String(Array.isArray(header) ? header[0] : header || '').trim();
if (provided.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
return diff === 0;
}
export default async function handler(req, res) {
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
if (!isAuthorized(req)) return json(res, 401, { error: 'Unauthorized' });
const fbConfig = getConfig();
if (!fbConfig.configured) {
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
}
const startedAt = Date.now();
const outOfTime = () => Date.now() - startedAt > TIME_BUDGET_MS;
const created = [];
const orphans = [];
const errors = [];
const record = (type, path) => created.push({ type, path });
const fail = (scope, error) => errors.push({ scope, message: String(error?.message || error).slice(0, 300) });
try {
const admin = createAdminClient();
const [companiesResult, projectsResult, tasksResult, subsResult] = await Promise.all([
admin.from('companies').select('id, name'),
admin.from('projects').select('id, name, company_id'),
admin.from('tasks').select('id, title, project_id'),
admin.from('profiles').select('id, name').eq('role', 'external'),
]);
for (const result of [companiesResult, projectsResult, tasksResult, subsResult]) {
if (result.error) throw result.error;
}
const companies = companiesResult.data || [];
const projects = projectsResult.data || [];
const tasks = tasksResult.data || [];
const subs = subsResult.data || [];
const rootDirs = await listDirectories(fbConfig, fbConfig.clientRoot);
if (rootDirs === null) {
await ensureDirectory(fbConfig, fbConfig.clientRoot);
record('client-root', fbConfig.clientRoot);
}
const rootSet = new Set(rootDirs || []);
let complete = true;
for (const company of companies) {
if (outOfTime()) { complete = false; break; }
const companyName = safeName(company.name, company.id);
const companyPath = joinPath(fbConfig.clientRoot, companyName);
const projectsPath = joinPath(companyPath, 'Projects');
const companyProjects = projects.filter(project => project.company_id === company.id);
try {
if (!rootSet.has(companyName)) {
await ensureDirectory(fbConfig, companyPath);
record('company', companyPath);
}
let projectDirs = await listDirectories(fbConfig, projectsPath);
if (projectDirs === null) {
if (companyProjects.length === 0) continue;
await ensureDirectory(fbConfig, projectsPath);
record('projects-root', projectsPath);
projectDirs = [];
}
const projectDirSet = new Set(projectDirs);
// Phase 1: create project folders that do not exist yet, with their defaults.
const missingProjects = companyProjects.filter(p => !projectDirSet.has(safeName(p.name, p.id)));
await pooled(missingProjects, CONCURRENCY, async (project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
try {
await createDirectory(fbConfig, projectPath);
record('project', projectPath);
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
await createDirectory(fbConfig, joinPath(projectPath, folderName));
}
} catch (error) {
fail(projectPath, error);
}
});
// Phase 2: list every project folder once to learn which task folders exist.
const existingProjects = companyProjects.filter(p => projectDirSet.has(safeName(p.name, p.id)));
const listings = await pooled(existingProjects, CONCURRENCY, async (project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
try {
return await listDirectories(fbConfig, projectPath);
} catch (error) {
fail(projectPath, error);
return null;
}
});
// Phase 3: create the missing leaves, flattened so the pool stays saturated.
const work = [];
existingProjects.forEach((project, index) => {
const childDirs = listings[index];
if (childDirs === null) return;
const childSet = new Set(childDirs);
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
if (!childSet.has(folderName)) work.push({ type: 'project-subfolder', path: joinPath(projectPath, folderName), children: [] });
}
for (const task of tasks.filter(t => t.project_id === project.id)) {
const taskName = safeName(task.title, task.id);
if (childSet.has(taskName)) continue;
work.push({ type: 'task', path: joinPath(projectPath, taskName), children: REQUEST_DEFAULT_SUBFOLDERS });
}
});
missingProjects.forEach((project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
for (const task of tasks.filter(t => t.project_id === project.id)) {
work.push({ type: 'task', path: joinPath(projectPath, safeName(task.title, task.id)), children: REQUEST_DEFAULT_SUBFOLDERS });
}
});
await pooled(work, CONCURRENCY, async (item) => {
if (outOfTime()) { complete = false; return; }
try {
await createDirectory(fbConfig, item.path);
record(item.type, item.path);
for (const folderName of item.children) {
await createDirectory(fbConfig, joinPath(item.path, folderName));
}
} catch (error) {
fail(item.path, error);
}
});
// Reported only — never deleted, since these may hold real work.
const expected = new Set(companyProjects.map(p => safeName(p.name, p.id)));
for (const dirName of projectDirSet) {
if (!expected.has(dirName)) orphans.push(joinPath(projectsPath, dirName));
}
} catch (error) {
fail(companyPath, error);
}
}
if (subs.length > 0 && !outOfTime()) {
try {
const subDirs = await listDirectories(fbConfig, fbConfig.subsRoot);
if (subDirs === null) {
await ensureDirectory(fbConfig, fbConfig.subsRoot);
record('subs-root', fbConfig.subsRoot);
}
const subDirSet = new Set(subDirs || []);
const missingSubs = subs.filter(profile => !subDirSet.has(safeName(profile.name, profile.id)));
await pooled(missingSubs, CONCURRENCY, async (profile) => {
const subPath = joinPath(fbConfig.subsRoot, safeName(profile.name, profile.id));
try {
await createDirectory(fbConfig, subPath);
record('subcontractor', subPath);
} catch (error) {
fail(subPath, error);
}
});
} catch (error) {
fail(fbConfig.subsRoot, error);
}
}
return json(res, 200, {
ok: errors.length === 0,
complete,
elapsedMs: Date.now() - startedAt,
configured: true,
createdCount: created.length,
created,
orphanCount: orphans.length,
orphans,
errorCount: errors.length,
errors: errors.slice(0, 50),
});
} catch (error) {
return json(res, error?.status || 500, {
ok: false,
complete: false,
elapsedMs: Date.now() - startedAt,
error: String(error?.message || 'Folder reconcile failed.'),
createdCount: created.length,
created,
errorCount: errors.length,
errors: errors.slice(0, 50),
});
}
}
-111
View File
@@ -1,111 +0,0 @@
const FB_SOURCE = 'files';
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');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function parentDir(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
parts.pop();
return `/${parts.join('/')}`;
}
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
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'),
configured: Boolean(url),
};
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${config.token}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
const err = new Error(text || `FileBrowser ${res.status}`);
err.status = res.status;
throw err;
}
}
async function mkdir(config, parentPath, name) {
const folderPath = joinPath(parentPath, safeName(name));
await fbFetch(config, 'POST', '/api/resources', {
params: { path: folderPath, isDir: 'true' },
}).catch(() => {});
}
async function renameFolder(config, oldName, newName) {
const oldSafe = safeName(oldName);
const newSafe = safeName(newName);
if (!oldSafe || !newSafe || oldSafe === newSafe) return;
const fromPath = joinPath(config.clientRoot, oldSafe);
const toPath = joinPath(config.clientRoot, newSafe);
await fbFetch(config, 'PATCH', '/api/resources', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'rename',
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
overwrite: false,
}),
}).catch(() => {});
}
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
if (secret) {
const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || '';
if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' });
}
const { type, record, old_record } = req.body || {};
if (!record?.name) return res.status(200).json({ ok: true, skipped: true });
const config = getConfig();
if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' });
const clientRoot = config.clientRoot;
const clientsParent = parentDir(clientRoot);
const clientsDirName = clientRoot.split('/').filter(Boolean).pop();
// Ensure parent Clients dir exists
await mkdir(config, clientsParent, clientsDirName);
if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) {
await renameFolder(config, old_record.name, record.name);
}
// Always ensure folder for current name exists (idempotent)
await mkdir(config, clientRoot, record.name);
res.status(200).json({ ok: true, type, name: record.name });
}
-117
View File
@@ -1,117 +0,0 @@
const FB_SOURCE = 'files';
const MEMBER_ROLES = new Set(['team', 'external']);
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');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function parentDir(path) {
const parts = normalizePath(path).split('/').filter(Boolean);
parts.pop();
return `/${parts.join('/')}`;
}
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function getConfig() {
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
return {
url,
token: process.env.FILEBROWSER_TOKEN || '',
membersRoot: normalizePath(process.env.FILEBROWSER_MEMBERS_ROOT || '/fourgebranding/Team'),
configured: Boolean(url),
};
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${config.token}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
const err = new Error(text || `FileBrowser ${res.status}`);
err.status = res.status;
throw err;
}
}
async function mkdir(config, parentPath, name) {
const safe = safeName(name);
if (!safe) return;
await fbFetch(config, 'POST', '/api/resources', {
params: { path: joinPath(parentPath, safe), isDir: 'true' },
}).catch(() => {});
}
async function renameFolder(config, root, oldName, newName) {
const oldSafe = safeName(oldName);
const newSafe = safeName(newName);
if (!oldSafe || !newSafe || oldSafe === newSafe) return;
await fbFetch(config, 'PATCH', '/api/resources', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'rename',
items: [{
fromSource: FB_SOURCE, fromPath: joinPath(root, oldSafe),
toSource: FB_SOURCE, toPath: joinPath(root, newSafe),
}],
overwrite: false,
}),
}).catch(() => {});
}
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
if (secret) {
const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || '';
if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' });
}
const { type, record, old_record } = req.body || {};
// Only process team and external roles
if (!MEMBER_ROLES.has(record?.role)) return res.status(200).json({ ok: true, skipped: true });
if (!record?.name) return res.status(200).json({ ok: true, skipped: 'no name' });
const config = getConfig();
if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' });
const membersRoot = config.membersRoot;
const membersParent = parentDir(membersRoot);
const membersDirName = membersRoot.split('/').filter(Boolean).pop();
// Ensure /fourgebranding/team dir exists
await mkdir(config, membersParent, membersDirName);
if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) {
await renameFolder(config, membersRoot, old_record.name, record.name);
}
// Ensure folder for current name exists
await mkdir(config, membersRoot, record.name);
res.status(200).json({ ok: true, type, name: record.name, role: record.role });
}
-124
View File
@@ -1,124 +0,0 @@
import { createClient } from '@supabase/supabase-js';
const FB_SOURCE = 'files';
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');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
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'),
configured: Boolean(url),
};
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${config.token}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
const err = new Error(text || `FileBrowser ${res.status}`);
err.status = res.status;
throw err;
}
}
async function mkdir(config, path) {
await fbFetch(config, 'POST', '/api/resources', {
params: { path, isDir: 'true' },
}).catch(() => {});
}
async function renameFolder(config, fromPath, toPath) {
await fbFetch(config, 'PATCH', '/api/resources', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'rename',
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
overwrite: false,
}),
}).catch(() => {});
}
async function requireTeamUser(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) return false;
const client = createClient(supabaseUrl, supabaseAnonKey, {
auth: { persistSession: false, autoRefreshToken: false },
global: { headers: { Authorization: authHeader } },
});
const { data: userData } = await client.auth.getUser();
if (!userData?.user) return false;
const { data: profile } = await client.from('profiles').select('role').eq('id', userData.user.id).single();
return profile?.role === 'team';
}
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
const authHeader = req.headers.authorization || '';
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
if (authHeader.startsWith('Bearer ')) {
const isTeam = await requireTeamUser(authHeader);
if (!isTeam) return res.status(403).json({ error: 'Team members only' });
} else if (secret) {
const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || '';
if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' });
}
const { type, record, old_record } = req.body || {};
if (!record?.name || !record?.company_name) return res.status(200).json({ ok: true, skipped: 'missing name or company_name' });
const config = getConfig();
if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' });
const companyDir = joinPath(config.clientRoot, safeName(record.company_name));
const projectsDir = joinPath(companyDir, 'Projects');
// Ensure Clients/{company}/Projects/ exists
await mkdir(config, companyDir);
await mkdir(config, projectsDir);
if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) {
const oldPath = joinPath(projectsDir, safeName(old_record.name));
const newPath = joinPath(projectsDir, safeName(record.name));
await renameFolder(config, oldPath, newPath);
}
// Ensure folder for current project name exists
const projectDir = joinPath(projectsDir, safeName(record.name));
await mkdir(config, projectDir);
await mkdir(config, joinPath(projectDir, '00 Project Files'));
res.status(200).json({ ok: true, type, company: record.company_name, project: record.name });
}
-105
View File
@@ -1,105 +0,0 @@
const FB_SOURCE = 'files';
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');
clean.push(part);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
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'),
configured: Boolean(url),
};
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${config.token}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
const err = new Error(text || `FileBrowser ${res.status}`);
err.status = res.status;
throw err;
}
}
async function mkdir(config, path) {
await fbFetch(config, 'POST', '/api/resources', {
params: { path, isDir: 'true' },
}).catch(() => {});
}
async function renameFolder(config, fromPath, toPath) {
await fbFetch(config, 'PATCH', '/api/resources', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'rename',
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
overwrite: false,
}),
}).catch(() => {});
}
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
if (secret) {
const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || '';
if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' });
}
const { type, record, old_record } = req.body || {};
if (!record?.title || !record?.project_name || !record?.company_name) {
return res.status(200).json({ ok: true, skipped: 'missing title, project_name, or company_name' });
}
const config = getConfig();
if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' });
const projectDir = joinPath(config.clientRoot, safeName(record.company_name), 'Projects', safeName(record.project_name));
// Ensure parent dirs exist
await mkdir(config, joinPath(config.clientRoot, safeName(record.company_name)));
await mkdir(config, joinPath(config.clientRoot, safeName(record.company_name), 'Projects'));
await mkdir(config, projectDir);
if (type === 'UPDATE' && old_record?.title && old_record.title !== record.title) {
const oldPath = joinPath(projectDir, safeName(old_record.title));
const newPath = joinPath(projectDir, safeName(record.title));
await renameFolder(config, oldPath, newPath);
}
const taskDir = joinPath(projectDir, safeName(record.title));
await mkdir(config, taskDir);
await mkdir(config, joinPath(taskDir, 'Working Files'));
await mkdir(config, joinPath(taskDir, 'Request Info'));
res.status(200).json({ ok: true, type, company: record.company_name, project: record.project_name, task: record.title });
}
@@ -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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
-171
View File
@@ -1,171 +0,0 @@
# Fourge Portal Layout System
This is the single source of truth for dashboard/profile visual structure and UI geometry.
## 1) Global Frame
- Viewport app shell: `height: 100vh`, `overflow: hidden`
- Main content gutter: `24px` all sides
- Sidebar: `width: 76px`, `top: 24px`, `left: 24px`, `height: calc(100vh - 48px)`, `border-radius: 8px`
- Main wrapper offset from sidebar: `margin-left: 100px`
- Page rhythm unit: `24px` (header spacing, card gaps, section gaps)
## 2) Theme + Background
- Background ownership is `body` via `background: var(--bg)`.
- Dark base token `--bg` is full gradient:
- radial glow + vertical dark gradient.
- Light base token `--bg` is full gradient:
- gray radial glow + white vertical gradient.
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
## 3) Tokens
- Accent: `#F5A523`
- Card bg dark: `rgba(255,255,255,0.02)`
- Card bg light: `rgba(0,0,0,0.02)`
- Secondary card tone dark: `rgba(255,255,255,0.08)`
- Secondary card tone light: `rgba(0,0,0,0.08)`
- Border dark: `rgba(245,165,35,0.15)`
- Border light: `rgba(0,0,0,0.1)`
- Text primary dark/light: `#ffffff / #0d0d0d`
- Text secondary dark/light: `#a8a8a8 / rgba(0,0,0,0.6)`
- Text muted dark/light: `#666666 / rgba(0,0,0,0.38)`
## 4) Typography
- Font family: `Fourge`, then `-apple-system`, `BlinkMacSystemFont`, `'Segoe UI'`, `sans-serif`
- Base font size: `14px`
- Header title: `28px`, `500`, `line-height: 1.2`
- Header subtitle: `13px`
- Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
- Body table text: `12px/13px` by column importance
## 5) Card System
- Default widget shell:
- `background: var(--card-bg)`
- `border: 1px solid var(--border)`
- `border-radius: 8px`
- `padding: 18px 21px`
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
- Compact card radius (legacy generic `.card`): `4px` (do not use for new dashboard widgets)
## 6) Header + Top Right Controls
- Site header: `padding-top: 24px`, `padding-bottom: 24px`
- Right control row:
- Search icon button: `32x32`
- Search button to theme toggle space: `7px` (`search-wrap margin-right`)
- Theme toggle: `32x32`
- Theme toggle to avatar: `14px` (`avatar-wrap margin-left`)
- Avatar button: `49x49`, circle, `2px` inner ring + `2px` accent outline
## 7) Dashboard Grids (Team)
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
- Row 2: `grid-template-columns: 1fr 280px`, `gap: 24`, `margin-top: 24`
- Row 3: `grid-template-columns: 1fr 1fr`, `gap: 24`, `margin-top: 24`
- Row 4 full-width: `margin-top: 24`
## 8) Stat Cards
- Card min height: `120px`
- Internal row gap: `21px`
- Label/value/sub spacing:
- Label: `margin-bottom: 5px`
- Value: `30px`, `400`, `letter-spacing: -0.5`, `line-height: 1.1`
- Sub: `12px`, `margin-top: 5px`
- Icon badge: `27x27`, circle
- Icon glyph: `13x13`
## 9) Calendar
- Card uses widget shell
- Header-to-grid gap: `14px`
- Weekday label: `10px`, `600`, `letter-spacing: 0.5`
- Day cell button: `28x28`, circular
- Day number: `12px`
- Today style: bg `#F5A523`, text `#0d0d0d`, `700`
- Dots: up to 3, each `3x3`, gap `2`
- Popover:
- Anchored left of cell: `right: calc(100% + 8px)`, vertical centered
- `width: 210px`, `padding: 10px 12px`, `border-radius: 8px`
- shadow `0 12px 32px rgba(0,0,0,0.45)`
- row dot `6x6`, row text `12px`
## 10) Activity + Performance Rows
- Visible rows target: 5
- Row layout: `display:flex`, `align-items:center`, `gap:10px`
- Row spacing: `margin-top: 10px` from second row onward
- Name text: `13px`
- Meta/date text: `11px`
- Progress track: `height: 4px`, `radius: 2px`
- Percentage width slot: `min-width: 28px`
## 11) Tables
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
- Header cells:
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
- bottom spacing: `padding-bottom: 12px`
- Body cells:
- primary text: `13px`
- secondary/metrics text: `12px`
- row vertical spacing via cell padding: typically `5px`
- Hot Items column widths:
- check `10%`, task `40%`, requested by `35%`, due by `15%`
- Client Highlight column widths:
- icon `5%`, company `22%`, contact `23%`, projects `13%`, open `13%`, outstanding `12%`, paid `12%`
- Sorting rule:
- Every visible data column header must be sortable.
- Use clickable header controls (`SortTh`) with ascending/descending indicator.
- Exclude only non-data utility/action columns (checkbox-only, icon-only status marker, action buttons).
## 12) Profile Page
- Container: full available content width, column, `gap: 24`
- Top row: `grid-template-columns: 1fr 280px`, `gap: 24`
- At `<=1200px`: top row stacks to one column
- Main profile card uses widget shell
- Internal card layout:
- row `gap: 20px`
- portrait column `width: 160px`, portrait max `140x140`, circle
- detail grid `140px 1fr`, `row-gap: 8`, `column-gap: 12`, `margin-top: 14`
- social row `margin-top: 14`, `gap: 8`
- self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 30px`, `font-size: 12px`
- Right calendar card shows only tasks/events assigned to the viewed profile user
- Modal:
- overlay: fixed inset, `z-index: 1200`, bg `rgba(0,0,0,0.58)`, blur `6px`
- overlay padding: `24px`
- modal width: `min(620px, 100%)`
- modal max-height: `calc(100vh - 48px)`, `overflow-y: auto`
## 13) Radius + Geometry Rules
- Dashboard/profile widgets: `8px` radius
- Sidebar: `8px` radius
- Buttons/input/dropdowns mostly `4px` radius
- Circular elements (avatar/day/icon badges): `50%`
## 14) Z-Index Stack
- Sidebar: `200`
- Header dropdowns/tooltips: `300`
- Calendar hover popover: `1002` within card context (`card can be 1001 active`)
- Modal overlay: `1200`
## 15) Motion
- Motion vars:
- fast `160ms`
- base `220ms`
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
- Dropdown animation: `ui-fade-up` from `translateY(4px)` + opacity 0 -> 1
## 17) Hover Interaction Contract
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
- Single hover source-of-truth block controls these elements.
- Dark hover surface baseline: `#1f1f1f`.
- Light hover surface baseline: `rgba(0,0,0,0.08)`.
- Nav icon opacity must lift from muted to full on hover (`opacity: 1`).
- Hover and active must be visually distinct:
- hover uses stronger temporary contrast (`bg` + thin border),
- active remains persistent selected-state background.
## 16) Non-Negotiable Implementation Rules
- Keep gradient backgrounds on `html`, not `body`
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
- Maintain global `24px` spacing rhythm for page/frame/grid gaps
- Keep team dashboard card order:
1. Open Tasks
2. Active Projects
3. Net Profit
4. Revenue (wide)
- Keep row 2 order: Recent Activity (left), Calendar (right)
Generated Executable → Regular
-7
View File
@@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.99.3", "@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2", "heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1", "jszip": "^3.10.1",
@@ -2026,12 +2025,6 @@
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==", "integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
"license": "LGPL-3.0" "license": "LGPL-3.0"
}, },
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
Executable → Regular
-1
View File
@@ -12,7 +12,6 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.99.3", "@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2", "heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1", "jszip": "^3.10.1",
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

-83
View File
@@ -1,83 +0,0 @@
// One-time: creates 00 Project Files folder inside every existing project folder in FileBrowser
// Run: node scripts/backfill-project-files-folder.mjs
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createClient } from '@supabase/supabase-js';
const __dir = dirname(fileURLToPath(import.meta.url));
const envFile = resolve(__dir, '../.env.backfill');
const env = {};
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
});
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
const FB_TOKEN = env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const FB_SOURCE = 'files';
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function joinPath(...parts) {
const raw = parts.join('/');
const clean = raw.split('/').filter(p => p && p !== '.');
return `/${clean.join('/')}`;
}
async function mkdir(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
method: 'POST',
headers: { Authorization: `Bearer ${FB_TOKEN}` },
});
if (!res.ok) {
const text = await res.text();
if (!text.includes('already') && !text.includes('exist')) {
console.warn(` mkdir ${path}: ${res.status} ${text.slice(0, 80)}`);
}
}
}
async function main() {
const { data: projects, error } = await admin
.from('projects')
.select('id, name, company:companies(name)');
if (error) { console.error('Query failed:', error.message); process.exit(1); }
console.log(`Found ${projects.length} projects`);
for (const p of projects) {
const company = safeName(p.company?.name || '');
const project = safeName(p.name || '');
if (!company || !project) { console.log(` Skipping (missing name): ${p.id}`); continue; }
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
const targetDir = joinPath(projectDir, '00 Project Files');
await mkdir(joinPath(CLIENT_ROOT, company));
await mkdir(joinPath(CLIENT_ROOT, company, 'Projects'));
await mkdir(projectDir);
await mkdir(targetDir);
console.log(`${company} / ${project} / 00 Project Files`);
}
console.log('\nDone.');
}
main().catch(err => { console.error(err); process.exit(1); });
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env node
// Backfill FileBrowser folders for all existing projects and tasks.
// Project: Clients/{company}/Projects/{project}/00 Project Files/ + 00 Project Info/
// Task: Clients/{company}/Projects/{project}/{task}/Working Files/ + Request Info/
// Run: node --env-file=.env.backfill scripts/backfill-project-folders.mjs
const FB_SOURCE = 'files';
const FILEBROWSER_URL = (process.env.FILEBROWSER_URL || 'https://fourgebranding.krao.us').replace(/\/+$/, '');
const FILEBROWSER_TOKEN = process.env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const SUPABASE_URL = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!FILEBROWSER_TOKEN) { console.error('Missing FILEBROWSER_TOKEN'); process.exit(1); }
if (!SUPABASE_URL || !SUPABASE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
function safeName(value) {
return String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function normalizePath(path) {
const parts = String(path || '/').split('/').filter(p => p && p !== '.' && p !== '..');
return `/${parts.join('/')}`;
}
function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
async function mkdir(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
const res = await fetch(`${FILEBROWSER_URL}/api/resources?${qs}`, {
method: 'POST',
headers: { Authorization: `Bearer ${FILEBROWSER_TOKEN}` },
});
// 200 = created, 409 = already exists — both fine
if (!res.ok && res.status !== 409) {
const text = await res.text();
throw new Error(`mkdir ${path} failed (${res.status}): ${text}`);
}
return res.status;
}
async function supabaseFetch(path) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
headers: {
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
},
});
if (!res.ok) throw new Error(`Supabase ${path}: ${await res.text()}`);
return res.json();
}
async function run() {
console.log('Fetching projects from Supabase...');
const projects = await supabaseFetch('projects?select=id,name,company:companies(name)&order=created_at.asc');
console.log(`Found ${projects.length} projects.`);
console.log('Fetching tasks from Supabase...');
const tasks = await supabaseFetch('tasks?select=id,title,project:projects(name,company:companies(name))&order=submitted_at.asc');
console.log(`Found ${tasks.length} tasks.\n`);
let created = 0;
let existing = 0;
let errors = 0;
// ── Projects ──────────────────────────────────────────────────────────────
console.log('=== PROJECTS ===');
for (const project of projects) {
const companyName = project.company?.name;
if (!companyName) { console.log(` SKIP ${project.name} — no company`); continue; }
const companyDir = joinPath(CLIENT_ROOT, safeName(companyName));
const projectsDir = joinPath(companyDir, 'Projects');
const projectDir = joinPath(projectsDir, safeName(project.name));
try {
await mkdir(companyDir);
await mkdir(projectsDir);
const s = await mkdir(projectDir);
await mkdir(joinPath(projectDir, '00 Project Files'));
await mkdir(joinPath(projectDir, '00 Project Info'));
if (s === 409) { console.log(` EXISTS ${companyName} / ${project.name}`); existing++; }
else { console.log(` CREATED ${companyName} / ${project.name}`); created++; }
} catch (err) {
console.error(` ERROR ${companyName} / ${project.name}: ${err.message}`);
errors++;
}
}
// ── Tasks ─────────────────────────────────────────────────────────────────
console.log('\n=== TASKS ===');
for (const task of tasks) {
const projectName = task.project?.name;
const companyName = task.project?.company?.name;
if (!projectName || !companyName) { console.log(` SKIP ${task.title} — missing project/company`); continue; }
const projectDir = joinPath(CLIENT_ROOT, safeName(companyName), 'Projects', safeName(projectName));
const taskDir = joinPath(projectDir, safeName(task.title));
try {
// Ensure parent exists (idempotent)
await mkdir(joinPath(CLIENT_ROOT, safeName(companyName)));
await mkdir(joinPath(CLIENT_ROOT, safeName(companyName), 'Projects'));
await mkdir(projectDir);
const s = await mkdir(taskDir);
await mkdir(joinPath(taskDir, 'Working Files'));
await mkdir(joinPath(taskDir, 'Request Info'));
if (s === 409) { console.log(` EXISTS ${companyName} / ${projectName} / ${task.title}`); existing++; }
else { console.log(` CREATED ${companyName} / ${projectName} / ${task.title}`); created++; }
} catch (err) {
console.error(` ERROR ${companyName} / ${projectName} / ${task.title}: ${err.message}`);
errors++;
}
}
console.log(`\nDone. Created: ${created} Already existed: ${existing} Errors: ${errors}`);
}
run().catch(err => { console.error(err); process.exit(1); });
-165
View File
@@ -1,165 +0,0 @@
// One-time script: copies existing submission files from Supabase Storage to FileBrowser
// Run: node scripts/backfill-request-files.mjs
import { readFileSync } from 'fs';
import { createClient } from '@supabase/supabase-js';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dir = dirname(fileURLToPath(import.meta.url));
const envFile = resolve(__dir, '../.env.backfill');
// Parse env file
const env = {};
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
});
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
const FB_TOKEN = env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const FB_SOURCE = 'files';
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
function normalizePath(path) {
const parts = String(path || '/').trim().split('/').filter(Boolean);
const clean = [];
for (const p of parts) {
if (p === '.') continue;
if (p === '..') throw new Error('path traversal');
clean.push(p);
}
return `/${clean.join('/')}`;
}
function joinPath(...parts) { return normalizePath(parts.join('/')); }
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
async function fbFetch(method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const res = await fetch(`${FB_URL}${endpoint}?${qs}`, {
method,
headers: { Authorization: `Bearer ${FB_TOKEN}`, ...headers },
body,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`FB ${res.status}: ${text.slice(0, 200)}`);
}
return res;
}
async function mkdir(path) {
await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
}
async function main() {
const { data: rows, error } = await admin
.from('submission_files')
.select(`
id, name, storage_path,
submission:submissions!inner(
id, version_number,
task:tasks!inner(
id, title,
project:projects!inner(
id, name,
company:companies!inner(name)
)
)
)
`);
if (error) { console.error('Query failed:', error.message); process.exit(1); }
// Group by task + version
const groups = new Map();
for (const row of rows || []) {
const sub = row.submission;
const task = sub?.task;
const project = task?.project;
const company = project?.company;
if (!task || !project || !company) continue;
const key = `${task.id}::${sub.version_number}`;
if (!groups.has(key)) {
groups.set(key, {
companyName: company.name,
projectName: project.name,
taskTitle: task.title,
versionNumber: sub.version_number,
files: [],
});
}
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
}
console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`);
let processed = 0, skipped = 0, errors = 0;
for (const [key, group] of groups) {
if (group.files.length === 0) { skipped++; continue; }
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
const companyDir = joinPath(CLIENT_ROOT, safeName(group.companyName));
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
const requestInfoDir = joinPath(taskDir, 'Request Info');
const revDir = joinPath(requestInfoDir, revFolder);
console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`);
await mkdir(companyDir);
await mkdir(joinPath(companyDir, 'Projects'));
await mkdir(projectDir);
await mkdir(taskDir);
await mkdir(requestInfoDir);
await mkdir(revDir);
for (const file of group.files) {
try {
const { data: signed, error: signErr } = await admin.storage
.from('submissions')
.createSignedUrl(file.storage_path, 120);
if (signErr || !signed?.signedUrl) throw new Error(`signed url failed: ${signErr?.message}`);
const fileRes = await fetch(signed.signedUrl);
if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`);
const fileBuffer = await fileRes.arrayBuffer();
const fbFilePath = joinPath(revDir, file.name);
await fbFetch('POST', '/api/resources', {
params: { path: fbFilePath, override: 'true' },
headers: { 'Content-Type': 'application/octet-stream' },
body: fileBuffer,
});
console.log(`${file.name}`);
processed++;
} catch (err) {
console.error(`${file.name}: ${err.message}`);
errors++;
}
}
}
console.log(`\nDone. Processed: ${processed}, Skipped: ${skipped}, Errors: ${errors}`);
}
main().catch(err => { console.error(err); process.exit(1); });
@@ -1,103 +0,0 @@
// One-time: removes leftover '.Project Files' and 'Project Files' folders
// that may still exist alongside the renamed '00 Project Files'
// Run: node scripts/cleanup-old-project-files-folders.mjs
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createClient } from '@supabase/supabase-js';
const __dir = dirname(fileURLToPath(import.meta.url));
const envFile = resolve(__dir, '../.env.backfill');
const env = {};
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
});
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
const FB_TOKEN = env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const FB_SOURCE = 'files';
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function joinPath(...parts) {
const clean = parts.join('/').split('/').filter(p => p && p !== '.');
return `/${clean.join('/')}`;
}
async function fbList(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
headers: { Authorization: `Bearer ${FB_TOKEN}` },
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
return data?.folders || [];
}
async function fbDelete(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${FB_TOKEN}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text.slice(0, 100)}`);
}
}
const OLD_NAMES = ['.Project Files', 'Project Files'];
async function main() {
const { data: projects, error } = await admin
.from('projects')
.select('id, name, company:companies(name)');
if (error) { console.error('Query failed:', error.message); process.exit(1); }
console.log(`Checking ${projects.length} projects...\n`);
let cleaned = 0;
for (const p of projects) {
const company = safeName(p.company?.name || '');
const project = safeName(p.name || '');
if (!company || !project) continue;
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
const entries = await fbList(projectDir);
if (!entries) { console.log(` ? could not list: ${company} / ${project}`); continue; }
const names = entries.map(e => e.name);
for (const oldName of OLD_NAMES) {
if (names.includes(oldName)) {
const oldPath = joinPath(projectDir, oldName);
try {
await fbDelete(oldPath);
console.log(` ✓ deleted "${oldName}": ${company} / ${project}`);
cleaned++;
} catch (err) {
console.error(` ✗ failed to delete "${oldName}" in ${company} / ${project}: ${err.message}`);
}
}
}
}
console.log(`\nDone. Cleaned ${cleaned} old folder(s).`);
}
main().catch(err => { console.error(err); process.exit(1); });
+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>
-104
View File
@@ -1,104 +0,0 @@
// One-time: renames .00 Project Files → 00 Project Files for all existing project folders
// Run: node scripts/rename-project-files-folder.mjs
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createClient } from '@supabase/supabase-js';
const __dir = dirname(fileURLToPath(import.meta.url));
const envFile = resolve(__dir, '../.env.backfill');
const env = {};
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
});
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
const FB_TOKEN = env.FILEBROWSER_TOKEN;
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
const FB_SOURCE = 'files';
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
function joinPath(...parts) {
const clean = parts.join('/').split('/').filter(p => p && p !== '.');
return `/${clean.join('/')}`;
}
async function fbRename(fromPath, toPath) {
const qs = new URLSearchParams({ source: FB_SOURCE }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${FB_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'rename',
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
overwrite: false,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text.slice(0, 120)}`);
}
}
async function fbMkdir(path) {
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
method: 'POST',
headers: { Authorization: `Bearer ${FB_TOKEN}` },
});
if (!res.ok) {
const text = await res.text();
if (!text.includes('already') && !text.includes('exist')) throw new Error(`mkdir ${path}: ${res.status}`);
}
}
async function main() {
const { data: projects, error } = await admin
.from('projects')
.select('id, name, company:companies(name)');
if (error) { console.error('Query failed:', error.message); process.exit(1); }
console.log(`Found ${projects.length} projects`);
for (const p of projects) {
const company = safeName(p.company?.name || '');
const project = safeName(p.name || '');
if (!company || !project) continue;
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
const oldPath = joinPath(projectDir, 'Project Files');
const newPath = joinPath(projectDir, '00 Project Files');
try {
await fbRename(oldPath, newPath);
console.log(` ✓ renamed: ${company} / ${project}`);
} catch (err) {
// If rename fails (source doesn't exist), ensure new folder exists
try {
await fbMkdir(newPath);
console.log(` + created: ${company} / ${project} (no dot folder found)`);
} catch (e2) {
console.log(` ~ exists: ${company} / ${project}`);
}
}
}
console.log('\nDone.');
}
main().catch(err => { console.error(err); process.exit(1); });
Executable → Regular
View File
Executable → Regular
+70 -58
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, Component } from 'react'; import { lazy, Suspense, Component } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import PageLoader from './components/PageLoader'; import PageLoader from './components/PageLoader';
@@ -39,42 +39,52 @@ class ChunkErrorBoundary extends Component {
import Login from './pages/Login'; import Login from './pages/Login';
import PayInvoice from './pages/PayInvoice'; import PayInvoice from './pages/PayInvoice';
const ProfilePage = lazy(() => import('./pages/Settings')); const ProfilePage = lazy(() => import('./pages/Profile'));
const CompaniesPage = lazy(() => import('./pages/CompaniesPage')); const CompaniesPage = lazy(() => import('./pages/Companies'));
const CompanyDetail = lazy(() => import('./pages/CompanyDetail')); const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
const Invoices = lazy(() => import('./pages/team/Invoices')); const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices'));
const RequestDetail = lazy(() => import('./pages/RequestDetail')); const TaskDetail = lazy(() => import('./pages/TaskDetail'));
const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice')); const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO'));
const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO')); const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail'));
const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail')); const TeamSubcontractorPODetail = lazy(() => import('./pages/team/TeamSubcontractorPODetail'));
const SubcontractorPODetail = lazy(() => import('./pages/team/SubcontractorPODetail')); const TeamSubInvoiceDetail = lazy(() => import('./pages/team/TeamSubInvoiceDetail'));
const SubInvoiceDetail = lazy(() => import('./pages/team/SubInvoiceDetail'));
const SurveyMaker = lazy(() => import('./pages/SurveyMaker')); const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
const BrandBook = lazy(() => import('./pages/BrandBook')); const BrandBook = lazy(() => import('./pages/BrandBook'));
const Converters = lazy(() => import('./pages/Converters')); const Converters = lazy(() => import('./pages/Converters'));
const FileSharing = lazy(() => import('./pages/FileSharing')); const TeamFourgePasswords = lazy(() => import('./pages/team/TeamFourgePasswords'));
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords')); const TeamReports = lazy(() => import('./pages/team/TeamReports'));
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders')); const ExternalMyPurchaseOrders = lazy(() => import('./pages/external/ExternalMyPurchaseOrders'));
const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices')); const ExternalMyInvoices = lazy(() => import('./pages/external/ExternalMyInvoices'));
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail')); const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/ExternalMyInvoiceDetail'));
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate')); const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/ExternalMyInvoiceCreate'));
const Projects = lazy(() => import('./pages/Projects')); const ProjectDetailPage = lazy(() => import('./pages/ProjectDetail'));
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard')); const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard'));
const RequestsPage = lazy(() => import('./pages/RequestsPage')); const RequestsPage = lazy(() => import('./pages/Tasks'));
const MyInvoices = lazy(() => import('./pages/client/MyInvoices')); const ClientMyInvoices = lazy(() => import('./pages/client/ClientMyInvoices'));
const NewRequest = lazy(() => import('./pages/client/NewRequest'));
const NewProject = lazy(() => import('./pages/client/NewProject'));
function RedirectProjectDetail() { function RedirectProjectDetail() {
const { id } = useParams(); const { id } = useParams();
return <Navigate to={`/projects/${id}`} replace />; return <Navigate to={`/projects/${id}`} replace />;
} }
function RedirectRequestDetail() { function RedirectClientDetail() {
const { id } = useParams(); const { id } = useParams();
return <Navigate to={`/requests/${id}`} replace />; 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() { function NavigateCompanyDetail() {
@@ -82,11 +92,7 @@ function NavigateCompanyDetail() {
return <Navigate to={`/company/${id}`} replace />; return <Navigate to={`/company/${id}`} replace />;
} }
function DashboardRoute() {
const { currentUser } = useAuth();
if (currentUser?.role === 'team') return <Navigate to="/team/dashboard" replace />;
return <DashboardPage />;
}
export default function App() { export default function App() {
return ( return (
@@ -97,37 +103,42 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Login />} /> <Route path="/" element={<Login />} />
<Route path="/dashboard" element={<ProtectedRoute role={['external', 'client']}><DashboardRoute /></ProtectedRoute>} /> <Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/team/dashboard" element={<ProtectedRoute role={['team']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute role={['team', 'external', 'client']}><Projects /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} /> <Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/tasks/:id" element={<RedirectRequestDetail />} /> <Route path="/clients/:id" element={<RedirectClientDetail />} />
<Route path="/requests/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestDetail /></ProtectedRoute>} /> <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" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} /> <Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
<Route path="/companies" element={<Navigate to="/company" replace />} /> <Route path="/companies" element={<Navigate to="/company" replace />} />
<Route path="/companies/:id" element={<NavigateCompanyDetail />} /> <Route path="/companies/:id" element={<NavigateCompanyDetail />} />
<Route path="/requests" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} /> <Route path="/team/tasks" element={<Navigate to="/tasks" replace />} />
<Route path="/team-projects" element={<Navigate to="/projects" replace />} /> <Route path="/tasks" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} /> <Route path="/requests" element={<Navigate to="/tasks" replace />} />
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} /> <Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><CreateSubcontractorPO /></ProtectedRoute>} /> <Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><InvoiceDetail /></ProtectedRoute>} /> <Route path="/finances/new" element={<Navigate to="/finances" replace />} />
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><SubcontractorPODetail /></ProtectedRoute>} /> <Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><SubInvoiceDetail /></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="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} /> <Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></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="/fourge-passwords" element={<ProtectedRoute role="team"><TeamFourgePasswords /></ProtectedRoute>} />
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} /> <Route path="/reports" element={<ProtectedRoute role="team"><TeamReports /></ProtectedRoute>} />
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><FourgePasswords /></ProtectedRoute>} />
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} /> <Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
<Route path="/assigned-requests" element={<Navigate to="/requests" replace />} /> <Route path="/assigned-requests" element={<Navigate to="/tasks" replace />} />
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><MyPurchaseOrders /></ProtectedRoute>} /> <Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><ExternalMyPurchaseOrders /></ProtectedRoute>} />
<Route path="/my-projects-sub" element={<Navigate to="/projects" replace />} /> <Route path="/my-projects-sub" element={<Navigate to="/tasks" replace />} />
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} /> <Route path="/subs-invoices" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} /> <Route path="/subs-invoices/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></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="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} /> <Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} /> <Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
@@ -135,13 +146,14 @@ export default function App() {
<Route path="/my-dashboard" element={<Navigate to="/dashboard" replace />} /> <Route path="/my-dashboard" element={<Navigate to="/dashboard" replace />} />
<Route path="/my-company" element={<Navigate to="/company" replace />} /> <Route path="/my-company" element={<Navigate to="/company" replace />} />
<Route path="/my-requests" element={<Navigate to="/requests" replace />} /> <Route path="/my-requests" element={<Navigate to="/tasks" replace />} />
<Route path="/my-requests/:id" element={<RedirectRequestDetail />} /> <Route path="/my-requests/:id" element={<RedirectToTask />} />
<Route path="/my-projects" element={<Navigate to="/projects" replace />} /> <Route path="/my-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} /> <Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
<Route path="/my-invoices" element={<ProtectedRoute role="client"><MyInvoices /></ProtectedRoute>} /> <Route path="/client-invoices" element={<ProtectedRoute role="client"><ClientMyInvoices /></ProtectedRoute>} />
<Route path="/new-request" element={<ProtectedRoute role="client"><NewRequest /></ProtectedRoute>} /> <Route path="/my-invoices" element={<Navigate to="/client-invoices" replace />} />
<Route path="/new-project" element={<ProtectedRoute role="client"><NewProject /></ProtectedRoute>} /> <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="/pay/:id" element={<PayInvoice />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
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

+13 -12
View File
@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'; import { useId, useRef, useState } from 'react';
const MAX_FILES = 20; const MAX_FILES = 20;
const MAX_SIZE_MB = 250; const MAX_SIZE_MB = 250;
@@ -10,6 +10,7 @@ const formatSize = (bytes) => {
}; };
export default function FileAttachment({ files, onChange }) { export default function FileAttachment({ files, onChange }) {
const inputId = useId();
const [errors, setErrors] = useState([]); const [errors, setErrors] = useState([]);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const dragCounter = useRef(0); const dragCounter = useRef(0);
@@ -60,12 +61,12 @@ export default function FileAttachment({ files, onChange }) {
return ( return (
<div className="form-group"> <div className="form-group">
<label> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>
Attach Files 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 Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each · Any file type
</span> </span>
</label> </div>
<div <div
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
@@ -74,15 +75,15 @@ export default function FileAttachment({ files, onChange }) {
onDrop={handleDrop} onDrop={handleDrop}
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, padding: '18px 16px', textAlign: 'center', borderRadius: 8, padding: '16px', textAlign: 'center',
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)', background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent',
transition: 'all 0.15s', transition: 'all 160ms', cursor: 'pointer',
}} }}
> >
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" /> <input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id={inputId} />
<label htmlFor="req-file-upload" style={{ cursor: 'pointer' }}> <label htmlFor={inputId} style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
<div style={{ fontSize: 22, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div> <div style={{ fontSize: 20, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)' }}> <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{dragging {dragging
? 'Drop files here' ? 'Drop files here'
: files.length > 0 : files.length > 0
@@ -102,7 +103,7 @@ export default function FileAttachment({ files, onChange }) {
{files.map((file, i) => ( {files.map((file, i) => (
<div key={i} style={{ <div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, 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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📄</span> <span>📄</span>
-664
View File
@@ -1,664 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import LoadingButton from './LoadingButton';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!value) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatDate(dt) {
if (!dt) return '—';
return new Date(dt).toLocaleDateString();
}
function fileIconStyle(ext) {
const e = ext.toLowerCase();
if (['jpg','jpeg','png','gif','webp','svg','ico','bmp','tiff','avif','heic'].includes(e)) return { bg: '#16a34a', color: '#fff' };
if (['mp4','mov','avi','mkv','webm','m4v','wmv','flv'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
if (['mp3','wav','ogg','flac','aac','m4a','wma'].includes(e)) return { bg: '#db2777', color: '#fff' };
if (e === 'pdf') return { bg: '#dc2626', color: '#fff' };
if (['doc','docx','rtf','odt'].includes(e)) return { bg: '#2563eb', color: '#fff' };
if (['txt','md'].includes(e)) return { bg: '#64748b', color: '#fff' };
if (['xls','xlsx','csv','ods','numbers'].includes(e)) return { bg: '#16a34a', color: '#fff' };
if (['ppt','pptx','odp','key'].includes(e)) return { bg: '#ea580c', color: '#fff' };
if (['zip','rar','7z','tar','gz','bz2','xz'].includes(e)) return { bg: '#92400e', color: '#fff' };
if (['js','ts','jsx','tsx','html','css','scss','json','py','rb','php','java','c','cpp','cs','go','rs'].includes(e)) return { bg: '#0891b2', color: '#fff' };
if (['ttf','otf','woff','woff2'].includes(e)) return { bg: '#6b7280', color: '#fff' };
if (['ai','eps'].includes(e)) return { bg: '#ff6c00', color: '#fff' };
if (['psd','psb'].includes(e)) return { bg: '#001e36', color: '#31a8ff' };
if (['indd','idml'].includes(e)) return { bg: '#49021f', color: '#ff3366' };
if (['fig','sketch','xd'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
return { bg: '#475569', color: '#fff' };
}
function FileIcon({ entry }) {
if (entry.type === 'dir') return <span className="file-icon">📁</span>;
const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE';
const { bg, color } = fileIconStyle(ext);
return (
<span className="file-icon" style={{ background: bg, color, border: 'none', fontSize: 9, fontWeight: 400, letterSpacing: 0.4, fontFamily: 'monospace' }}>
{ext}
</span>
);
}
function pathParts(path) {
return String(path || '/').split('/').filter(Boolean);
}
function pathTo(index, parts) {
return `/${parts.slice(0, index + 1).join('/')}`;
}
function encodeFbPath(path) {
return path.split('/').map(p => encodeURIComponent(p)).join('/');
}
function joinVirtualPath(...parts) {
return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/';
}
export default function FileBrowser({ initialPath = '/', rootPath = '/', showSync = false }) {
const { currentUser } = useAuth();
const [currentPath, setCurrentPath] = useState(initialPath);
const [entries, setEntries] = useState([]);
const [configured, setConfigured] = useState(true);
const [parentPath, setParentPath] = useState('/');
const [canGoUp, setCanGoUp] = useState(false);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState('');
const [dragging, setDragging] = useState(false);
const [error, setError] = useState('');
const [readOnly, setReadOnly] = useState(false);
const [folderName, setFolderName] = useState('');
const [showFolderInput, setShowFolderInput] = useState(false);
const [movingEntry, setMovingEntry] = useState(null);
const [renamingEntry, setRenamingEntry] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [draggedEntry, setDraggedEntry] = useState(null);
const [dragOverFolder, setDragOverFolder] = useState(null);
const [uploadProgress, setUploadProgress] = useState(null);
const fileInputRef = useRef(null);
const folderInputRef = useRef(null);
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
const apiFetch = async (url, options = {}) => {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) throw new Error('Your session expired. Please sign in again.');
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${session.access_token}`,
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || 'File request failed.');
return data;
};
const loadFiles = async (path = currentPath) => {
setLoading(true);
setError('');
try {
const params = new URLSearchParams({ action: 'list', path });
const data = await apiFetch(`/api/filebrowser?${params}`);
setConfigured(data.configured !== false);
setEntries(data.entries || []);
setCurrentPath(data.path || '/');
setParentPath(data.parentPath || '/');
setCanGoUp(data.canGoUp || false);
setReadOnly(data.readOnly || false);
if (data.configured === false) setError(data.error || 'FileBrowser is not configured.');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFiles(initialPath);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialPath]);
const openFolder = (entry) => {
if (entry.type === 'dir') loadFiles(entry.path);
};
const downloadFile = async (entry) => {
setWorking(`download:${entry.path}`);
setError('');
try {
const data = await apiFetch(`/api/filebrowser?action=download&path=${encodeURIComponent(entry.path)}`);
if (data.url && data.token) {
// Append token as query param for browser direct download
const sep = data.url.includes('?') ? '&' : '?';
const a = document.createElement('a');
a.href = `${data.url}${sep}auth=${encodeURIComponent(data.token)}`;
a.download = entry.name;
a.target = '_blank';
a.rel = 'noopener noreferrer';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
} catch (err) {
setError(err.message);
} finally {
setWorking('');
}
};
const deleteEntry = async (entry) => {
const kind = entry.type === 'dir' ? 'folder' : 'file';
if (!window.confirm(`Delete "${entry.name}" ${kind}? This cannot be undone.`)) return;
setWorking(`delete:${entry.path}`);
setError('');
try {
await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, {
method: 'DELETE',
});
await loadFiles(currentPath);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
}
};
const createFolder = async (e) => {
e.preventDefault();
if (!folderName.trim()) return;
setWorking('mkdir');
setError('');
try {
await apiFetch('/api/filebrowser?action=mkdir', {
method: 'POST',
body: JSON.stringify({ path: currentPath, name: folderName }),
});
setFolderName('');
setShowFolderInput(false);
await loadFiles(currentPath);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
}
};
const renameEntry = async (e) => {
e.preventDefault();
const newName = renameValue.trim();
if (!newName || newName === renamingEntry.name) {
setRenamingEntry(null);
return;
}
setWorking(`rename:${renamingEntry.path}`);
setError('');
try {
await apiFetch('/api/filebrowser?action=rename', {
method: 'POST',
body: JSON.stringify({ path: renamingEntry.path, name: newName }),
});
setRenamingEntry(null);
await loadFiles(currentPath);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
}
};
const startRename = (entry) => {
setMovingEntry(null);
setRenamingEntry(entry);
setRenameValue(entry.name);
};
async function uploadOneFile(url, token, fbPath, file, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const res = await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(fbPath)}&override=true`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
},
body: file,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${text || res.status}`);
}
return;
} catch (e) {
if (attempt === retries) throw new Error(`Upload failed for ${file.name} after ${retries} attempts: ${e.message}`);
await new Promise(r => setTimeout(r, attempt * 1000));
}
}
}
async function runConcurrent(tasks, concurrency = 2) {
let index = 0;
let done = 0;
const total = tasks.length;
const errors = [];
await Promise.all(Array.from({ length: concurrency }, async () => {
while (index < total) {
const task = tasks[index++];
try { await task(); } catch (e) { errors.push(e); }
done++;
setUploadProgress(Math.round((done / total) * 100));
}
}));
if (errors.length) throw errors[0];
}
// Upload files directly to FileBrowser using admin token
const uploadFiles = async (files) => {
const selected = Array.from(files || []);
if (!selected.length) return;
setWorking('upload');
setUploadProgress(0);
setError('');
try {
const tokenData = await apiFetch('/api/filebrowser?action=upload-token', {
method: 'POST',
body: JSON.stringify({ path: currentPath }),
});
const { token, url, fbPath } = tokenData;
const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.name), file));
await runConcurrent(tasks);
setUploadProgress(100);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
setUploadProgress(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setDragging(false);
await loadFiles(currentPath);
}
};
const uploadFolder = async (files) => {
const selected = Array.from(files || []).filter(f => f.webkitRelativePath);
if (!selected.length) return;
setWorking('upload');
setUploadProgress(0);
setError('');
try {
const tokenData = await apiFetch('/api/filebrowser?action=upload-token', {
method: 'POST',
body: JSON.stringify({ path: currentPath }),
});
const { token, url, fbPath } = tokenData;
// Create directories sequentially shallow-first
const dirsNeeded = new Set();
for (const file of selected) {
const parts = file.webkitRelativePath.split('/').slice(0, -1);
for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/'));
}
const sortedDirs = [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) {
const dirFbPath = joinVirtualPath(fbPath, dir);
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(dirFbPath)}&isDir=true`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
// Upload files concurrently
const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.webkitRelativePath), file));
await runConcurrent(tasks);
setUploadProgress(100);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
setUploadProgress(null);
if (folderInputRef.current) folderInputRef.current.value = '';
await loadFiles(currentPath);
}
};
const moveEntry = async (entry, targetFolderPath) => {
setWorking(`move:${entry.path}`);
setError('');
try {
await apiFetch('/api/filebrowser?action=move', {
method: 'POST',
body: JSON.stringify({ srcPath: entry.path, dstPath: targetFolderPath }),
});
setMovingEntry(null);
await loadFiles(currentPath);
} catch (err) {
setError(err.message);
} finally {
setWorking('');
}
};
const handleDragEnter = (e) => {
e.preventDefault();
if (!configured || loading || working || draggedEntry || readOnly) return;
setDragging(true);
};
const handleDragOver = (e) => {
e.preventDefault();
if (!configured || loading || working || draggedEntry || readOnly) return;
e.dataTransfer.dropEffect = 'copy';
setDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
};
const readFsEntry = (entry) => new Promise((resolve) => {
if (entry.isFile) {
entry.file(file => {
const rel = entry.fullPath.replace(/^\//, '');
Object.defineProperty(file, 'webkitRelativePath', { value: rel, writable: false, configurable: true });
resolve([file]);
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
const readAll = (acc) => reader.readEntries(async (entries) => {
if (!entries.length) { resolve(acc); return; }
const nested = await Promise.all(entries.map(readFsEntry));
readAll([...acc, ...nested.flat()]);
});
readAll([]);
} else {
resolve([]);
}
});
const handleDrop = async (e) => {
e.preventDefault();
setDragging(false);
if (draggedEntry) return;
if (!configured || loading || working) return;
const items = Array.from(e.dataTransfer.items || []);
const fsEntries = items.map(item => item.webkitGetAsEntry?.()).filter(Boolean);
if (fsEntries.length && fsEntries.some(en => en.isDirectory)) {
const allFiles = (await Promise.all(fsEntries.map(readFsEntry))).flat();
uploadFolder(allFiles);
} else {
if (!e.dataTransfer.files?.length) return;
uploadFiles(e.dataTransfer.files);
}
};
const handleRowDragStart = (e, entry) => {
e.stopPropagation();
setDraggedEntry(entry);
e.dataTransfer.effectAllowed = 'move';
};
const handleRowDragEnd = () => {
setDraggedEntry(null);
setDragOverFolder(null);
};
const handleFolderDragOver = (e, folder) => {
if (!draggedEntry || draggedEntry.path === folder.path) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
setDragOverFolder(folder.path);
};
const handleFolderDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null);
};
const handleFolderDrop = (e, folder) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(null);
if (!draggedEntry || draggedEntry.path === folder.path) return;
const entry = draggedEntry;
setDraggedEntry(null);
moveEntry(entry, folder.path);
};
return (
<section
className={`file-browser${dragging ? ' file-browser-dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{(loading || working || uploadProgress !== null) && (
<div className="file-browser-progress">
<div
className={`file-browser-progress-bar${uploadProgress === null ? ' indeterminate' : ''}`}
style={uploadProgress !== null ? { width: `${uploadProgress}%` } : undefined}
/>
</div>
)}
{dragging && (
<div className="file-drop-overlay">
<div className="file-drop-panel">
<div className="file-drop-icon"></div>
<div className="file-drop-title">Drop files to upload</div>
<div className="file-drop-subtitle">Files will be added to the current folder.</div>
</div>
</div>
)}
<div className="file-browser-toolbar">
<div className="file-browser-breadcrumbs">
<button type="button" onClick={() => loadFiles(rootPath)} className="file-breadcrumb">Files</button>
{breadcrumbs.slice(pathParts(rootPath).length).map((part, index) => {
const absIndex = pathParts(rootPath).length + index;
return (
<button type="button" key={`${part}-${absIndex}`} onClick={() => loadFiles(pathTo(absIndex, breadcrumbs))} className="file-breadcrumb">
{part}
</button>
);
})}
</div>
<div className="file-browser-actions">
{!readOnly && (showFolderInput ? (
<form style={{ display: 'flex', gap: 6 }} onSubmit={createFolder}>
<input
type="text"
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
placeholder="Folder name"
autoFocus
disabled={!configured || loading || Boolean(working)}
style={{ fontSize: 13, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', width: 160 }}
/>
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === 'mkdir'} disabled={!folderName.trim() || !configured || loading || Boolean(working)} loadingText="Creating...">
Create
</LoadingButton>
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
</form>
) : (
<button className="btn btn-outline btn-sm" disabled={!configured || loading || Boolean(working)} onClick={() => setShowFolderInput(true)}>
+ New Folder
</button>
))}
<LoadingButton className="btn btn-outline btn-sm" loading={loading} disabled={Boolean(working)} loadingText="Refreshing..." onClick={() => loadFiles(currentPath)}>
Refresh
</LoadingButton>
{entries.length > 0 && (
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${currentPath}`} disabled={Boolean(working)} loadingText="Zipping..." onClick={() => downloadFile({ path: currentPath, name: breadcrumbs[breadcrumbs.length - 1] || 'files', type: 'dir' })}>
ZIP
</LoadingButton>
)}
{!readOnly && (
<>
<input ref={fileInputRef} type="file" multiple className="file-upload-input" onChange={(e) => uploadFiles(e.target.files)} />
<input ref={folderInputRef} type="file" className="file-upload-input" onChange={(e) => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
<LoadingButton className="btn btn-outline btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => folderInputRef.current?.click()}>
Folder
</LoadingButton>
<LoadingButton className="btn btn-primary btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => fileInputRef.current?.click()}>
Files
</LoadingButton>
</>
)}
</div>
</div>
{uploadProgress !== null && (
<div style={{ padding: '4px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
Uploading... {uploadProgress}%
</div>
)}
{error && <div className="notification notification-info">{error}</div>}
{draggedEntry && (
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
Dragging "{draggedEntry.name}" drop onto a folder to move it
</div>
)}
<div className="file-list">
{canGoUp && currentPath !== rootPath && (
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
<span className="file-icon"></span>
<span className="file-name">Up one folder</span>
<span className="file-meta"></span>
<span className="file-meta"></span>
<span />
</button>
)}
<div className="file-row file-row-head">
<span />
<span>Name</span>
<span style={{ textAlign: 'right' }}>Size</span>
<span style={{ textAlign: 'right' }}>Modified</span>
<span />
</div>
{loading ? (
<div className="empty-state">Loading files...</div>
) : entries.length === 0 ? (
<div className="empty-state">
<h3>No files here yet</h3>
<p>Upload files or create a folder to start this workspace.</p>
</div>
) : entries.map(entry => {
const isMoving = movingEntry?.path === entry.path;
const isRenaming = renamingEntry?.path === entry.path;
const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
const isDragTarget = entry.type === 'dir' && draggedEntry && draggedEntry.path !== entry.path;
const isDragOver = dragOverFolder === entry.path;
return (
<div
className={`file-row${isDragOver ? ' file-row-drag-over' : ''}`}
key={`${entry.type}:${entry.path}`}
draggable={!working}
onDragStart={(e) => handleRowDragStart(e, entry)}
onDragEnd={handleRowDragEnd}
onDragOver={isDragTarget ? (e) => handleFolderDragOver(e, entry) : undefined}
onDragLeave={isDragTarget ? handleFolderDragLeave : undefined}
onDrop={isDragTarget ? (e) => handleFolderDrop(e, entry) : undefined}
style={isDragOver ? { outline: '2px solid var(--accent)', borderRadius: 4 } : undefined}
>
<FileIcon entry={entry} />
{isRenaming ? (
<form style={{ display: 'flex', gap: 6, flex: 1 }} onSubmit={renameEntry}>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
autoFocus
disabled={Boolean(working)}
style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }}
onKeyDown={(e) => { if (e.key === 'Escape') setRenamingEntry(null); }}
/>
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === `rename:${entry.path}`} disabled={!renameValue.trim() || Boolean(working)} loadingText="Renaming...">
Save
</LoadingButton>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setRenamingEntry(null)}>Cancel</button>
</form>
) : entry.type === 'dir' ? (
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
{entry.name}
</button>
) : (
<span className="file-name">{entry.name}</span>
)}
{!isRenaming && (
<>
<span className="file-meta">{formatBytes(entry.size)}</span>
<span className="file-meta">{formatDate(entry.mtime)}</span>
<span className="file-row-actions">
{readOnly ? null : isMoving ? (
<>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
{targetFolders.length === 0 ? (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
) : targetFolders.map(folder => (
<LoadingButton
key={folder.path}
className="btn btn-outline btn-sm"
loading={working === `move:${entry.path}`}
disabled={Boolean(working)}
loadingText="Moving..."
onClick={() => moveEntry(entry, folder.path)}
>
{folder.name}
</LoadingButton>
))}
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}></button>
</>
) : (
<>
<LoadingButton className="btn-icon" title={entry.type === 'dir' ? 'Download ZIP' : 'Download'} loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="↓" onClick={() => downloadFile(entry)}>
</LoadingButton>
<button type="button" className="btn-icon" title="Rename" disabled={Boolean(working)} onClick={() => startRename(entry)}>
<svg width="17" height="17" 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>
<LoadingButton className="btn-icon btn-icon-danger" title="Delete" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="…" onClick={() => deleteEntry(entry)}>
</LoadingButton>
</>
)}
</span>
</>
)}
</div>
);
})}
</div>
</section>
);
}
+63 -16
View File
@@ -1,36 +1,83 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
const FilterIcon = () => ( const FilterIcon = () => (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <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"/> <path d="M2 4h12M4 8h8M6 12h4"/>
</svg> </svg>
); );
export default function FilterDropdown({ value, onChange, options }) { export default function FilterDropdown({ value, onChange, options }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef(null); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
function handler(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); } function onDown(e) {
document.addEventListener('mousedown', handler); if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
return () => document.removeEventListener('mousedown', handler); setOpen(false);
}, [open]); }
const current = options.find(o => o.value === value); 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 ( return (
<div ref={ref} style={{ position: 'relative' }}> <>
<button className="btn btn-outline btn-sm" onClick={() => setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <button
ref={btnRef}
type="button"
onClick={() => { if (!open) place(); 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 /> <FilterIcon />
{current?.label}
</button> </button>
{open && ( {open && pos && createPortal(
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, zIndex: 200, minWidth: 160, boxShadow: '0 4px 12px rgba(0,0,0,0.25)' }}> <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 => ( {options.map(opt => (
<button key={opt.value} onClick={() => { onChange(opt.value); setOpen(false); }} style={{ display: 'block', width: '100%', padding: '7px 14px', textAlign: 'left', background: value === opt.value ? 'rgba(245,165,35,0.08)' : 'transparent', fontSize: 13, color: value === opt.value ? 'var(--accent)' : 'var(--text-primary)', border: 'none', cursor: 'pointer' }}> <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} {opt.label}
</button> </button>
))} ))}
</div> </div>,
document.body
)} )}
</div> </>
); );
} }
+66
View File
@@ -0,0 +1,66 @@
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import PageLoader from './PageLoader';
export default function InvoiceDetailPopup({
title,
subtitle,
headerRight,
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}>
<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
+167 -56
View File
@@ -1,17 +1,19 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import PageLoader from './PageLoader';
import ProfileAvatar from './ProfileAvatar';
import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
const ICONS = { 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>, 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>, 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>, 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>,
fileSharing: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6L9 2z"/><polyline points="9,2 9,6 13.5,6"/></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>, 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>, 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>, 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>, 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>, 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>, 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>, 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>, 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>,
@@ -25,34 +27,32 @@ function TeamNav({ onNav }) {
const location = useLocation(); const location = useLocation();
const primaryLinks = [ const primaryLinks = [
{ to: '/team/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/requests', label: 'Requests', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/projects', label: 'Projects', icon: ICONS.projects }, { to: '/finances', label: 'Finances', icon: ICONS.invoices },
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
]; ];
const utilityLinks = [ const utilityLinks = [
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey }, { to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook }, { to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter }, { to: '/converters', label: 'Image Converter', icon: ICONS.converter },
{ to: '/reports', label: 'Reports', icon: ICONS.reports },
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords }, { to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
]; ];
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users'); const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
const isUsersActive = 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 ( return (
<div className="sidebar-section"> <div className="sidebar-section">
{primaryLinks.map(({ to, label, icon }) => ( {primaryLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <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> <NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink> </NavLink>
))} ))}
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} /> <div className="sidebar-tools-divider" />
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}> <div className="sidebar-tools-label">Tools</div>
Tools
</div>
{utilityLinks.map(({ to, label, icon }) => ( {utilityLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span> <NI icon={icon} /><span className="nav-label">{label}</span>
@@ -69,18 +69,18 @@ function TeamNav({ onNav }) {
} }
function ClientNav({ onNav }) { 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 = [ const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/requests', label: 'Requests', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/projects', label: 'Projects', icon: ICONS.projects }, { to: '/client-invoices', label: 'Invoices', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
{ to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
]; ];
return ( return (
<div className="sidebar-section"> <div className="sidebar-section">
{links.map(({ to, label, icon }) => ( {links.map(({ to, label, icon }) => (
<NavLink key={label} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <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> <NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink> </NavLink>
))} ))}
@@ -89,12 +89,12 @@ function ClientNav({ onNav }) {
} }
function ExternalNav({ 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 = [ const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/requests', label: 'Requests', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
{ to: '/projects', label: 'Projects', icon: ICONS.projects }, { to: '/subs-invoices', label: 'Invoices', icon: ICONS.invoices },
{ to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices },
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey }, { to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook }, { to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter }, { to: '/converters', label: 'Image Converter', icon: ICONS.converter },
@@ -106,13 +106,11 @@ function ExternalNav({ onNav }) {
<div key={to}> <div key={to}>
{index === 4 && ( {index === 4 && (
<> <>
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} /> <div className="sidebar-tools-divider" />
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}> <div className="sidebar-tools-label">Tools</div>
Tools
</div>
</> </>
)} )}
<NavLink to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <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> <NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink> </NavLink>
</div> </div>
@@ -121,16 +119,22 @@ 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 { currentUser, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark'); const [theme, setTheme] = useState(getInitialTheme);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [] }); const [searchResults, setSearchResults] = useState({ projects: [], tasks: [], companies: [], people: [] });
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [searchExpanded, setSearchExpanded] = useState(false);
const searchInputRef = useRef(null); const searchInputRef = useRef(null);
const [avatarOpen, setAvatarOpen] = useState(false); const [avatarOpen, setAvatarOpen] = useState(false);
const avatarRef = useRef(null); const avatarRef = useRef(null);
@@ -139,25 +143,72 @@ export default function Layout({ children }) {
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening'; const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
const firstName = currentUser?.name?.split(' ')[0] || ''; const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/'); const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
const headerTitle = isProfileRoute ? 'Profile' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`; 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 const headerSubtitle = isProfileRoute
? 'Account details and security settings.' ? 'Account details and security settings.'
: "Here's what's happening today."; : 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(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
}, [theme]); }, [theme]);
useEffect(() => { useEffect(() => {
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [] }); return; } if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [], companies: [], people: [] }); return; }
const t = setTimeout(async () => { const t = setTimeout(async () => {
const { supabase } = await import('../lib/supabase'); const { supabase } = await import('../lib/supabase');
const [{ data: projects }, { data: tasks }] = await Promise.all([ 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('projects').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
supabase.from('tasks').select('id, title').ilike('title', `%${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 || [] }); setSearchResults({ projects: projects || [], tasks: tasks || [], companies: companies || [], people: people || [] });
}, 280); }, 280);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [searchQuery]); }, [searchQuery]);
@@ -171,6 +222,9 @@ export default function Layout({ children }) {
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [avatarOpen]); }, [avatarOpen]);
const [navigating, setNavigating] = useState(false);
const navTimerRef = useRef(null);
const [lastPathname, setLastPathname] = useState(location.pathname); const [lastPathname, setLastPathname] = useState(location.pathname);
if (lastPathname !== location.pathname) { if (lastPathname !== location.pathname) {
setLastPathname(location.pathname); setLastPathname(location.pathname);
@@ -178,13 +232,25 @@ export default function Layout({ children }) {
setAvatarOpen(false); setAvatarOpen(false);
} }
useEffect(() => {
setNavigating(true);
clearTimeout(navTimerRef.current);
navTimerRef.current = setTimeout(() => setNavigating(false), 500);
return () => clearTimeout(navTimerRef.current);
}, [location.pathname]);
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark'); const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const handleLogout = async () => { await logout(); navigate('/'); }; const handleLogout = async () => { await logout(); navigate('/'); };
const hasResults = searchResults.projects.length > 0 || searchResults.tasks.length > 0; const hasResults =
searchResults.projects.length > 0 ||
searchResults.tasks.length > 0 ||
searchResults.companies.length > 0 ||
searchResults.people.length > 0;
return ( return (
<div className="app-layout"> <div className="app-layout">
{(navigating || loading) && <PageLoader />}
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />} {menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}> <aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
@@ -199,6 +265,27 @@ export default function Layout({ children }) {
: <ClientNav onNav={() => setMenuOpen(false)} /> : <ClientNav onNav={() => setMenuOpen(false)} />
} }
<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>
</aside> </aside>
<div className="main-wrapper"> <div className="main-wrapper">
@@ -206,40 +293,42 @@ export default function Layout({ children }) {
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu"> <button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
<span /><span /><span /> <span /><span /><span />
</button> </button>
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
</div> </div>
<main className="main-content"> <main className="main-content">
<div className="site-header"> <div className="site-header">
<div> <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-greeting">{headerTitle}</div>
<div className="site-header-sub">{headerSubtitle}</div> <div className="site-header-sub">{headerSubtitle}</div>
</>
)}
</div> </div>
<div className="site-header-right"> <div className="site-header-right">
<div className="site-header-search-wrap"> <div className="site-header-search-wrap">
{!searchExpanded ? ( <span className="site-header-search-icon" aria-hidden="true">
<button
className="site-header-search-btn"
onClick={() => { setSearchExpanded(true); setTimeout(() => searchInputRef.current?.focus(), 50); }}
aria-label="Search"
>
<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> <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>
</button> </span>
) : (
<>
<input <input
ref={searchInputRef} ref={searchInputRef}
className="site-header-search" className="site-header-search"
type="text" type="text"
placeholder="Search projects & tasks..." placeholder="Search"
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
onFocus={() => setSearchOpen(true)} onFocus={() => setSearchOpen(true)}
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); }, 150)} onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchQuery(''); }, 150)}
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); } }} onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchQuery(''); } }}
/> />
</>
)}
{searchOpen && searchQuery.trim() && ( {searchOpen && searchQuery.trim() && (
<div className="site-header-dropdown"> <div className="site-header-dropdown">
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>} {!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
@@ -247,7 +336,7 @@ export default function Layout({ children }) {
<> <>
<div className="site-header-dropdown-group">Projects</div> <div className="site-header-dropdown-group">Projects</div>
{searchResults.projects.map(p => ( {searchResults.projects.map(p => (
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}> <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> <span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
{p.name} {p.name}
</div> </div>
@@ -258,13 +347,35 @@ export default function Layout({ children }) {
<> <>
<div className="site-header-dropdown-group">Tasks</div> <div className="site-header-dropdown-group">Tasks</div>
{searchResults.tasks.map(r => ( {searchResults.tasks.map(r => (
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}> <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> <span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
{r.title} {r.title}
</div> </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>
)} )}
</div> </div>
@@ -277,12 +388,12 @@ export default function Layout({ children }) {
</button> </button>
<div className="site-header-avatar-wrap" ref={avatarRef}> <div className="site-header-avatar-wrap" ref={avatarRef}>
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu"> <button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu" style={{ overflow: 'hidden' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" fill="currentColor"/></svg> <ProfileAvatar profile={currentUser} name={currentUser?.name} size={68} fontSize={20} />
</button> </button>
{avatarOpen && ( {avatarOpen && (
<div className="site-header-avatar-menu"> <div className="site-header-avatar-menu">
<button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Settings</button> <button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Profile</button>
<div className="site-header-avatar-divider" /> <div className="site-header-avatar-divider" />
<button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button> <button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button>
</div> </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 ( return (
<div <div style={{
style={{ position: isContainerScope ? 'absolute' : 'fixed',
minHeight: '100vh', inset: 0,
background: 'var(--bg, #111)', zIndex: isContainerScope ? 20 : 1200,
background: 'var(--overlay-scrim)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}} padding: 24,
> borderRadius: isContainerScope ? 'inherit' : 0,
<img src="/fourge-logo.png" alt="Loading" style={{ width: 160, opacity }} /> }}>
<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> </div>
); );
} }
+68
View File
@@ -0,0 +1,68 @@
function normalizeName(value) {
return String(value || '').trim().toLowerCase();
}
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>
);
}
View File
+205 -62
View File
@@ -5,10 +5,15 @@ import FileAttachment from './FileAttachment';
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates'; import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3); 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 = '') => ({ const emptyForm = (companyId = '') => ({
companyId, companyId,
project: '', project: '',
serviceType: '', serviceType: '',
signFamily: '',
title: '', title: '',
deadline: defaultDeadline(), deadline: defaultDeadline(),
description: '', description: '',
@@ -36,19 +41,31 @@ export default function RequestForm({
error = '', error = '',
submitLabel = 'Submit Request', submitLabel = 'Submit Request',
initialCompanyId = '', initialCompanyId = '',
initialValues = null,
lockedFields = [],
}) { }) {
const [form, setForm] = useState(() => emptyForm(initialCompanyId)); const [form, setForm] = useState(() => ({
...emptyForm(initialCompanyId),
requestedBy: showRequester ? (currentUser?.id || '') : '',
...(initialValues || {}),
}));
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [existingProjects, setExistingProjects] = useState([]); const [existingProjects, setExistingProjects] = useState([]);
const [customProjects, setCustomProjects] = useState([]); const [customProjects, setCustomProjects] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false); const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState(''); const [newProjectName, setNewProjectName] = useState('');
const [companyUsers, setCompanyUsers] = useState([]); const [companyUsers, setCompanyUsers] = useState([]);
const [signCount, setSignCount] = useState('');
const [signs, setSigns] = useState([]);
const [localError, setLocalError] = useState('');
const companyId = form.companyId || initialCompanyId; const usesSignFields = form.serviceType === 'Brand Book';
// Single-company user: lock selection to their one company (derived, not stored).
const companyId = form.companyId || (companies.length === 1 ? companies[0].id : '') || initialCompanyId;
useEffect(() => { useEffect(() => {
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; } if (!companyId) return;
Promise.all([ Promise.all([
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'), supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
showRequester showRequester
@@ -72,17 +89,53 @@ export default function RequestForm({
setCompanyUsers(merged); setCompanyUsers(merged);
} }
}); });
setForm(f => ({ ...f, project: '', requestedBy: '' })); }, [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));
};
// Sign rows follow the count field; resized on edit rather than in an effect.
const handleSignCountChange = (e) => {
const raw = e.target.value;
setSignCount(raw);
const n = Math.max(0, parseInt(raw) || 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;
});
};
const handleServiceTypeChange = (e) => {
set('serviceType')(e);
if (e.target.value !== 'Brand Book') { setSignCount(''); setSigns([]); }
};
// Switching company invalidates the project/requester picked under the old one.
const handleCompanyChange = (e) => {
setForm(f => ({ ...f, companyId: e.target.value, project: '', requestedBy: '' }));
setCustomProjects([]); setCustomProjects([]);
setIsTypingProject(false); setIsTypingProject(false);
setNewProjectName(''); setNewProjectName('');
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps };
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value })); // No company selected yet means nothing loaded applies, so read through as empty.
const activeProjects = companyId ? existingProjects : [];
const activeCompanyUsers = companyId ? companyUsers : [];
const allProjectNames = [ const allProjectNames = [
...existingProjects.map(p => p.name), ...activeProjects.map(p => p.name),
...customProjects.filter(name => !existingProjects.some(p => p.name === name)), ...customProjects.filter(name => !activeProjects.some(p => p.name === name)),
]; ];
const handleProjectSelect = (e) => { const handleProjectSelect = (e) => {
@@ -105,113 +158,203 @@ export default function RequestForm({
setNewProjectName(''); setNewProjectName('');
}; };
const requesterOptions = showRequester ? [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
] : [];
const showCompanySelect = companies.length > 1 || showRequester; const showCompanySelect = companies.length > 1 || showRequester;
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...activeCompanyUsers.filter(u => u.id !== currentUser?.id),
];
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const requester = requesterOptions.find(u => u.id === form.requestedBy); setLocalError('');
const requestedByName = requester ? requester.name.replace(' (You)', '') : ''; const selectedRequester = showRequester
onSubmit({ ...form, companyId, requestedByName }, files, existingProjects); ? 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 ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{showCompanySelect && ( {/* Row 1: Company + Project */}
<div className="grid-2">
{lockedFields.includes('company') ? (
<div className="form-group"> <div className="form-group">
<label>Company *</label> <label style={modalLabelStyle}>Company</label>
<select <input value={companies.find(c => c.id === companyId)?.name || companyId} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
value={form.companyId} </div>
onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} ) : showCompanySelect ? (
required <div className="form-group">
> <label style={modalLabelStyle}>Company *</label>
<select value={companyId} onChange={handleCompanyChange} required style={modalInputStyle}>
<option value="">Select company...</option> <option value="">Select company...</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)} {companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select> </select>
</div> </div>
)} ) : companies.length === 1 ? (
<div className="form-group"> <div className="form-group">
<label>Project *</label> <label style={modalLabelStyle}>Company</label>
{isTypingProject ? ( <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 }}> <div style={{ display: 'flex', gap: 8 }}>
<input <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 }} />
type="text" <button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
placeholder="Enter project name..." <button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
autoFocus
style={{ flex: 1 }}
/>
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
</div> </div>
) : ( ) : (
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId}> <select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId} style={modalInputStyle}>
<option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option> <option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option>
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)} {allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
{(!showCompanySelect || companyId) && <option value="__new__"> Create new project...</option>} {(!showCompanySelect || companyId) && <option value="__new__"> Create new project...</option>}
</select> </select>
)} )}
</div> </div>
</div>
{/* Row 2: Service Type + Desired Deadline */}
<div className="grid-2"> <div className="grid-2">
<div className="form-group"> <div className="form-group">
<label>Service Type *</label> <label style={modalLabelStyle}>Service Type *</label>
<select value={form.serviceType} onChange={set('serviceType')} required> <select value={form.serviceType} onChange={handleServiceTypeChange} required style={modalInputStyle}>
<option value="">Select service...</option> <option value="">Select service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)} {serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Desired Deadline</label> <label style={modalLabelStyle}>Desired Deadline</label>
<input type="date" value={form.deadline} onChange={set('deadline')} /> <input type="date" value={form.deadline} onChange={set('deadline')} style={modalInputStyle} />
</div> </div>
</div> </div>
<div className="form-group" style={{ marginTop: -4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
<span>Mark as Hot</span>
</label>
</div>
{showRequester && ( {showRequester && (
<div className="form-group"> <div className="form-group">
<label>Requested By *</label> <label style={modalLabelStyle}>Requested By *</label>
<select value={form.requestedBy} onChange={set('requestedBy')} disabled={!companyId} required> <select
value={form.requestedBy}
onChange={set('requestedBy')}
required
disabled={!companyId}
style={modalInputStyle}
>
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option> <option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
{requesterOptions.map(user => ( {requesterOptions.map(user => (
<option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option> <option key={user.id} value={user.id}>{user.name}</option>
))} ))}
</select> </select>
</div> </div>
)} )}
<div className="form-group"> {/* Row 3: Title/Location + Mark as Hot inline */}
<label>Request Title *</label> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required /> <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> </div>
{usesSignFields && (
<div className="form-group"> <div className="form-group">
<label>Description *</label> <label style={modalLabelStyle}>Sign Count *</label>
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={{ minHeight: 100 }} required /> <input
type="number"
min="1"
max="50"
placeholder="How many signs?"
value={signCount}
onChange={handleSignCountChange}
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> </div>
<FileAttachment files={files} onChange={setFiles} /> <FileAttachment files={files} onChange={setFiles} />
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {error}</div>} {(localError || error) && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {localError || error}</div>}
<div className="action-buttons"> <div className="action-buttons modal-action-row" style={{ justifyContent: 'flex-end' }}>
<button type="submit" className="btn btn-primary" disabled={saving}> <button type="submit" className="btn btn-outline" disabled={saving} style={modalActionButtonStyle}>
{saving ? 'Submitting...' : submitLabel} {saving ? 'Submitting...' : submitLabel}
</button> </button>
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>} {onCancel && <button type="button" className="btn btn-outline" onClick={onCancel} style={modalActionButtonStyle}>Cancel</button>}
</div> </div>
</form> </form>
); );
+2 -1
View File
@@ -1,7 +1,8 @@
export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) { export default function SortTh({ col, children, sortKey, sortDir, onSort, style, className }) {
const active = sortKey === col; const active = sortKey === col;
return ( return (
<th <th
className={className}
onClick={() => onSort(col)} onClick={() => onSort(col)}
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }} style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
> >
Executable → Regular
+3 -3
View File
@@ -18,10 +18,10 @@ const labels = {
client: 'Client', client: 'Client',
}; };
export default function StatusBadge({ status }) { export default function StatusBadge({ status, label }) {
return ( return (
<span className={`badge badge-${status}`}> <span className={`badge badge-status badge-${status}`}>
{labels[status] || status} {label ?? labels[status] ?? status}
</span> </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>
);
}
+558
View File
@@ -0,0 +1,558 @@
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 { isReviewShadowDescription } from '../lib/taskVersions';
import { useActionLock } from '../hooks/useActionLock';
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const REVISION_RATE = 30;
const ELIGIBLE_TASK_STATUSES = new Set(['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
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: deliveryRows, error: deliveriesError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
// Scoped by who actually delivered each version, not by current task assignment —
// a task can be reassigned mid-flight and the original sub still needs to bill
// for the version(s) they personally delivered.
supabase
.from('deliveries')
.select('version_number, sent_by, submission:submissions!inner(task_id, description, task:tasks!inner(id, title, status, current_version, project:projects(name)))'),
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 (deliveriesError) throw deliveriesError;
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 myName = (currentUser.name || '').trim().toLowerCase();
const unitsByKey = new Map();
for (const delivery of (deliveryRows || [])) {
if ((delivery.sent_by || '').trim().toLowerCase() !== myName) continue;
const submission = delivery.submission;
const task = submission?.task;
if (!task || !ELIGIBLE_TASK_STATUSES.has(task.status)) continue;
if (isReviewShadowDescription(submission.description)) continue;
const version = Number(delivery.version_number || 0);
if (!isCompletedVersionEligible(task, version)) continue;
const key = `${task.id}:${version}`;
if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
unitsByKey.set(key, {
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),
});
}
const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
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>
) : error ? (
<div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</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>
) : error ? (
<p style={{ fontSize: 13, color: 'var(--danger)' }}>{error}</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
+11
View File
@@ -124,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 login = async (email, password) => {
const { error } = await supabase.auth.signInWithPassword({ email, password }); const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message }; if (error) return { error: error.message };
Executable → Regular
+2 -1
View File
@@ -8,10 +8,11 @@ export const mockUsers = [
]; ];
export const serviceTypes = [ export const serviceTypes = [
'Brand Book',
'Sign Family',
'Logo Design', 'Logo Design',
'Brand Identity', 'Brand Identity',
'Brand Guidelines', 'Brand Guidelines',
'Brand Book',
'Social Media Graphics', 'Social Media Graphics',
'Print Design', 'Print Design',
'Business Cards', '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);
}, []);
}
Executable → Regular
+700 -357
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -1,13 +1,31 @@
import { supabase } from './supabase'; import { supabase } from './supabase';
export async function logActivity({ actorId, actorName, action, taskId, taskTitle, projectId, projectName }) { 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({ const { error } = await supabase.from('activity_log').insert({
actor_id: actorId || null, actor_id: normalizedActorId,
actor_name: actorName || null, actor_name: actorName || null,
action, action,
task_id: taskId || null, task_id: normalizedTaskId,
task_title: taskTitle || null, task_title: taskTitle || null,
project_id: projectId || null, project_id: normalizedProjectId,
project_name: projectName || null, project_name: projectName || null,
}); });
if (error) console.error('logActivity failed:', action, error); if (error) console.error('logActivity failed:', action, error);
+6 -7
View File
@@ -1,4 +1,9 @@
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import { formatLongDate } from './dates';
function formatDate(dateStr) {
return formatLongDate(dateStr ? `${dateStr}T12:00:00` : '', '-');
}
// Letter landscape: 792 x 612 pt // Letter landscape: 792 x 612 pt
const W = 792; const W = 792;
@@ -8,12 +13,6 @@ const ACCENT = [245, 165, 35];
const DARK = [18, 18, 18]; const DARK = [18, 18, 18];
const HEADER_H = 64; 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) { function formatCoverAddress(address) {
const parts = String(address || '') const parts = String(address || '')
.split(',') .split(',')
@@ -384,7 +383,7 @@ export async function generateBrandBookEditorPDF(data) {
let pageNum = 0; let pageNum = 0;
const rev = String(revision || '01').padStart(2, '0'); const rev = String(revision || '01').padStart(2, '0');
const displayDate = bookDate 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 ────────────────────────────────────────────────────────────── // ─── COVER PAGE ──────────────────────────────────────────────────────────────
+1 -8
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
export function useLiveClock() { function useLiveClock() {
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
useEffect(() => { useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000); const id = setInterval(() => setNow(new Date()), 1000);
@@ -20,10 +20,3 @@ export function DashboardBanner() {
</div> </div>
); );
} }
export function getGreeting() {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
+40
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) { export function parseDateOnly(value) {
if (!value) return null; if (!value) return null;
const [year, month, day] = String(value).split('-').map(Number); const [year, month, day] = String(value).split('-').map(Number);
-120
View File
@@ -1,120 +0,0 @@
import { supabase } from './supabase';
async function fbCall(method, action, body = null) {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
await fetch(`/api/filebrowser?action=${action}`, {
method,
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
}).catch(() => {});
}
// Create /Clients/{name}/ folder. Silently fails if already exists.
export async function createClientFolder(companyName) {
if (!companyName) return;
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName });
}
// Rename /Clients/{oldName} → /Clients/{newName}
export async function renameClientFolder(oldName, newName) {
if (!oldName || !newName || oldName === newName) return;
await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName });
}
// Same safeName logic as the server (api/filebrowser.js)
function safeName(v) {
return String(v || '').trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
}
// Upload files to the correct Request Info/{rev} folder in FileBrowser.
// companyName is required. versionNumber defaults to 0 (R00).
// Best-effort — call with .catch(() => {}) so failures don't block submission.
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
if (!files?.length || !companyName || !projectName || !taskTitle) return;
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
const authHeader = `Bearer ${session.access_token}`;
// Determine role
const { data: profile } = await supabase.from('profiles').select('role').eq('id', session.user.id).single();
const role = profile?.role;
const co = safeName(companyName);
const proj = safeName(projectName);
const task = safeName(taskTitle);
const rev = `R${String(versionNumber).padStart(2, '0')}`;
// Build virtual path segments for mkdir.
// Clients: virtual root is per-company; company folder already exists — start one level in.
// Team/external: full path under /Clients/{co}/...
let mkdirs;
let revPath;
if (role === 'client') {
revPath = `/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
mkdirs = [
{ path: `/${co}`, name: 'Projects' },
{ path: `/${co}/Projects`, name: proj },
{ path: `/${co}/Projects/${proj}`, name: task },
{ path: `/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
{ path: `/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
];
} else {
revPath = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
mkdirs = [
{ path: '/Clients', name: co },
{ path: `/Clients/${co}`, name: 'Projects' },
{ path: `/Clients/${co}/Projects`, name: proj },
{ path: `/Clients/${co}/Projects/${proj}`, name: task },
{ path: `/Clients/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
{ path: `/Clients/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
];
}
for (const seg of mkdirs) {
await fetch('/api/filebrowser?action=mkdir', {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(seg),
}).catch(() => {});
}
// Get upload token for the revision folder
const tokenRes = await fetch('/api/filebrowser?action=upload-token', {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: revPath }),
}).catch(() => null);
if (!tokenRes?.ok) return;
const { token, url, fbPath } = await tokenRes.json();
if (!token || !url || !fbPath) return;
for (const file of files) {
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(`${fbPath}/${file.name}`)}&override=true`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': file.type || 'application/octet-stream' },
body: file,
}).catch(() => {});
}
}
// Create missing /Clients/{name}/ folders for all companies.
export async function backfillClientFolders() {
const { data } = await supabase.from('companies').select('name');
if (!data?.length) return;
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
for (const company of data) {
if (company.name) await fbCall('POST', 'mkdir', { path: '/Clients', name: company.name });
}
}
+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 = {}) { export async function generateSubcontractorPOPDF(po, options = {}) {
const logo = await loadImage('/fourge-logo.png'); 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 pageWidth = 612;
const pageHeight = 792;
const scale = 3;
const margin = 42; const margin = 42;
const rightEdge = pageWidth - margin; const rightEdge = pageWidth - margin;
const headerH = 62; 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 statusLabel = String(po.status || 'draft').replace(/_/g, ' ').toUpperCase();
const projectName = po.project?.name || 'Subcontractor Work'; const projectName = po.project?.name || 'Subcontractor Work';
const companyName = po.project?.company?.name || 'Fourge Branding'; const companyName = po.project?.company?.name || 'Fourge Branding';
const subcontractorName = po.profile?.name || 'External Team Member'; const subcontractorName = po.profile?.name || 'External Team Member';
const subcontractorEmail = po.profile?.email || ''; const subcontractorEmail = po.profile?.email || '';
const tableW = pageWidth - margin * 2;
const rowH = 28;
doc.setFillColor(20, 20, 20); const metaRows = [
doc.rect(0, 0, pageWidth, headerH, 'F'); ['Invoice Date', po.date ? formatDate(po.date) : ''],
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) : ''],
['Due Date', po.due_date ? formatDate(po.due_date) : 'Not set'], ['Due Date', po.due_date ? formatDate(po.due_date) : 'Not set'],
['Terms', po.terms || 'Net 15'], ['Terms', po.terms || 'Net 15'],
['Status', statusLabel], ['Status', statusLabel],
['Amount', formatCurrency(po.amount)], ['Amount', formatCurrency(po.amount)],
]; ];
const tableX = margin; const sortedItems = (po.items || [])
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
.slice() .slice()
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0)); .sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
sortedItems.forEach((item, index) => { // ── Measure and paginate items ──────────────────────────────────────────────
const rowTop = y + index * 28; const measureCanvas = document.createElement('canvas');
if (index % 2 === 0) { const mCtx = measureCanvas.getContext('2d');
doc.setFillColor(248, 248, 248); if (!mCtx) throw new Error('Canvas unavailable');
doc.rect(tableX, rowTop - 14, tableW, 28, 'F');
// 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'); if (pages.length === 0) pages.push([]);
doc.setFontSize(10);
doc.setTextColor(45, 45, 45); // ── Per-page canvas renderer ────────────────────────────────────────────────
doc.text(item.description || item.task?.title || 'Work Item', tableX + 12, rowTop); function renderPageCanvas(pageItems, pageIndex, pageCount) {
doc.setFont('helvetica', 'bold'); const canvas = document.createElement('canvas');
doc.text(formatCurrency(item.amount), tableX + tableW - 12, rowTop, { align: 'right' }); 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; // ── Last page: summary + notes ─────────────────────────────────────────────
} if (pageIndex === pageCount - 1) {
y += 10;
doc.setFont('helvetica', 'bold'); if (po.description) {
doc.setFontSize(11); ctx.font = '700 11px Helvetica, Arial, sans-serif';
doc.setTextColor(30, 30, 30); ctx.fillStyle = '#1e1e1e';
doc.text(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y); ctx.fillText(po.items?.length > 0 ? 'Summary' : 'Scope of Work', margin, y);
y += 16; y += 16;
ctx.font = '10px Helvetica, Arial, sans-serif';
doc.setFont('helvetica', 'normal'); ctx.fillStyle = '#464646';
doc.setFontSize(10); mCtx.font = '10px Helvetica, Arial, sans-serif';
doc.setTextColor(70, 70, 70); const scopeLines = wrapText(mCtx, po.description, tableW);
const scopeLines = doc.splitTextToSize(po.description || '', pageWidth - margin * 2); scopeLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
doc.text(scopeLines, margin, y); y += 10;
y += scopeLines.length * 13 + 18; }
if (po.notes) { if (po.notes) {
doc.setFont('helvetica', 'bold'); ctx.font = '700 11px Helvetica, Arial, sans-serif';
doc.setTextColor(30, 30, 30); ctx.fillStyle = '#1e1e1e';
doc.text('Notes', margin, y); ctx.fillText('Notes', margin, y);
y += 16; y += 16;
doc.setFont('helvetica', 'normal'); ctx.font = '10px Helvetica, Arial, sans-serif';
doc.setTextColor(70, 70, 70); ctx.fillStyle = '#464646';
const noteLines = doc.splitTextToSize(po.notes, pageWidth - margin * 2); mCtx.font = '10px Helvetica, Arial, sans-serif';
doc.text(noteLines, margin, y); const noteLines = wrapText(mCtx, po.notes, tableW);
noteLines.forEach(line => { ctx.fillText(line, margin, y); y += 13; });
}
} }
doc.setDrawColor(220, 220, 220); return canvas;
doc.line(margin, 704, rightEdge, 704); }
doc.setFont('helvetica', 'normal');
doc.setFontSize(9); // ── Assemble PDF ────────────────────────────────────────────────────────────
doc.setTextColor(120, 120, 120); const doc = new jsPDF({ unit: 'pt', format: 'letter', compress: true, putOnlyUsedFonts: true, precision: 2 });
doc.text( for (let i = 0; i < pages.length; i++) {
'This purchase order authorizes the subcontractor work described above. Payment is subject to Fourge Branding approval and completion of assigned work.', if (i > 0) doc.addPage();
margin, const canvas = renderPageCanvas(pages[i], i, pages.length);
724, const imgData = canvas.toDataURL('image/jpeg', 0.97);
{ maxWidth: pageWidth - margin * 2 }, doc.addImage(imgData, 'JPEG', 0, 0, pageWidth, pageHeight);
); }
const filename = `${poNumber}.pdf`; const filename = `${poNumber}.pdf`;
if (options.save === false || options.output === 'blob') return doc.output('blob'); 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;
}
+41
View File
@@ -0,0 +1,41 @@
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 POPUP_FIELD_LABEL = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
display: 'block',
marginBottom: 4,
};
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 { supabase } from './supabase';
import { ensureProjectFolder } from './folderSync';
function normalizeProjectName(name = '') { function normalizeProjectName(name = '') {
return String(name).trim().toLowerCase(); 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 = []) { export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
const normalized = normalizeProjectName(projectName); const normalized = normalizeProjectName(projectName);
if (!companyId || !normalized) throw new Error('Project company and name are required.'); 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') .select('id, name, company_id, status')
.single(); .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') { if (insertError?.code === '23505') {
const { data: retryProjects, error: retryError } = await supabase const { data: retryProjects, error: retryError } = await supabase
@@ -80,11 +96,28 @@ export async function createInitialSubmissionForRequest({
requestKey, requestKey,
isHot, isHot,
serviceType, serviceType,
signFamily,
signCount,
signs,
deadline, deadline,
description, description,
submittedBy, submittedBy,
submittedByName, 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 const { data: submission, error } = await supabase
.from('submissions') .from('submissions')
.insert({ .insert({
@@ -94,6 +127,9 @@ export async function createInitialSubmissionForRequest({
type: 'initial', type: 'initial',
is_hot: isHot, is_hot: isHot,
service_type: serviceType, service_type: serviceType,
sign_family: signFamily || null,
sign_count: normalizedSignCount || null,
signs: serializeSubmissionSigns(normalizedSigns),
deadline: deadline || null, deadline: deadline || null,
description, description,
submitted_by: submittedBy, submitted_by: submittedBy,
@@ -111,7 +147,17 @@ export async function createInitialSubmissionForRequest({
.eq('request_key', requestKey) .eq('request_key', requestKey)
.single(); .single();
if (existingError) throw existingError; 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.'); throw error || new Error('Failed to create submission.');
+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 jsPDF from 'jspdf';
import { formatDateAtNoon } from './dates';
const W = 792; const W = 792;
const H = 612; const H = 612;
@@ -8,9 +9,7 @@ const DARK = [18, 18, 18];
const HEADER_H = 64; const HEADER_H = 64;
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return '-'; return formatDateAtNoon(dateStr, '-');
const d = new Date(`${dateStr}T12:00:00`);
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
} }
async function blobToDataUrl(blob) { 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,
};
}
+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
+47 -54
View File
@@ -6,6 +6,7 @@ import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor'; import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
import { cleanupBrandBookStorage } from '../lib/deleteHelpers'; import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
const BUCKET = 'brand-books'; const BUCKET = 'brand-books';
@@ -158,7 +159,6 @@ export default function BrandBook() {
const projectLogoRef = useRef(); const projectLogoRef = useRef();
const clientLogoRef = useRef(); const clientLogoRef = useRef();
const [filterCompany, setFilterCompany] = useState(''); const [filterCompany, setFilterCompany] = useState('');
const [selectedBookIds, setSelectedBookIds] = useState([]);
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at'); const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
useEffect(() => { useEffect(() => {
@@ -174,7 +174,8 @@ export default function BrandBook() {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (address.includes(',') && address.length >= 12) { if (address.includes(',') && address.length >= 12) {
loadMapsFromAddress(address, { silent: true }); // Called through a ref so the debounce isn't reset by the handler's identity.
loadMapsFromAddressRef.current(address, { silent: true });
} }
}, 900); }, 900);
@@ -221,7 +222,6 @@ export default function BrandBook() {
setLoadingBooks(true); setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false }); const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []); setSavedBooks(data || []);
setSelectedBookIds(prev => prev.filter(id => (data || []).some(book => book.id === id)));
setLoadingBooks(false); setLoadingBooks(false);
}; };
@@ -234,6 +234,8 @@ export default function BrandBook() {
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) })); setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
}; };
const loadMapsFromAddressRef = useRef(null);
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => { const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
const trimmed = String(address || '').trim(); const trimmed = String(address || '').trim();
if (!trimmed) { if (!trimmed) {
@@ -289,6 +291,10 @@ export default function BrandBook() {
} }
}; };
useEffect(() => {
loadMapsFromAddressRef.current = loadMapsFromAddress;
});
const handleCustomerAddressChange = (e) => { const handleCustomerAddressChange = (e) => {
const value = e.target.value; const value = e.target.value;
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value })); setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
@@ -451,7 +457,7 @@ export default function BrandBook() {
const handleSave = async () => { const handleSave = async () => {
if (!bookInfo.clientName.trim()) { if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' }); setNotification({ type: 'error', msg: 'Please select a company.' });
return; return;
} }
setSaving(true); setSaving(true);
@@ -613,7 +619,7 @@ export default function BrandBook() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) { if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' }); setNotification({ type: 'error', msg: 'Please select a company.' });
return; return;
} }
setGenerating(true); setGenerating(true);
@@ -681,7 +687,7 @@ export default function BrandBook() {
const handleClientLogoUpload = async (e) => { const handleClientLogoUpload = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; 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); setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase(); const ext = file.name.split('.').pop().toLowerCase();
const path = `${bookInfo.clientId}/logo.${ext}`; const path = `${bookInfo.clientId}/logo.${ext}`;
@@ -792,20 +798,21 @@ export default function BrandBook() {
{loadingBooks ? ( {loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p> <p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
) : filteredBooks.length === 0 ? ( ) : filteredBooks.length === 0 ? (
<div className="empty-state"> <div className="card" style={{ minHeight: 160, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<h3>{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}</h3> <div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
<p>{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}</p> {savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button> </div>
{savedBooks.length === 0 && <button className="btn btn-primary" onClick={handleNew}>+ New Brand Book</button>}
</div> </div>
) : ( ) : (
<div className="table-wrapper"> <div className="table-wrapper">
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh> <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="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh> <SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh> <SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Company</SortTh>
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh> <SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
<th></th> <th></th>
</tr> </tr>
@@ -877,7 +884,7 @@ export default function BrandBook() {
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton> <LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div> </div>
{notification && ( {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} {notification.msg}
</span> </span>
)} )}
@@ -891,7 +898,7 @@ export default function BrandBook() {
<div className="card-title">Brand Book Info</div> <div className="card-title">Brand Book Info</div>
<div className="grid-2"> <div className="grid-2">
<div className="form-group"> <div className="form-group">
<label>Client *</label> <label>Company *</label>
<select value={bookInfo.clientId} onChange={handleClientChange}> <select value={bookInfo.clientId} onChange={handleClientChange}>
<option value=""> Select client </option> <option value=""> Select client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
@@ -1046,7 +1053,7 @@ export default function BrandBook() {
{/* Client info (saved per company) */} {/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 400, 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 style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company reused across all brand books.</div>
</div> </div>
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}> <button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
@@ -1055,7 +1062,7 @@ export default function BrandBook() {
</div> </div>
<div className="form-group"> <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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{bookInfo.clientLogoUrl && ( {bookInfo.clientLogoUrl && (
<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' }} /> <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' }} />
@@ -1180,7 +1187,7 @@ export default function BrandBook() {
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton> <LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div> </div>
{notification && ( {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} {notification.msg}
</span> </span>
)} )}
@@ -1228,7 +1235,7 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>} {sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
{canRemove && ( {canRemove && (
<span role="button" onClick={onRemove} <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>
</div> </div>
@@ -1334,7 +1341,7 @@ function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter,
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12, padding: 12,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
@@ -1405,7 +1412,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: '24px 16px', textAlign: 'center', cursor: 'pointer', padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s', color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
}} }}
@@ -1436,7 +1443,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: '20px 16px', textAlign: 'center', cursor: 'pointer', padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s', marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
@@ -1456,14 +1463,14 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }} style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
/> />
{item.file && ( {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: 400 }}>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 <button
type="button" type="button"
onClick={() => onRemove(i)} onClick={() => onRemove(i)}
style={{ style={{
position: 'absolute', top: -6, right: -6, 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', width: 18, height: 18, fontSize: 10, color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
}} }}
@@ -1508,7 +1515,7 @@ function CombinedMockupPhotoField({
const tileStyle = { const tileStyle = {
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12, padding: 12,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
@@ -1647,7 +1654,7 @@ function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDra
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12, padding: 12,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
@@ -1730,7 +1737,7 @@ function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnt
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12, padding: 12,
cursor: preview ? 'pointer' : 'default', cursor: preview ? 'pointer' : 'default',
display: 'flex', display: 'flex',
@@ -2350,14 +2357,9 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
return ( return (
<div <div
style={{ style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999, padding: 20,
}}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div style={{ background: 'var(--card-bg)', borderRadius: 4, 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 style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div> <div>
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div> <div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
@@ -2422,7 +2424,7 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
)} )}
</span> </span>
</div> </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>} {!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>}
<canvas <canvas
ref={canvasRef} ref={canvasRef}
@@ -2835,7 +2837,7 @@ function PhotoEditorModal({
ctx.restore(); ctx.restore();
if (showGuides && item.id === selectedArtworkId) { if (showGuides && item.id === selectedArtworkId) {
const rotateHandle = getRotateHandle(item); const rotateHandle = getRotateHandle(item);
ctx.strokeStyle = '#f5a523'; ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.setLineDash([6, 4]); ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.beginPath();
@@ -2853,7 +2855,7 @@ function PhotoEditorModal({
ctx.stroke(); ctx.stroke();
getArtworkHandles(item).forEach(({ x, y }) => { getArtworkHandles(item).forEach(({ x, y }) => {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#f5a523'; ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
@@ -2861,13 +2863,13 @@ function PhotoEditorModal({
ctx.stroke(); ctx.stroke();
}); });
ctx.fillStyle = '#1a1a1a'; ctx.fillStyle = '#1a1a1a';
ctx.strokeStyle = '#f5a523'; ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2); ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
ctx.fillStyle = '#f5a523'; ctx.fillStyle = 'var(--accent)';
ctx.font = '700 10px Helvetica, Arial, sans-serif'; ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4); ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
} }
@@ -2897,14 +2899,14 @@ function PhotoEditorModal({
} }
if (showGuides && calibrationPoints.length > 0) { if (showGuides && calibrationPoints.length > 0) {
ctx.fillStyle = '#22c55e'; ctx.fillStyle = 'var(--success)';
calibrationPoints.forEach((point) => { calibrationPoints.forEach((point) => {
ctx.beginPath(); ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2); ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
}); });
if (calibrationPoints.length === 2) { if (calibrationPoints.length === 2) {
ctx.strokeStyle = '#22c55e'; ctx.strokeStyle = 'var(--success)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y); ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
@@ -3437,18 +3439,9 @@ function PhotoEditorModal({
return ( return (
<div <div
style={{ style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999, padding: 20,
}}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div style={{ <div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column',
maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden',
boxShadow: '0 24px 64px rgba(0,0,0,0.55)',
}}>
{/* Header */} {/* Header */}
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}> <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div> <div>
@@ -3614,7 +3607,7 @@ function PhotoEditorModal({
}} }}
style={{ style={{
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`, 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)', color: 'var(--text-primary)',
borderRadius: 4, borderRadius: 4,
padding: '8px 10px', padding: '8px 10px',
@@ -3658,7 +3651,7 @@ function PhotoEditorModal({
}} }}
style={{ style={{
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`, 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)', color: 'var(--text-primary)',
borderRadius: 4, borderRadius: 4,
padding: '8px 10px', padding: '8px 10px',
@@ -3685,7 +3678,7 @@ function PhotoEditorModal({
<div <div
onDragOver={e => e.preventDefault()} onDragOver={e => e.preventDefault()}
onDrop={handleArtworkDrop} 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 && ( {!loaded && (
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div> <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import SortTh from '../components/SortTh'; import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable'; import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { deleteCompanyData } from '../lib/deleteHelpers'; import { deleteCompanyData } from '../lib/deleteHelpers';
import { readPageCache, writePageCache } from '../lib/pageCache'; import { readPageCache, writePageCache } from '../lib/pageCache';
import { createClientFolder, backfillClientFolders } from '../lib/filebrowserFolders'; import { ensureCompanyFolder, ensureSubcontractorFolder, renameSubcontractorFolder } from '../lib/folderSync';
// Team view // Team view
@@ -31,7 +32,6 @@ function TeamCompanies() {
const [editingUserId, setEditingUserId] = useState(null); const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState(''); const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null); const [deletingUserId, setDeletingUserId] = useState(null);
const [filterCompany, setFilterCompany] = useState('');
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client'); const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name'); const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name'); const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
@@ -50,6 +50,9 @@ function TeamCompanies() {
setLoading(false); setLoading(false);
} }
// load() only setStates after awaiting its queries; the rule can't see past the
// call boundary, and load() is shared with the create/delete handlers below.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const handleCreate = async (e) => { const handleCreate = async (e) => {
@@ -63,8 +66,11 @@ function TeamCompanies() {
}).select().single(); }).select().single();
setSaving(false); setSaving(false);
if (data) { if (data) {
createClientFolder(data.name).catch(() => {}); try {
backfillClientFolders().catch(() => {}); await ensureCompanyFolder({ companyId: data.id });
} catch (folderError) {
console.error('Company folder sync failed:', folderError);
}
setShowNew(false); setShowNew(false);
setNewForm({ name: '', phone: '', address: '' }); setNewForm({ name: '', phone: '', address: '' });
navigate(`/company/${data.id}`); navigate(`/company/${data.id}`);
@@ -72,7 +78,7 @@ function TeamCompanies() {
}; };
const handleDeleteCompany = async (company) => { const handleDeleteCompany = async (company) => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return; 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); await deleteCompanyData(company.id);
setCompanies(prev => prev.filter(c => c.id !== company.id)); setCompanies(prev => prev.filter(c => c.id !== company.id));
load(); load();
@@ -80,8 +86,18 @@ function TeamCompanies() {
const handleEditUserSave = async (userId) => { const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return; if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId); const existingUser = profiles.find(u => u.id === userId);
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u)); 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); setEditingUserId(null);
}; };
@@ -113,12 +129,29 @@ function TeamCompanies() {
const errBody = error?.context ? await error.context.json().catch(() => null) : null; const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message; const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { setUserError(errMsg); return; } 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);
}
}
setShowNewUser(false); setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' }); setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load(); load();
}; };
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
const getProfileCompanyIds = (profile) => { const getProfileCompanyIds = (profile) => {
const ids = new Set( const ids = new Set(
@@ -158,7 +191,7 @@ function TeamCompanies() {
</div> </div>
{tab === 'companies' && ( {tab === 'companies' && (
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}> <button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
{showNew ? 'Cancel' : '+ New Client'} {showNew ? 'Cancel' : '+ New Company'}
</button> </button>
)} )}
{tab === 'users' && ( {tab === 'users' && (
@@ -197,7 +230,7 @@ function TeamCompanies() {
</div> </div>
<div className="action-buttons"> <div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}> <button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
{saving ? 'Creating...' : 'Create Client'} {saving ? 'Creating...' : 'Create Company'}
</button> </button>
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button> <button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
</div> </div>
@@ -206,10 +239,10 @@ function TeamCompanies() {
)} )}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? ( {companies.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No clients yet.</div> <div className="card-empty-center">No companies</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh> <SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
@@ -325,11 +358,11 @@ function TeamCompanies() {
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{userSubTab === 'client' && <> {userSubTab === 'client' && <>
{unassigned.length > 0 && ( {unassigned.length > 0 && (
<div style={{ marginBottom: 12, padding: 14, background: 'rgba(220,38,38,0.06)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 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={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{unassigned.map(user => ( {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: '1px solid var(--border)' }}> <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 }}> <div style={{ flex: 1 }}>
{editingUserId === user.id ? ( {editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -360,10 +393,10 @@ function TeamCompanies() {
</div> </div>
)} )}
{clientProfiles.length === 0 ? ( {clientProfiles.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No users yet.</div> <div className="card-empty-center">No users</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh> <SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
@@ -379,7 +412,7 @@ function TeamCompanies() {
}).map(user => { }).map(user => {
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean); const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
return ( return (
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}> <tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
<td style={{ fontWeight: 400 }}> <td style={{ fontWeight: 400 }}>
{editingUserId === user.id ? ( {editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -414,10 +447,10 @@ function TeamCompanies() {
{userSubTab === 'external' && <> {userSubTab === 'external' && <>
{subcontractors.length === 0 ? ( {subcontractors.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No subcontractors yet.</div> <div className="card-empty-center">No subcontractors</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh> <SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
@@ -427,7 +460,7 @@ function TeamCompanies() {
</thead> </thead>
<tbody> <tbody>
{subSort(subcontractors, (u, key) => u[key] || '').map(user => ( {subSort(subcontractors, (u, key) => u[key] || '').map(user => (
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}> <tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
<td style={{ fontWeight: 400 }}> <td style={{ fontWeight: 400 }}>
{editingUserId === user.id ? ( {editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -481,10 +514,10 @@ function ClientCompanyList() {
</div> </div>
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? ( {companies.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No companies linked to your account.</div> <div className="card-empty-center">No companies</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh> <SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
@@ -509,177 +542,6 @@ function ClientCompanyList() {
); );
} }
// (removed old ClientCompanies dropdown kept for reference only)
function _UnusedClientCompanies() {
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: 400, background: 'var(--card-bg)',
border: '1px solid var(--border)', borderRadius: 4,
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: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 400, color: '#111', flexShrink: 0,
}}>
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<div style={{ fontWeight: 400, 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>
);
}
// Entry point // Entry point
export default function CompaniesPage() { export default function CompaniesPage() {
+102 -29
View File
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData'; import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers'; import { deleteCompanyData } from '../lib/deleteHelpers';
import { renameClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
import { logActivity } from '../lib/activityLog'; import { logActivity } from '../lib/activityLog';
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
export default function CompanyDetail() { export default function CompanyDetail() {
const { id } = useParams(); const { id } = useParams();
@@ -21,6 +22,8 @@ export default function CompanyDetail() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [availableUsers, setAvailableUsers] = useState([]); const [availableUsers, setAvailableUsers] = useState([]);
const [prices, setPrices] = useState([]); const [prices, setPrices] = useState([]);
const [signFamilies, setSignFamilies] = useState([]);
const [savingSignFamilyPrice, setSavingSignFamilyPrice] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('users'); const [tab, setTab] = useState('users');
const [savingPrice, setSavingPrice] = useState(null); const [savingPrice, setSavingPrice] = useState(null);
@@ -35,6 +38,10 @@ export default function CompanyDetail() {
const [editUserVal, setEditUserVal] = useState(''); const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null); 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() { async function load() {
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([ const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', id).single(), supabase.from('companies').select('*').eq('id', id).single(),
@@ -63,7 +70,6 @@ export default function CompanyDetail() {
} }
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load(); load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps }, [id]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -72,10 +78,14 @@ export default function CompanyDetail() {
if (!nameVal.trim()) return; if (!nameVal.trim()) return;
setSavingName(true); setSavingName(true);
const oldName = company.name; const oldName = company.name;
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id); const newName = nameVal.trim();
renameClientFolder(oldName, nameVal.trim()).catch(() => {}); await supabase.from('companies').update({ name: newName }).eq('id', id);
backfillClientFolders().catch(() => {}); try {
setCompany(c => ({ ...c, name: nameVal.trim() })); await renameCompanyFolder({ companyId: id, oldName, newName });
} catch (folderError) {
console.error('Company folder rename failed:', folderError);
}
setCompany(c => ({ ...c, name: newName }));
setEditingName(false); setEditingName(false);
setSavingName(false); setSavingName(false);
}; };
@@ -175,20 +185,15 @@ export default function CompanyDetail() {
status: 'active', status: 'active',
}).select().single(); }).select().single();
if (data) { 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 }); logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
setProjects(prev => [data, ...prev]); setProjects(prev => [data, ...prev]);
setNewProjectName(''); setNewProjectName('');
setShowNewProject(false); setShowNewProject(false);
// Fire-and-forget: create project folder in FileBrowser
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.access_token && company?.name) {
fetch('/api/sync-project-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.access_token}` },
body: JSON.stringify({ type: 'INSERT', record: { name: data.name, company_name: company.name } }),
}).catch(() => {});
}
});
} }
setSavingProject(false); setSavingProject(false);
}; };
@@ -223,7 +228,37 @@ export default function CompanyDetail() {
setSavingPrice(null); 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>; if (!company) return <Layout><p>Company not found.</p></Layout>;
const activeTasks = tasks.filter(t => t.status !== 'client_approved'); const activeTasks = tasks.filter(t => t.status !== 'client_approved');
@@ -293,15 +328,14 @@ export default function CompanyDetail() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 4, marginBottom: 10, flexWrap: 'wrap', minHeight: 'var(--btn-height)' }}>
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => ( {(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={`tab-btn${tab === t ? ' active' : ''}`} className={`section-tab-btn${tab === t ? ' is-active' : ''}`}
style={{ textTransform: 'capitalize' }}
> >
{t} {t.charAt(0).toUpperCase() + t.slice(1)}
{t === 'users' && availableUsers.length > 0 && ( {t === 'users' && availableUsers.length > 0 && (
<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 }}> <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} {availableUsers.length}
@@ -317,7 +351,7 @@ export default function CompanyDetail() {
<div className="card"> <div className="card">
<div className="card-title">Assigned Users</div> <div className="card-title">Assigned Users</div>
{users.length === 0 ? ( {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 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{users.map((user, i) => ( {users.map((user, i) => (
@@ -469,10 +503,7 @@ export default function CompanyDetail() {
)} )}
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="empty-state"> <div className="card card-empty-center">No projects</div>
<h3>No projects yet</h3>
<p>Create a project to start adding jobs.</p>
</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => { {projects.map(project => {
@@ -480,7 +511,7 @@ export default function CompanyDetail() {
const active = projectTasks.filter(t => t.status !== 'client_approved').length; const active = projectTasks.filter(t => t.status !== 'client_approved').length;
const done = projectTasks.filter(t => t.status === 'client_approved').length; const done = projectTasks.filter(t => t.status === 'client_approved').length;
return ( return (
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, 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' }}> <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>
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div> <div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
@@ -496,7 +527,7 @@ export default function CompanyDetail() {
{isTeam && <button {isTeam && <button
type="button" type="button"
onClick={() => handleDeleteProject(project)} 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" title="Delete project"
></button>} ></button>}
</div> </div>
@@ -549,6 +580,48 @@ export default function CompanyDetail() {
</div> </div>
))} ))}
</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> </div>
)} )}
</Layout> </Layout>
+14 -4
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { heicTo, isHeic } from 'heic-to/csp';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import LoadingButton from '../components/LoadingButton'; import LoadingButton from '../components/LoadingButton';
@@ -13,6 +12,14 @@ const OUTPUT_FORMATS = [
const HEIC_EXTENSIONS = new Set(['heic', 'heif']); const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
const MAX_FILES = 100; 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 = '') { function getExtension(name = '') {
return name.split('.').pop()?.toLowerCase() || ''; return name.split('.').pop()?.toLowerCase() || '';
} }
@@ -96,6 +103,7 @@ async function convertRasterBlob(blob, format, quality) {
} }
async function convertHeicFile(file, format, quality) { async function convertHeicFile(file, format, quality) {
const { heicTo } = await getHeicLib();
if (format.mime === 'image/jpeg' || format.mime === 'image/png') { if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
try { try {
return await heicTo({ blob: file, type: format.mime, quality }); 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) { async function convertFile(file, format, quality) {
const looksHeic = isHeicFile(file); 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) { if (looksHeic || confirmedHeic) {
return convertHeicFile(file, format, quality); return convertHeicFile(file, format, quality);
} }
@@ -368,7 +378,7 @@ export default function Converters() {
</div> </div>
{!files.length ? ( {!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 }}> <div style={{ display: 'grid', gap: 12 }}>
{files.map((file, index) => { {files.map((file, index) => {
@@ -398,7 +408,7 @@ export default function Converters() {
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div> <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
)} )}
{result?.status === 'done' && ( {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} Ready as {outputName}
</div> </div>
)} )}
-457
View File
@@ -1,457 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
const ICON_TONES = [
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
];
function iconTone(key) {
let h = 0;
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
return ICON_TONES[h];
}
function InitialPortrait({ name }) {
const tone = iconTone(name);
return (
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
</div>
);
}
function ExternalDashboard({ currentUser, projects, tasks, pos, submissions, clientProfiles, companyMemberships }) {
const myTasks = tasks.filter(t => t.assigned_to === currentUser?.id);
const myActiveTasks = myTasks.filter(t => !['client_approved', 'on_hold'].includes(t.status));
const myCompleted = myTasks.filter(t => t.status === 'client_approved');
const myCompletedRevisions = myCompleted.reduce((sum, t) => sum + Number(t.current_version || 0), 0);
const myOnHold = myTasks.filter(t => t.status === 'on_hold');
const myAssignedProjectCount = new Set(myTasks.map(t => t.project_id).filter(Boolean)).size;
const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0);
const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0);
const myProjectIds = new Set(myTasks.map(t => t.project_id).filter(Boolean));
const myProjects = myProjectIds.size > 0
? projects.filter(p => myProjectIds.has(p.id))
: projects;
const companyById = Object.fromEntries(myProjects.filter(p => p.company).map(p => [p.company_id, p.company]));
const myProjectIdSet = new Set(myProjects.map(p => p.id));
const activityEvents = buildActivityEvents(submissions, myProjectIdSet);
const externalHighlights = buildClientHighlights(Object.values(companyById), myProjects, tasks, clientProfiles, companyMemberships || [])
.sort((a, b) => b.openTaskCount - a.openTaskCount || a.company.name.localeCompare(b.company.name))
.slice(0, 5);
return (
<Layout>
<div className="dash-stat-grid">
<DashStatCard label="Active Tasks" value={myActiveTasks.length} sub={`${myOnHold.length} on hold`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Completed Tasks" value={myCompleted.length} sub={`${myCompletedRevisions} total revisions`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.trending} />
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myAssignedProjectCount} assigned to me`} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Pending Payment" value={fmtMoney(unpaidAmount)} sub={`${fmtMoney(paidAmount)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
</div>
<div className="dashboard-bottom-grid">
<ActivityFeed events={activityEvents} />
{myProjects.length > 0 && <ClientHighlightTable highlights={externalHighlights} />}
</div>
</Layout>
);
}
// Shared dashboard helpers
const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', 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: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', 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: 'rgba(167,139,250,0.15)', color: '#a78bfa', 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: 'rgba(245,165,35,0.15)', color: '#F5A523', 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: 'rgba(167,139,250,0.15)', color: '#a78bfa', 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: 'rgba(245,158,11,0.15)', color: '#f59e0b', 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"/></> },
};
function 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: 'created',
task_started: 'started',
task_on_hold: 'put on hold',
task_resumed: 'resumed',
task_submitted: 'submitted',
task_approved: 'approved',
project_created: 'created project',
request_submitted: 'submitted',
revision_requested: 'requested revision on',
};
function buildActivityEvents(activityData, projectIdSet = null) {
return (activityData || [])
.filter(e => !projectIdSet || !e.project_id || projectIdSet.has(e.project_id))
.map(e => ({
time: new Date(e.created_at),
name: e.actor_name || 'Fourge',
actionKey: e.action,
action: ACTION_LABEL[e.action] || e.action,
task: e.task_title || null,
project: e.project_name || null,
})).filter(e => !isNaN(e.time)).slice(0, 10);
}
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
return (companies || []).map(company => {
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
const primaryContact = (clientProfiles || []).find(p =>
p.company_id === company.id ||
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
);
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
});
}
function fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
return `$${Number(n).toFixed(2)}`;
}
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 }) {
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 lastPt = pts[pts.length - 1];
const firstPt = pts[0];
const area = `${line} L${lastPt[0].toFixed(1)},${H} L${firstPt[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="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#areaGrad)" />
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: '#ffffff', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
{sub && <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', marginTop: 5 }}>{sub}</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', ...(chartData ? { flex: 1, minWidth: 0 } : { flexShrink: 0 }) }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<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>
{chartData && <MiniAreaChart data={chartData} />}
</div>
</div>
);
}
const DASH_ICONS = {
revenue: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>',
projects: '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>',
invoice: '<rect x="2" y="2" width="20" height="20" rx="2"/><line x1="12" y1="6" x2="12" y2="18"/><path d="M16 8H9.5a2.5 2.5 0 000 5h5a2.5 2.5 0 010 5H8"/>',
trending: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
};
function StatBar({ items }) {
return (
<div className="stat-bar">
{items.map((item, i) => (
<div key={i} className="stat-bar-item">
<div className="stat-bar-header">
<div className="stat-bar-label">{item.label}</div>
<div className="stat-bar-dot" style={{ background: item.color }} />
</div>
<div className="stat-bar-value">{item.value}</div>
</div>
))}
</div>
);
}
function TaskFeed({ title, tasks, projects, emptyMessage }) {
const navigate = useNavigate();
return (
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</span>
{tasks.length > 0 && <span style={{ fontSize: 11, fontWeight: 400, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>}
</div>
{tasks.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
) : tasks.map((task, i) => {
const project = projects.find(p => p.id === task.project_id);
return (
<div key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '10px 16px', borderBottom: i < tasks.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.title}</div>
{project && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{project.name}</div>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{task.assigned_name ? <>Assigned to <span style={{ color: 'var(--text-primary)' }}>{task.assigned_name}</span></> : 'Unassigned'}
</div>
</div>
);
})}
</div>
);
}
function ActivityFeed({ events }) {
const visible = events.slice(0, 5);
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
{visible.length === 0 ? (
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.5)', marginTop: 14 }}>No recent activity</div>
) : visible.map((e, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
<ActionIcon actionKey={e.actionKey} />
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: '#ffffff', fontWeight: 400 }}>{e.name}</span>
{e.action && <span style={{ color: 'rgba(255,255,255,0.5)' }}> {e.action}</span>}
{e.task && <span style={{ color: '#ffffff' }}> {e.task}</span>}
</div>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
</div>
))}
</div>
);
}
function ClientHighlightTable({ highlights }) {
const { sortKey, sortDir, toggle, sort } = useSortable('company');
if (!highlights || highlights.length === 0) return null;
const fmtMoney = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const sortedHighlights = sort(highlights, (row, key) => {
if (key === 'company') return row.company?.name || '';
if (key === 'primaryContact') return row.primaryContact?.name || '';
if (key === 'projectCount') return row.projectCount || 0;
if (key === 'openTaskCount') return row.openTaskCount || 0;
if (key === 'outstandingTotal') return Number(row.outstandingTotal || 0);
if (key === 'paidTotal') return Number(row.paidTotal || 0);
return '';
});
return (
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Highlight</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--card-bg-2)' }}>
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'left', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Company</SortTh>
<SortTh col="primaryContact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Primary Contact</SortTh>
<SortTh col="projectCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Projects</SortTh>
<SortTh col="openTaskCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Open Tasks</SortTh>
<SortTh col="outstandingTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Outstanding</SortTh>
<SortTh col="paidTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Paid</SortTh>
</tr>
</thead>
<tbody>
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }, i) => (
<tr key={company.id} style={{ borderBottom: i < sortedHighlights.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '10px 16px', fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<InitialPortrait name={company.name} />
{company.name}
</div>
</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{primaryContact?.name || '—'}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{projectCount}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{openTaskCount}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(outstandingTotal)}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(paidTotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// Main export
export default function DashboardPage() {
const { currentUser } = useAuth();
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
// Client state
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
const companies = isClient
? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
: [];
const [allClientTasks, setAllClientTasks] = useState([]);
const [allClientProjects, setAllClientProjects] = useState([]);
const [allClientInvoices, setAllClientInvoices] = useState([]);
const [clientActivity, setClientActivity] = useState([]);
// External state
const cacheKey = 'team_dashboard_external';
const cached = isExternal ? readPageCache(cacheKey, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [pos, setPos] = useState(() => cached?.pos || []);
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [loading, setLoading] = useState(() => isClient ? hasCompany : isExternal && !cached);
useEffect(() => {
let cancelled = false;
if (isClient) {
if (!hasCompany) { setLoading(false); return; }
async function loadClient() {
try {
const [{ data: activeTasks }, { data: invoices }, { data: activityData }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, project_id, assigned_name, project:projects(id, name, company_id)').order('submitted_at', { ascending: false }),
supabase.from('invoices').select('total, status, company_id').in('status', ['sent', 'paid']),
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
]), 30000, 'Client dashboard load');
if (cancelled) return;
const clientTasks = activeTasks || [];
setAllClientTasks(clientTasks);
setAllClientInvoices(invoices || []);
setClientActivity(activityData || []);
const projectMap = {};
clientTasks.forEach(t => { if (t.project?.id) projectMap[t.project.id] = t.project; });
setAllClientProjects(Object.values(projectMap));
} catch (error) {
console.error('ClientDashboard load failed:', error);
} finally {
if (!cancelled) setLoading(false);
}
}
loadClient();
} else if (isExternal) {
async function loadExternal() {
try {
const [{ data: p }, { data: t }, { data: posData }, { data: memRows }, { data: clientProfiles }, { data: activityData }] = await withTimeout(Promise.all([
supabase.from('projects').select('id, name, company_id, company:companies(id, name)').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_to, assigned_name').order('submitted_at', { ascending: false }),
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
supabase.from('company_members').select('company_id, profile_id'),
supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'),
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
]), 30000, 'External dashboard load');
if (!cancelled) {
setProjects(p || []);
setTasks(t || []);
setPos(posData || []);
setSubmissions(activityData || []);
setClientProfiles(clientProfiles || []);
setCompanyMemberships(memRows || []);
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: activityData || [], pos: posData || [], clientProfiles: clientProfiles || [], companyMemberships: memRows || [] });
}
} catch (error) {
console.error('External dashboard load failed:', error);
} finally {
if (!cancelled) setLoading(false);
}
}
loadExternal();
} else {
setLoading(false);
}
return () => { cancelled = true; };
}, [isClient, isExternal, hasCompany, cacheKey, currentUser?.id]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
// Client render
if (isClient) {
const myCompanyIds = new Set(companies.map(c => c.id));
const myTasks = myCompanyIds.size > 0
? allClientTasks.filter(t => t.project?.company_id && myCompanyIds.has(t.project.company_id))
: allClientTasks;
const myProjects = myCompanyIds.size > 0
? allClientProjects.filter(p => myCompanyIds.has(p.company_id))
: allClientProjects;
const myInvoices = myCompanyIds.size > 0
? allClientInvoices.filter(i => myCompanyIds.has(i.company_id))
: allClientInvoices;
const myProjectIds = new Set(myProjects.map(p => p.id));
const reviewTasks = myTasks.filter(t => t.status === 'client_review');
const inProgressTasks = myTasks.filter(t => t.status === 'in_progress');
const onHoldTasks = myTasks.filter(t => t.status === 'on_hold');
const notStartedTasks = myTasks.filter(t => t.status === 'not_started');
const outstandingInvoices = myInvoices.filter(i => i.status === 'sent').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
const paidInvoices = myInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
return (
<Layout>
<div className="dash-stat-grid">
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myTasks.length} total tasks`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Outstanding" value={fmtMoney(outstandingInvoices)} sub={`${fmtMoney(paidInvoices)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
<DashStatCard label="In Progress" value={inProgressTasks.length} sub={`${notStartedTasks.length} not started`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Awaiting Review" value={reviewTasks.length} sub={`${onHoldTasks.length} on hold`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.trending} />
</div>
<ActivityFeed events={buildActivityEvents(clientActivity, myProjectIds)} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<TaskFeed title="Awaiting Your Review" tasks={reviewTasks} projects={myProjects} emptyMessage="No items need your review." />
<TaskFeed title="In Progress" tasks={inProgressTasks} projects={myProjects} emptyMessage="No items currently in progress." />
</div>
</Layout>
);
}
// External render
if (isExternal) {
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} submissions={submissions} clientProfiles={clientProfiles} companyMemberships={companyMemberships} />;
}
return null;
}
-16
View File
@@ -1,16 +0,0 @@
import Layout from '../components/Layout';
import FileBrowser from '../components/FileBrowser';
export default function FileSharing() {
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">File Sharing</div>
<div className="page-subtitle">Shared workspace for team members and subcontractors.</div>
</div>
</div>
<FileBrowser />
</Layout>
);
}
Executable → Regular
+3 -3
View File
@@ -59,14 +59,14 @@ export default function Login() {
required required
/> />
</div> </div>
{successMessage && <p style={{ color: '#22c55e', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>} {successMessage && <p style={{ color: 'var(--success)', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</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}> <button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'} {loading ? 'Signing in...' : 'Sign In'}
</button> </button>
</form> </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. Contact Fourge Branding to get access.
</p> </p>
</div> </div>
+13 -13
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import PageLoader from '../components/PageLoader';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
@@ -74,30 +75,29 @@ export default function PayInvoice() {
return ( return (
<div style={{ minHeight: '100vh', background: '#f5f5f5', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24 }}> <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 }}> <div style={{ width: '100%', maxWidth: 480 }}>
{/* Logo */} {/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 32 }}>
<img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} /> <img src="/fourge-logo.png" alt="Fourge Branding" style={{ height: 36, filter: 'invert(1)' }} />
</div> </div>
{loading ? ( {!invoice ? (
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div> <div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
) : !invoice ? (
<div style={{ background: '#fff', color: '#141414', 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={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div> <div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
</div> </div>
) : success || invoice.status === 'paid' ? ( ) : success || invoice.status === 'paid' ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, 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: 32, marginBottom: 12 }}></div>
<div style={{ fontSize: 20, fontWeight: 400, 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={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ fontSize: 24, fontWeight: 400, 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: 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 style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
</div> </div>
) : ( ) : (
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}> <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: 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={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div> <div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
@@ -105,27 +105,27 @@ export default function PayInvoice() {
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}> <div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
<div> <div>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div> <div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
<div style={{ fontWeight: 400, 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>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div> <div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
<div style={{ fontWeight: 400, 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> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div> <div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
<div style={{ fontSize: 28, fontWeight: 400, color: '#141414' }}>{totalLabel}</div> <div style={{ fontSize: 28, fontWeight: 400, color: 'var(--surface-sunken)' }}>{totalLabel}</div>
</div> </div>
{cancelled && ( {cancelled && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, 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. Payment was cancelled. You can try again below.
</div> </div>
)} )}
{error && ( {error && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, 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} {error}
</div> </div>
)} )}
@@ -135,7 +135,7 @@ export default function PayInvoice() {
disabled={paying} disabled={paying}
style={{ style={{
width: '100%', padding: '14px', borderRadius: 4, border: 'none', width: '100%', padding: '14px', borderRadius: 4, border: 'none',
background: paying ? '#999' : '#141414', color: '#fff', background: paying ? '#999' : 'var(--surface-sunken)', color: '#fff',
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer', fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
}} }}
> >
+705
View File
@@ -0,0 +1,705 @@
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 {
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}
>
<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>
);
}
+661
View File
@@ -0,0 +1,661 @@
import { useState, useEffect } from 'react';
import { useParams, 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 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 { currentUser } = useAuth();
const isClient = currentUser?.role === 'client';
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 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}>
<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}>
<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>
);
}

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