diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml new file mode 100644 index 0000000..ec29b28 --- /dev/null +++ b/.github/workflows/react-doctor.yml @@ -0,0 +1,56 @@ +# React Doctor — finds security, performance, correctness, accessibility, +# bundle-size, and architecture issues in React codebases. +# +# Docs: https://www.react.doctor/ci +# Source: https://github.com/millionco/react-doctor + +name: React Doctor + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + # Scans `main` on every push so you get a health-score trend on the + # default branch — useful for tracking the overall number commit-by-commit + # and catching regressions that slipped past PR review. PR-specific steps + # (the sticky summary comment) are skipped automatically on `push` events. + # Comment this block out if you only want PR-time scans. + push: + branches: [main] + +permissions: + # `actions/checkout` needs this to read the repo source. + contents: read + # Two uses: (1) reads the PR's changed-file list so the scan only checks + # what the PR touched (faster, scoped to the diff), and (2) posts/updates + # the sticky React Doctor summary comment on the PR. Downgrade `write` to + # `read` to keep the changed-file scan but disable comment posting. + pull-requests: write + # The sticky-comment step uses GitHub's `issues.createComment` / + # `issues.updateComment` endpoints — those are the same APIs that back PR + # comments (PRs are issues under the hood). Not exercised on `push` + # events, so safe to drop if you only run on `main`. + issues: write + +# Cancels any in-flight scan for the same PR (or branch, on push) the moment +# a new commit arrives, so reviewers only ever see the latest run. +concurrency: + group: react-doctor-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + react-doctor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: millionco/react-doctor@v1 + # Common configuration knobs — uncomment any to override the default. + # Full reference: https://www.react.doctor/ci + # with: + # non-blocking: true # Report findings but always exit 0 (won't fail the PR check) + # fail-on: warning # Gate level: "error" (default) | "warning" | "none" + # comment: false # Disable the sticky PR summary comment + # annotations: false # Disable inline GitHub Actions annotations on changed files + # version: "0.2.18" # Pin to a specific react-doctor version instead of "latest" + # directory: apps/web # Scan a sub-directory (default: ".") + # project: "web,admin" # In a monorepo, scan specific workspace project(s) diff --git a/bun.lock b/bun.lock index b58d364..8533392 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,6 @@ "@tanstack/react-router-devtools": "^1.132.0", "@tanstack/react-router-ssr-query": "^1.131.7", "@tanstack/react-start": "^1.132.0", - "@tanstack/router-plugin": "^1.132.0", "@uploadthing/react": "^7.3.3", "better-auth": "^1.5.5", "class-variance-authority": "^0.7.1", @@ -51,7 +50,6 @@ "ultracite": "7.3.2", "vite": "^8.0.12", "vitest": "^4", - "web-vitals": "^5.1.0", }, }, }, @@ -1467,8 +1465,6 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], - "web-vitals": ["web-vitals@5.1.0", "", {}, "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg=="], - "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], diff --git a/doctor.config.json b/doctor.config.json new file mode 100644 index 0000000..64dbb25 --- /dev/null +++ b/doctor.config.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://react.doctor/schema/config.json", + "ignore": { + "files": [ + "src/components/ui/**", + "node_modules/**", + "drizzle/**", + "dist/**", + ".output/**", + "react-doctor-report.json" + ] + }, + "rules": { + "react-doctor/only-export-components": "off", + "react-doctor/no-giant-component": "off", + "react-doctor/no-multi-comp": "off", + "react-doctor/prefer-useReducer": "off", + "react-doctor/no-many-boolean-props": "off", + "react-doctor/prefer-dynamic-import": "off", + "react-doctor/no-event-handler": "off", + "react-doctor/control-has-associated-label": "off", + "react-doctor/use-lazy-motion": "off", + "react-doctor/jsx-no-jsx-as-prop": "off", + "deslop/unused-export": "off", + "deslop/unused-file": "off" + } +} diff --git a/package.json b/package.json index 779a846..d8295eb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "typecheck": "bun x tsc --noEmit", "check": "ultracite check", "fix": "ultracite fix", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "doctor": "npx react-doctor@latest" }, "dependencies": { "@base-ui/react": "^1.3.0", @@ -27,7 +28,6 @@ "@tanstack/react-router-devtools": "^1.132.0", "@tanstack/react-router-ssr-query": "^1.131.7", "@tanstack/react-start": "^1.132.0", - "@tanstack/router-plugin": "^1.132.0", "@uploadthing/react": "^7.3.3", "better-auth": "^1.5.5", "class-variance-authority": "^0.7.1", @@ -63,7 +63,6 @@ "typescript": "^5.7.2", "ultracite": "7.3.2", "vite": "^8.0.12", - "vitest": "^4", - "web-vitals": "^5.1.0" + "vitest": "^4" } } diff --git a/scripts/migrate-portal.ts b/scripts/migrate-portal.ts index 0f11c86..9e38ae8 100644 --- a/scripts/migrate-portal.ts +++ b/scripts/migrate-portal.ts @@ -5,8 +5,14 @@ let hadError = false; // Add new columns to invites table (ignore if already exist) for (const [col, ddl] of [ - ["initiated_by_client_id", "ALTER TABLE invites ADD COLUMN initiated_by_client_id TEXT REFERENCES clients(id) ON DELETE SET NULL"], - ["admin_approved_at", "ALTER TABLE invites ADD COLUMN admin_approved_at INTEGER"], + [ + "initiated_by_client_id", + "ALTER TABLE invites ADD COLUMN initiated_by_client_id TEXT REFERENCES clients(id) ON DELETE SET NULL", + ], + [ + "admin_approved_at", + "ALTER TABLE invites ADD COLUMN admin_approved_at INTEGER", + ], ] as const) { try { await db.run(sql.raw(ddl)); @@ -28,7 +34,8 @@ if (hadError) { // Create status_change_requests table try { - await db.run(sql.raw(` + await db.run( + sql.raw(` CREATE TABLE IF NOT EXISTS status_change_requests ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, @@ -40,7 +47,8 @@ try { reviewed_at INTEGER, created_at INTEGER NOT NULL ) - `)); + `) + ); console.log("✓ Created status_change_requests table"); } catch (e: unknown) { console.error("status_change_requests error:", e); diff --git a/src/__tests__/api-invite-management.test.ts b/src/__tests__/api-invite-management.test.ts index f7c9d53..95c5901 100644 --- a/src/__tests__/api-invite-management.test.ts +++ b/src/__tests__/api-invite-management.test.ts @@ -21,18 +21,18 @@ vi.mock("@/server/email/notifications", () => ({ import { getSessionUserFromHeaders } from "@/auth/session.server"; import { + approveInviteRecord, createInviteRecord, getActiveInviteById, getClientById, getInviteRecordById, refreshInviteExpiration, revokeInviteRecord, - approveInviteRecord, } from "@/db/records"; import { Route as InvitesRoute } from "@/routes/api/invites"; +import { Route as ApproveInviteRoute } from "@/routes/api/invites/$id/approve"; import { Route as ResendInviteRoute } from "@/routes/api/invites/$id/resend"; import { Route as RevokeInviteRoute } from "@/routes/api/invites/$id/revoke"; -import { Route as ApproveInviteRoute } from "@/routes/api/invites/$id/approve"; import { sendInviteEmail } from "@/server/email/notifications"; const inviteHandlers = InvitesRoute.options.server?.handlers as { diff --git a/src/components/auth/auth-shell.tsx b/src/components/auth/auth-shell.tsx index a65db6e..3e3c1d6 100644 --- a/src/components/auth/auth-shell.tsx +++ b/src/components/auth/auth-shell.tsx @@ -71,7 +71,7 @@ export function AuthShell({

