diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml
new file mode 100644
index 0000000..fba4dc6
--- /dev/null
+++ b/.github/workflows/react-doctor.yml
@@ -0,0 +1,47 @@
+# 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:
+ # Scans the PR's changed files and posts a sticky summary comment listing only the new issues introduced relative to the merge base of the target branch.
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+ # Scans `main` on every push to track the health-score trend and catch regressions that slipped past PR review.
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+ statuses: 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ persist-credentials: false
+
+ - uses: millionco/react-doctor@75932719d11ef50e110eac01ce04acdc05fde074 # v2.0.1
+ # Common configuration knobs — uncomment any to override the default.
+ # Full reference: https://www.react.doctor/ci
+ # with:
+ # blocking: warning # Gate level: "error" (default) | "warning" | "none" (advisory)
+ # scope: full # On PRs, scan the whole project instead of just changed files
+ # comment: false # Disable the sticky PR summary comment
+ # review-comments: false # Disable inline review comments on changed lines
+ # commit-status: false # Disable the commit status (score + counts, links to the run)
+ # version: "0.4.0" # 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/.gitignore b/.gitignore
index 98f79b6..bd1618c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,16 @@
-node_modules
-.DS_Store
-dist
-dist-ssr
-*.local
-count.txt
-.env
-.nitro
-.tanstack
-.wrangler
-.output
-.vinxi
-__unconfig*
-todos.json
-local.db
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
+count.txt
+.env
+.nitro
+.tanstack
+.wrangler
+.output
+.vinxi
+__unconfig*
+todos.json
+local.db
+react-doctor-report.json
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..16f0564 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@2"
},
"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 ;
+ 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..3f8893c 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)) {
+ const clamped = clampRange({
+ startIndex: controlledStart,
+ endIndex: controlledEnd,
+ });
+ prevStartRef.current = controlledStart;
+ prevEndRef.current = controlledEnd;
+ setInternalRange(clamped);
+ lastCommittedRef.current = clamped;
+ }
// ── 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..42ea25c 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,9 +131,15 @@ function ChartTooltipContent({
>
{nestLabel ? null : tooltipLabel}
- {payload
- .filter((item) => item.type !== "none")
- .map((item, index) => {
+ {(() => {
+ let visibleIndex = 0;
+ return payload.map((item) => {
+ if (item.type === "none") {
+ return null;
+ }
+
+ const currentVisibleIndex = visibleIndex++;
+
// 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
@@ -122,7 +147,7 @@ function ChartTooltipContent({
nameKey && item.payload
? (item.payload as Record)[nameKey]
: undefined;
- const key = `${payloadName ?? item.name ?? item.dataKey ?? "value"}`;
+ const key = `${payloadName ?? item.value ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
// Get colors count for this item to determine gradient vs solid
@@ -135,10 +160,10 @@ function ChartTooltipContent({
indicator === "dot" && "items-center",
selected != null && selected !== item.dataKey && "opacity-30"
)}
- key={index}
+ key={key}
>
{formatter && item?.value !== undefined && item.name ? (
- formatter(item.value, item.name, item, index, item.payload)
+ formatter(item.value, item.name, item, currentVisibleIndex, item.payload)
) : (
<>
{itemConfig?.icon ? (
@@ -181,7 +206,8 @@ function ChartTooltipContent({
)}
);
- })}
+ });
+ })()}
);
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 */}
)}
-
+
{/* 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 && (
+
+
);
}
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 (