- © {new Date().getFullYear()} Clientra. Refined workspace design. + © {new Date().getFullYear()} Clientra. Refined workspace design.
diff --git a/src/components/auth/worker-invite-form.tsx b/src/components/auth/worker-invite-form.tsx index cfa1e8c..9b41ac9 100644 --- a/src/components/auth/worker-invite-form.tsx +++ b/src/components/auth/worker-invite-form.tsx @@ -22,6 +22,11 @@ interface InvitationData { role: string; } +const handleSignOut = async () => { + await authClient.signOut(); + window.location.reload(); +}; + export function WorkerInviteForm({ token }: { token: string }) { const router = useRouter(); const [invitation, setInvitation] = useState(null); @@ -85,10 +90,7 @@ export function WorkerInviteForm({ token }: { token: string }) { } }; - const handleSignOut = async () => { - await authClient.signOut(); - window.location.reload(); - }; + if (loading) { return ( diff --git a/src/components/common/panel-section.tsx b/src/components/common/panel-section.tsx new file mode 100644 index 0000000..c0b9a1f --- /dev/null +++ b/src/components/common/panel-section.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface PanelSectionProps { + action?: ReactNode; + children: ReactNode; + className?: string; + description?: string; + title: string; + variant?: "card" | "ghost"; +} + +export function PanelSection({ + title, + description, + action, + children, + className, + variant = "card", +}: PanelSectionProps) { + return ( +
+
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {action} +
+ {children} +
+ ); +} diff --git a/src/components/common/product-charts.tsx b/src/components/common/product-charts.tsx index 86e5d52..cc34a69 100644 --- a/src/components/common/product-charts.tsx +++ b/src/components/common/product-charts.tsx @@ -60,17 +60,17 @@ export function ProjectStatusPieChart({ dark: ["#a1a1aa"], }; - if (statusKey.includes("progress")) { + if (/progress/.test(statusKey)) { colors = { light: ["#0d9488"], dark: ["#2dd4bf"], }; - } else if (statusKey.includes("completed")) { + } else if (/completed/.test(statusKey)) { colors = { light: ["#15803d"], dark: ["#22c55e"], }; - } else if (statusKey.includes("planning")) { + } else if (/planning/.test(statusKey)) { colors = { light: ["#0284c7"], dark: ["#38bdf8"], @@ -178,6 +178,14 @@ export function BudgetComposedChart({ } // ── Activity Pie/Donut Chart ────────────────────────────────────────────────── +// Build static chartConfig from data labels +const ACTIVITY_COLOR_PALETTE = [ + { light: palette.forest, dark: palette.forest }, + { light: palette.mint, dark: palette.mint }, + { light: palette.teal, dark: palette.teal }, + { light: palette.sky, dark: palette.sky }, +]; + export function ActivityPieChart({ data, isLoading, @@ -197,17 +205,9 @@ export function ActivityPieChart({ }; }); - // Build dynamic chartConfig from data labels - const colorPalette = [ - { light: palette.forest, dark: palette.forest }, - { light: palette.mint, dark: palette.mint }, - { light: palette.teal, dark: palette.teal }, - { light: palette.sky, dark: palette.sky }, - ]; - const chartConfig: ChartConfig = {}; keyedData.forEach((item, i) => { - const colors = colorPalette[i % colorPalette.length]; + const colors = ACTIVITY_COLOR_PALETTE[i % ACTIVITY_COLOR_PALETTE.length]; chartConfig[item._key] = { colors: { light: colors.light, dark: colors.dark }, label: item.label, @@ -231,6 +231,33 @@ export function ActivityPieChart({ } // ── Activity Sankey Chart ──────────────────────────────────────────────────── +// Source nodes (activity types) get distinct brand colors +const SANKEY_TYPE_COLORS: Record = { + Clients: { + light: ["#15803d"], + dark: ["#22c55e"], + }, + Projects: { + light: ["#047857"], + dark: ["#10b981"], + }, + Comments: { + light: ["#0284c7"], + dark: ["#38bdf8"], + }, + Files: { + light: ["#d97706"], + dark: ["#f59e0b"], + }, + Onboarding: { + light: ["#0d9488"], + dark: ["#2dd4bf"], + }, +}; + +// All project/target nodes share a single unified teal +const SANKEY_PROJECT_COLOR = { light: ["#0d9488"], dark: ["#2dd4bf"] }; + export function ActivitySankeyChart({ data, isLoading, @@ -238,45 +265,18 @@ export function ActivitySankeyChart({ data: SankeyData; isLoading?: boolean; }) { - // Source nodes (activity types) get distinct brand colors - const typeColors: Record = { - Clients: { - light: ["#15803d"], - dark: ["#22c55e"], - }, - Projects: { - light: ["#047857"], - dark: ["#10b981"], - }, - Comments: { - light: ["#0284c7"], - dark: ["#38bdf8"], - }, - Files: { - light: ["#d97706"], - dark: ["#f59e0b"], - }, - Onboarding: { - light: ["#0d9488"], - dark: ["#2dd4bf"], - }, - }; - - // All project/target nodes share a single unified teal - const projectColor = { light: ["#0d9488"], dark: ["#2dd4bf"] }; - const chartConfig: ChartConfig = {}; for (const node of data.nodes) { - if (typeColors[node.name]) { + if (SANKEY_TYPE_COLORS[node.name]) { chartConfig[node.name] = { label: node.name, - colors: typeColors[node.name], + colors: SANKEY_TYPE_COLORS[node.name], }; } else { chartConfig[node.name] = { label: node.name, - colors: projectColor, + colors: SANKEY_PROJECT_COLOR, }; } } diff --git a/src/components/common/route-pending.tsx b/src/components/common/route-pending.tsx index 4612810..c24d014 100644 --- a/src/components/common/route-pending.tsx +++ b/src/components/common/route-pending.tsx @@ -4,6 +4,40 @@ import { AppShell } from "@/components/layout/app-shell"; import { PortalShell } from "@/components/layout/portal-shell"; import { Skeleton } from "@/components/ui/skeleton"; +const CLIENTS_CARD_SKELETONS = [ + "client-s-1", + "client-s-2", + "client-s-3", + "client-s-4", + "client-s-5", + "client-s-6", +]; + +const CLIENT_DETAIL_DOSSIER_ITEMS = [ + "dossier-s-1", + "dossier-s-2", + "dossier-s-3", + "dossier-s-4", + "dossier-s-5", + "dossier-s-6", +]; +const CLIENT_DETAIL_PROJECT_SKELETONS = ["project-s-1", "project-s-2"]; +const CLIENT_DETAIL_INVITE_SKELETONS = ["invite-s-1", "invite-s-2"]; + +const PROJECT_DETAIL_MILESTONES_SKELETONS = ["milestone-s-1", "milestone-s-2"]; +const PROJECT_DETAIL_COMMENT_SKELETONS = ["comment-s-1", "comment-s-2", "comment-s-3"]; +const PROJECT_DETAIL_FILE_SKELETONS = ["file-s-1", "file-s-2"]; +const PROJECT_DETAIL_UPDATE_SKELETONS = ["update-s-1", "update-s-2"]; + +const PORTAL_HOME_CARD_SKELETONS = ["active-s-1", "active-s-2"]; +const PORTAL_PROJECTS_CARD_SKELETONS = ["proj-s-1", "proj-s-2", "proj-s-3"]; + +const PORTAL_PROJECT_DETAIL_MILESTONES_SKELETONS = ["milestone-s-1", "milestone-s-2"]; +const PORTAL_PROJECT_DETAIL_COMMENT_SKELETONS = ["comment-s-1", "comment-s-2"]; +const PORTAL_PROJECT_DETAIL_FILE_SKELETONS = ["file-s-1", "file-s-2"]; +const PORTAL_PROJECT_DETAIL_UPDATE_SKELETONS = ["update-s-1", "update-s-2"]; + + interface PendingShellProps { children: ReactNode; testId: string; @@ -257,15 +291,6 @@ export function UsersPendingPage() { } export function ClientsPendingPage() { - const cardSkeletons = [ - "client-s-1", - "client-s-2", - "client-s-3", - "client-s-4", - "client-s-5", - "client-s-6", - ]; - return (
- {cardSkeletons.map((id) => ( + {CLIENTS_CARD_SKELETONS.map((id) => (
- {dossierItems.map((id) => ( + {CLIENT_DETAIL_DOSSIER_ITEMS.map((id) => (
@@ -370,7 +384,7 @@ export function ClientDetailPendingPage() { Linked Projects
- {projectSkeletons.map((id) => ( + {CLIENT_DETAIL_PROJECT_SKELETONS.map((id) => (
- {inviteSkeletons.map((id) => ( + {CLIENT_DETAIL_INVITE_SKELETONS.map((id) => (
@@ -524,7 +533,7 @@ export function ProjectDetailPendingPage() {
- {milestonesSkeletons.map((id) => ( + {PROJECT_DETAIL_MILESTONES_SKELETONS.map((id) => (
- {commentSkeletons.map((id) => ( + {PROJECT_DETAIL_COMMENT_SKELETONS.map((id) => (
- {fileSkeletons.map((id) => ( + {PROJECT_DETAIL_FILE_SKELETONS.map((id) => (
- {updateSkeletons.map((id) => ( + {PROJECT_DETAIL_UPDATE_SKELETONS.map((id) => (
- {cardSkeletons.map((id) => ( + {PORTAL_HOME_CARD_SKELETONS.map((id) => (
- {cardSkeletons.map((id) => ( + {PORTAL_PROJECTS_CARD_SKELETONS.map((id) => (
@@ -832,7 +833,7 @@ export function PortalProjectDetailPendingPage() {
- {milestonesSkeletons.map((id) => ( + {PORTAL_PROJECT_DETAIL_MILESTONES_SKELETONS.map((id) => (
- {commentSkeletons.map((id) => ( + {PORTAL_PROJECT_DETAIL_COMMENT_SKELETONS.map((id) => (
- {fileSkeletons.map((id) => ( + {PORTAL_PROJECT_DETAIL_FILE_SKELETONS.map((id) => (
- {updateSkeletons.map((id) => ( + {PORTAL_PROJECT_DETAIL_UPDATE_SKELETONS.map((id) => (
); } + +export function MutationErrorBanner({ + error, +}: { + error: { message: string } | null | undefined; +}) { + if (!error) { + return null; + } + return ( +

+ {error.message} +

+ ); +} diff --git a/src/components/evilcharts/charts/bar-chart.tsx b/src/components/evilcharts/charts/bar-chart.tsx index 8aceb73..e5d300b 100644 --- a/src/components/evilcharts/charts/bar-chart.tsx +++ b/src/components/evilcharts/charts/bar-chart.tsx @@ -138,6 +138,8 @@ type EvilBarChartPropsWithCallback< > = EvilBarChartProps & (EvilBarChartClickable | EvilBarChartNotClickable); +const EMPTY_GLOWING_BARS: any[] = []; + export function EvilBarChart< TData extends Record, TConfig extends Record, @@ -169,7 +171,7 @@ export function EvilBarChart< enableHoverHighlight = false, isLoading = false, loadingBars, - glowingBars = [], + glowingBars = EMPTY_GLOWING_BARS, showBrush = false, brushHeight, brushFormatLabel, diff --git a/src/components/evilcharts/charts/line-chart.tsx b/src/components/evilcharts/charts/line-chart.tsx index 28c65bc..f37319d 100644 --- a/src/components/evilcharts/charts/line-chart.tsx +++ b/src/components/evilcharts/charts/line-chart.tsx @@ -130,6 +130,8 @@ type EvilLineChartPropsWithCallback< > = EvilLineChartProps & (EvilLineChartClickable | EvilLineChartNotClickable); +const EMPTY_GLOWING_LINES: any[] = []; + export function EvilLineChart< TData extends Record, TConfig extends Record, @@ -160,7 +162,7 @@ export function EvilLineChart< isClickable = false, isLoading = false, loadingPoints, - glowingLines = [], + glowingLines = EMPTY_GLOWING_LINES, showBrush = false, brushHeight, brushFormatLabel, diff --git a/src/components/evilcharts/charts/radial-chart.tsx b/src/components/evilcharts/charts/radial-chart.tsx index 8e9cd50..f4447f3 100644 --- a/src/components/evilcharts/charts/radial-chart.tsx +++ b/src/components/evilcharts/charts/radial-chart.tsx @@ -100,6 +100,8 @@ type EvilRadialChartPropsWithCallback> = EvilRadialChartProps & (EvilRadialChartClickable | EvilRadialChartNotClickable); +const EMPTY_GLOWING_BARS: string[] = []; + export function EvilRadialChart>({ data, dataKey, @@ -122,7 +124,7 @@ export function EvilRadialChart>({ tooltipDefaultIndex, isClickable = false, isLoading = false, - glowingBars = [], + glowingBars = EMPTY_GLOWING_BARS, onSelectionChange, backgroundVariant, }: EvilRadialChartPropsWithCallback) { diff --git a/src/components/evilcharts/charts/sankey-chart.tsx b/src/components/evilcharts/charts/sankey-chart.tsx index a041e50..7f78c5c 100644 --- a/src/components/evilcharts/charts/sankey-chart.tsx +++ b/src/components/evilcharts/charts/sankey-chart.tsx @@ -799,50 +799,50 @@ const GlowFilter = ({ * The skeleton sankey shown while the chart is loading. Rendered by the root in * place of the real diagram — a fixed grid of pulsing nodes and links. */ -const LoadingSankey = () => { - const nodes = [ - { x: 30, y: 25, width: 12, height: 65, delay: 0 }, - { x: 30, y: 110, width: 12, height: 50, delay: 0.3 }, - { x: 30, y: 180, width: 12, height: 45, delay: 0.15 }, - { x: 244, y: 20, width: 12, height: 55, delay: 0.45 }, - { x: 244, y: 95, width: 12, height: 75, delay: 0.6 }, - { x: 244, y: 190, width: 12, height: 40, delay: 0.25 }, - { x: 458, y: 35, width: 12, height: 80, delay: 0.5 }, - { x: 458, y: 135, width: 12, height: 90, delay: 0.1 }, - ]; - - const links = [ - { from: 0, to: 3, width: 26, delay: 0.2 }, - { from: 0, to: 4, width: 18, delay: 0.7 }, - { from: 1, to: 4, width: 24, delay: 0.4 }, - { from: 1, to: 5, width: 12, delay: 0.9 }, - { from: 2, to: 4, width: 16, delay: 0.1 }, - { from: 2, to: 5, width: 14, delay: 0.55 }, - { from: 3, to: 6, width: 22, delay: 0.35 }, - { from: 3, to: 7, width: 18, delay: 0.8 }, - { from: 4, to: 6, width: 28, delay: 0.05 }, - { from: 4, to: 7, width: 32, delay: 0.65 }, - { from: 5, to: 7, width: 16, delay: 0.45 }, - ]; - - // Builds a bezier path connecting the right edge of one node to the left of another - const getLinkPath = (fromIdx: number, toIdx: number) => { - const from = nodes[fromIdx]; - const to = nodes[toIdx]; - const startX = from.x + from.width; - const startY = from.y + from.height / 2; - const endX = to.x; - const endY = to.y + to.height / 2; - const controlX1 = startX + (endX - startX) * 0.4; - const controlX2 = startX + (endX - startX) * 0.6; - return `M${startX},${startY} C${controlX1},${startY} ${controlX2},${endY} ${endX},${endY}`; - }; +const LOADING_NODES = [ + { x: 30, y: 25, width: 12, height: 65, delay: 0 }, + { x: 30, y: 110, width: 12, height: 50, delay: 0.3 }, + { x: 30, y: 180, width: 12, height: 45, delay: 0.15 }, + { x: 244, y: 20, width: 12, height: 55, delay: 0.45 }, + { x: 244, y: 95, width: 12, height: 75, delay: 0.6 }, + { x: 244, y: 190, width: 12, height: 40, delay: 0.25 }, + { x: 458, y: 35, width: 12, height: 80, delay: 0.5 }, + { x: 458, y: 135, width: 12, height: 90, delay: 0.1 }, +]; + +const LOADING_LINKS = [ + { from: 0, to: 3, width: 26, delay: 0.2 }, + { from: 0, to: 4, width: 18, delay: 0.7 }, + { from: 1, to: 4, width: 24, delay: 0.4 }, + { from: 1, to: 5, width: 12, delay: 0.9 }, + { from: 2, to: 4, width: 16, delay: 0.1 }, + { from: 2, to: 5, width: 14, delay: 0.55 }, + { from: 3, to: 6, width: 22, delay: 0.35 }, + { from: 3, to: 7, width: 18, delay: 0.8 }, + { from: 4, to: 6, width: 28, delay: 0.05 }, + { from: 4, to: 7, width: 32, delay: 0.65 }, + { from: 5, to: 7, width: 16, delay: 0.45 }, +]; + +// Builds a bezier path connecting the right edge of one node to the left of another +const getLinkPath = (fromIdx: number, toIdx: number) => { + const from = LOADING_NODES[fromIdx]; + const to = LOADING_NODES[toIdx]; + const startX = from.x + from.width; + const startY = from.y + from.height / 2; + const endX = to.x; + const endY = to.y + to.height / 2; + const controlX1 = startX + (endX - startX) * 0.4; + const controlX2 = startX + (endX - startX) * 0.6; + return `M${startX},${startY} C${controlX1},${startY} ${controlX2},${endY} ${endX},${endY}`; +}; +const LoadingSankey = () => { const baseDuration = LOADING_ANIMATION_DURATION / 1000; return ( <> - {links.map((link, i) => ( + {LOADING_LINKS.map((link, i) => ( { }} /> ))} - {nodes.map((node, i) => ( + {LOADING_NODES.map((node, i) => ( (null); export function useChart() { - const context = React.useContext(ChartContext); + const context = React.use(ChartContext); if (!context) { throw new Error("useChart must be used within a "); @@ -102,8 +102,10 @@ function ChartContainer({ // Validate chart config at runtime validateChartConfigColors(config); + const contextValue = React.useMemo(() => ({ config }), [config]); + return ( - +
{ (color, index) => ` --color-${key}-${index}: ${color};` ); }) - .filter(Boolean) .join("\n"); const css = Object.entries(THEMES) @@ -209,7 +210,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { ) .join("\n"); - return ; }; // Helper to extract item config from a payload. diff --git a/src/components/evilcharts/ui/evil-brush.tsx b/src/components/evilcharts/ui/evil-brush.tsx index bd36e76..faaf5ee 100644 --- a/src/components/evilcharts/ui/evil-brush.tsx +++ b/src/components/evilcharts/ui/evil-brush.tsx @@ -249,18 +249,15 @@ function EvilBrush({ // mouse movements don't produce index changes (e.g., at boundaries) const lastCommittedRef = React.useRef(internalRange); - useEffect(() => { - if (!isControlled) { - setInternalRange((prev) => { - const adjusted = { - startIndex: Math.min(prev.startIndex, Math.max(0, totalPoints - 1)), - endIndex: Math.min(prev.endIndex, Math.max(0, totalPoints - 1)), - }; - lastCommittedRef.current = adjusted; - return adjusted; - }); - } - }, [totalPoints, isControlled]); + // Adjust range when totalPoints changes during render + if (totalPoints > 0 && (internalRange.startIndex > totalPoints - 1 || internalRange.endIndex > totalPoints - 1)) { + const adjusted = { + startIndex: Math.min(internalRange.startIndex, Math.max(0, totalPoints - 1)), + endIndex: Math.min(internalRange.endIndex, Math.max(0, totalPoints - 1)), + }; + setInternalRange(adjusted); + lastCommittedRef.current = adjusted; + } // ── Clamping & committing ─────────────────────────────────────────────── @@ -333,18 +330,19 @@ function EvilBrush({ // Position always driven by internalRange (never lags behind controlled props) const range = internalRange; - // Sync internalRange with controlled props when not dragging - useEffect(() => { - if (isControlled && !isDragging) { - const syncedRange = { - startIndex: controlledStart, - endIndex: controlledEnd, - }; - // eslint-disable-next-line react-hooks/set-state-in-effect - setInternalRange(syncedRange); - lastCommittedRef.current = syncedRange; - } - }, [isControlled, controlledStart, controlledEnd, isDragging]); + const prevStartRef = React.useRef(controlledStart); + const prevEndRef = React.useRef(controlledEnd); + + if (isControlled && (controlledStart !== prevStartRef.current || controlledEnd !== prevEndRef.current)) { + prevStartRef.current = controlledStart; + prevEndRef.current = controlledEnd; + const syncedRange = { + startIndex: controlledStart, + endIndex: controlledEnd, + }; + setInternalRange(syncedRange); + lastCommittedRef.current = syncedRange; + } // ── Computed positions (%) ────────────────────────────────────────────── @@ -543,16 +541,19 @@ function MiniChart({ connectNulls?: boolean; barRadius?: number; }) { - const gradients = React.useMemo( - () => - Object.entries(chartConfig) - .filter(([key]) => keys.includes(key)) - .map(([dataKey, config]) => ({ - dataKey, + const gradients = React.useMemo(() => { + const result: Array<{ dataKey: string; colorsCount: number }> = []; + const keysSet = new Set(keys); + for (const [key, config] of Object.entries(chartConfig)) { + if (keysSet.has(key)) { + result.push({ + dataKey: key, colorsCount: getColorsCount(config), - })), - [chartConfig, keys] - ); + }); + } + } + return result; + }, [chartConfig, keys]); const dashArray = strokeVariant === "dashed" || strokeVariant === "animated-dashed" @@ -740,13 +741,14 @@ function useEvilBrush>({ // deferred value. React can skip intermediate slices during fast drags. const deferredRange = React.useDeferredValue(range); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + const [prevDataLength, setPrevDataLength] = React.useState(data.length); + if (data.length !== prevDataLength) { + setPrevDataLength(data.length); setRange({ startIndex: 0, endIndex: Math.max(0, data.length - 1), }); - }, [data.length]); + } const visibleData = React.useMemo( () => data.slice(deferredRange.startIndex, deferredRange.endIndex + 1), diff --git a/src/components/evilcharts/ui/legend.tsx b/src/components/evilcharts/ui/legend.tsx index 14dc8d0..c458310 100644 --- a/src/components/evilcharts/ui/legend.tsx +++ b/src/components/evilcharts/ui/legend.tsx @@ -52,38 +52,43 @@ function ChartLegendContent({ className )} > - {payload - .filter((item) => item.type !== "none") - .map((item) => { - // For pie charts, item.value contains the sector name (e.g., "chrome") - // For radial charts, the name is in item.payload[nameKey] - // For other charts, item.dataKey contains the series name (e.g., "desktop") - const payloadName = - nameKey && item.payload - ? (item.payload as Record)[nameKey] - : undefined; - const key = `${payloadName ?? item.value ?? item.dataKey ?? "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const isSelected = selected === null || selected === key; - - // Get colors count for this item to determine gradient vs solid - const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1; - + {payload.map((item) => { + if (item.type === "none") { + return null; + } + + // For pie charts, item.value contains the sector name (e.g., "chrome") + // For radial charts, the name is in item.payload[nameKey] + // For other charts, item.dataKey contains the series name (e.g., "desktop") + const payloadName = + nameKey && item.payload + ? (item.payload as Record)[nameKey] + : undefined; + const key = `${payloadName ?? item.value ?? item.dataKey ?? "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const isSelected = selected === null || selected === key; + + // Get colors count for this item to determine gradient vs solid + const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1; + + const handleSelect = () => { + if (!isClickable) { + return; + } + + onSelectChange?.(selected === key ? null : key); + }; + + if (isClickable) { return ( -
svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", - !isSelected && "opacity-30", - isClickable && "cursor-pointer" + "flex items-center gap-1.5 transition-opacity [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground cursor-pointer text-left bg-transparent border-none p-0 font-sans text-sm", + !isSelected && "opacity-30" )} key={key} - onClick={() => { - if (!isClickable) { - return; - } - - onSelectChange?.(selected === key ? null : key); - }} + onClick={handleSelect} > {itemConfig?.icon && !hideIcon ? ( @@ -95,9 +100,31 @@ function ChartLegendContent({ /> )} {itemConfig?.label} -
+ ); - })} + } + + return ( +
svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", + !isSelected && "opacity-30" + )} + key={key} + > + {itemConfig?.icon && !hideIcon ? ( + + ) : ( + + )} + {itemConfig?.label} +
+ ); + })}
); } diff --git a/src/components/evilcharts/ui/tooltip.tsx b/src/components/evilcharts/ui/tooltip.tsx index 76887d7..7cfd9d0 100644 --- a/src/components/evilcharts/ui/tooltip.tsx +++ b/src/components/evilcharts/ui/tooltip.tsx @@ -26,7 +26,31 @@ const variantMap: Record = { "frosted-glass": "bg-background/70 backdrop-blur-sm", }; -function ChartTooltipContent({ +function ChartTooltipContent( + props: React.ComponentProps & + React.ComponentProps<"div"> & { + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; + selected?: string | null; + roundness?: TooltipRoundness; + variant?: TooltipVariant; + } & Omit< + RechartsPrimitive.DefaultTooltipContentProps, + "accessibilityLayer" + > +) { + if (!(props.active && props.payload?.length)) { + // Empty tooltip - to prevent position getting 0.0 so it doesnt animate tooltip every time from 0.0 origin + return ; + } + + return ; +} + +function ChartTooltipContentInner({ active, payload, className, @@ -94,11 +118,6 @@ function ChartTooltipContent({ labelKey, ]); - if (!(active && payload?.length)) { - // Empty tooltip - to prevent position getting 0.0 so it doesnt animate tooltip every time from 0.0 origin - return ; - } - const nestLabel = payload.length === 1 && indicator !== "dot"; return ( @@ -112,33 +131,35 @@ function ChartTooltipContent({ > {nestLabel ? null : tooltipLabel}
- {payload - .filter((item) => item.type !== "none") - .map((item, index) => { - // For pie charts, item.name contains the sector name (e.g., "chrome") - // For radial charts, the name is in item.payload[nameKey] - // For other charts, item.name or item.dataKey contains the series name - const payloadName = - nameKey && item.payload - ? (item.payload as Record)[nameKey] - : undefined; - const key = `${payloadName ?? item.name ?? item.dataKey ?? "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - - // Get colors count for this item to determine gradient vs solid - const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1; - - return ( -
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", - indicator === "dot" && "items-center", - selected != null && selected !== item.dataKey && "opacity-30" - )} - key={index} - > - {formatter && item?.value !== undefined && item.name ? ( - formatter(item.value, item.name, item, index, item.payload) + {payload.map((item, index) => { + if (item.type === "none") { + return null; + } + + // For pie charts, item.name contains the sector name (e.g., "chrome") + // For radial charts, the name is in item.payload[nameKey] + // For other charts, item.name or item.dataKey contains the series name + const payloadName = + nameKey && item.payload + ? (item.payload as Record)[nameKey] + : undefined; + const key = `${payloadName ?? item.name ?? item.dataKey ?? "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + + // Get colors count for this item to determine gradient vs solid + const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1; + + return ( +
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center", + selected != null && selected !== item.dataKey && "opacity-30" + )} + key={key} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) ) : ( <> {itemConfig?.icon ? ( diff --git a/src/components/layout/portal-shell.tsx b/src/components/layout/portal-shell.tsx index e8d439d..8d65cec 100644 --- a/src/components/layout/portal-shell.tsx +++ b/src/components/layout/portal-shell.tsx @@ -42,7 +42,12 @@ import { authClient } from "@/lib/auth-client"; const logoUrl = "/logo.webp"; const portalNav = [ - { href: "/portal", label: "Overview", icon: DashboardSquare01Icon, exact: true }, + { + href: "/portal", + label: "Overview", + icon: DashboardSquare01Icon, + exact: true, + }, { href: "/portal/projects", label: "Projects", icon: Briefcase01Icon }, { href: "/portal/files", label: "Files", icon: File01Icon }, { href: "/portal/activity", label: "Activity", icon: Activity01Icon }, @@ -68,7 +73,9 @@ function PortalNavUser() { } }; - if (!user) return null; + if (!user) { + return null; + } return ( @@ -125,11 +132,11 @@ function PortalSidebarNav() { width={32} /> {showText && ( -
+
Clientra - + Client Portal
@@ -145,9 +152,11 @@ function PortalSidebarNav() { {portalNav.map((item) => ( {({ isActive }) => (
{/* Discussion Column */} -
-
-
-

- Project Discussion -

-

- Share updates, ask questions, or leave feedback. -

-
+ onAddOpenChange(true)} @@ -237,8 +230,11 @@ export function ProjectCollaborationView({ > Post Comment -
- + } + description="Share updates, ask questions, or leave feedback." + title="Project Discussion" + variant="ghost" + > {/* Comment Creation Dialog */} @@ -325,19 +321,14 @@ export function ProjectCollaborationView({
)}
- + {/* Activity Timeline Column */} -
-
-

- Activity Timeline -

-

- Chronological feed of project events. -

-
- +
)} - +
); diff --git a/src/components/projects/project-files-panel.tsx b/src/components/projects/project-files-panel.tsx index 8e24152..61fbdff 100644 --- a/src/components/projects/project-files-panel.tsx +++ b/src/components/projects/project-files-panel.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Delete02Icon, Download01Icon, @@ -12,10 +10,12 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { useQueryClient } from "@tanstack/react-query"; import { useRef, useState } from "react"; +import { PanelSection } from "@/components/common/panel-section"; import { EmptyPanel, ErrorPanel, LoadingPanel, + MutationErrorBanner, } from "@/components/common/state-panel"; import { Button } from "@/components/ui/button"; import { @@ -83,38 +83,23 @@ function getUploadValidationError(files: File[]) { return null; } -function getFileIcon(mimeType: string, fileName: string) { - const name = fileName.toLowerCase(); - const mime = mimeType.toLowerCase(); - - if (mime.startsWith("image/")) { - return FileImageIcon; - } - if (mime === "application/pdf" || name.endsWith(".pdf")) { - return Pdf01Icon; - } - if ( - mime.includes("spreadsheet") || - mime.includes("csv") || - mime.includes("excel") || - name.endsWith(".csv") || - name.endsWith(".xls") || - name.endsWith(".xlsx") - ) { - return FileChartColumnIcon; - } - return FileEmpty01Icon; -} - -function getFileIconColor(mimeType: string, fileName: string) { +function getFileTypeInfo(mimeType: string, fileName: string) { const name = fileName.toLowerCase(); const mime = mimeType.toLowerCase(); if (mime.startsWith("image/")) { - return "text-purple-600 bg-purple-50 dark:bg-purple-950/20 border-purple-200 dark:border-purple-900"; + return { + icon: FileImageIcon, + colorStyles: + "text-purple-600 bg-purple-50 dark:bg-purple-950/20 border-purple-200 dark:border-purple-900", + }; } if (mime === "application/pdf" || name.endsWith(".pdf")) { - return "text-rose-600 bg-rose-50 dark:bg-rose-950/20 border-rose-200 dark:border-rose-900"; + return { + icon: Pdf01Icon, + colorStyles: + "text-rose-600 bg-rose-50 dark:bg-rose-950/20 border-rose-200 dark:border-rose-900", + }; } if ( mime.includes("spreadsheet") || @@ -124,9 +109,17 @@ function getFileIconColor(mimeType: string, fileName: string) { name.endsWith(".xls") || name.endsWith(".xlsx") ) { - return "text-emerald-600 bg-emerald-50 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-900"; + return { + icon: FileChartColumnIcon, + colorStyles: + "text-emerald-600 bg-emerald-50 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-900", + }; } - return "text-teal-600 bg-teal-50 dark:bg-teal-950/20 border-teal-200 dark:border-teal-900"; + return { + icon: FileEmpty01Icon, + colorStyles: + "text-teal-600 bg-teal-50 dark:bg-teal-950/20 border-teal-200 dark:border-teal-900", + }; } interface ProjectFilesPanelProps { @@ -218,19 +211,10 @@ export function ProjectFilesPanel({ Boolean(filesQuery.error && visibleFiles.length === 0); return ( -
- {/* Panel Header */} -
-
-

- Project Files -

-

- Share and retrieve assets, templates, or documents securely. -

-
-
- + - {mutationError ? ( -
- {mutationError} -
- ) : null} + {/* Loading & Error States */} {filesQuery.isLoading && visibleFiles.length === 0 ? ( @@ -316,8 +298,7 @@ export function ProjectFilesPanel({ {visibleFiles.map((file) => { - const IconComponent = getFileIcon(file.mimeType, file.fileName); - const colorStyles = getFileIconColor( + const { icon: IconComponent, colorStyles } = getFileTypeInfo( file.mimeType, file.fileName ); @@ -389,6 +370,6 @@ export function ProjectFilesPanel({
) : null} - + ); } diff --git a/src/components/projects/project-milestones-panel.tsx b/src/components/projects/project-milestones-panel.tsx index 298cb21..af1ed0b 100644 --- a/src/components/projects/project-milestones-panel.tsx +++ b/src/components/projects/project-milestones-panel.tsx @@ -5,10 +5,12 @@ import { } from "@hugeicons/core-free-icons"; import { type FormEvent, useState } from "react"; import { UnifiedActivityList } from "@/components/common/activity-list"; +import { PanelSection } from "@/components/common/panel-section"; import { EmptyPanel, ErrorPanel, LoadingPanel, + MutationErrorBanner, } from "@/components/common/state-panel"; import { Button } from "@/components/ui/button"; import { @@ -44,6 +46,7 @@ import { useProjectMilestonesData, useUpdateProjectMilestoneMutation, } from "@/lib/api"; +import { optionalString } from "@/lib/utils"; const MILESTONE_STATUS_OPTIONS = [ { label: "To do", value: "todo" }, @@ -75,11 +78,6 @@ function toMilestonePayload( }; } -function optionalString(value: string) { - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - export function formatMilestoneStatus(status: ProjectMilestoneStatus) { return ( MILESTONE_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? @@ -335,6 +333,17 @@ function ProjectMilestoneForm({ ); } +function useCrudDialogs() { + const [isAddOpen, setIsAddOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + return { + isAddOpen, + setIsAddOpen, + editingItem, + setEditingItem, + }; +} + export function ProjectMilestonesPanel({ canManage, projectId, @@ -346,9 +355,12 @@ export function ProjectMilestonesPanel({ const createMilestone = useCreateProjectMilestoneMutation(); const updateMilestone = useUpdateProjectMilestoneMutation(); const deleteMilestone = useDeleteProjectMilestoneMutation(); - const [isAddOpen, setIsAddOpen] = useState(false); - const [editingMilestone, setEditingMilestone] = - useState(null); + const { + isAddOpen, + setIsAddOpen, + editingItem: editingMilestone, + setEditingItem: setEditingMilestone, + } = useCrudDialogs(); if (milestonesQuery.isLoading && !milestonesQuery.data) { return ( @@ -368,17 +380,9 @@ export function ProjectMilestonesPanel({ const milestones = milestonesQuery.data ?? []; return ( -
-
-
-

- Project Milestones -

-

- Track key deliverables and deadlines for maximum transparency. -

-
- {canManage && ( + setIsAddOpen(true)} @@ -386,9 +390,11 @@ export function ProjectMilestonesPanel({ > Add Milestone - )} -
- + ) + } + description="Track key deliverables and deadlines for maximum transparency." + title="Project Milestones" + > {/* Add Milestone Dialog */} {canManage && ( @@ -462,11 +468,7 @@ export function ProjectMilestonesPanel({ }} onEdit={setEditingMilestone} /> - {deleteMilestone.error ? ( -

- {deleteMilestone.error.message} -

- ) : null} -
+ + ); } diff --git a/src/components/projects/project-updates-panel.tsx b/src/components/projects/project-updates-panel.tsx index 106c0bb..00f0216 100644 --- a/src/components/projects/project-updates-panel.tsx +++ b/src/components/projects/project-updates-panel.tsx @@ -1,9 +1,11 @@ import { type FormEvent, useState } from "react"; import { UnifiedActivityList } from "@/components/common/activity-list"; +import { PanelSection } from "@/components/common/panel-section"; import { EmptyPanel, ErrorPanel, LoadingPanel, + MutationErrorBanner, } from "@/components/common/state-panel"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -15,6 +17,22 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { type ProjectUpdate, type ProjectUpdatePayload, @@ -201,6 +219,11 @@ function ProjectUpdateForm({ } } + const statusItems = UPDATE_STATUS_OPTIONS.map((option) => ({ + label: option.label, + value: option.value, + })); + return (
undefined); }} > -
-