From ca7e2c64de1057184221069f6e8308b5a234ae73 Mon Sep 17 00:00:00 2001 From: TheodorTomas Date: Wed, 28 Jan 2026 14:45:25 +0400 Subject: [PATCH] feat: init commit for dashboard --- .../analysis/chart-builder/page.tsx | 196 +++++++++++ .../[environmentId]/analysis/charts/page.tsx | 109 ++++++ .../analysis/dashboard/[dashboardId]/page.tsx | 183 ++++++++++ .../analysis/dashboards/page.tsx | 133 ++++++++ .../[environmentId]/analysis/layout.tsx | 57 ++++ .../[environmentId]/analysis/page.tsx | 9 + .../[environmentId]/analysis/store.ts | 182 ++++++++++ .../components/MainNavigation.tsx | 10 +- .../environments/[environmentId]/layout.tsx | 6 + apps/web/i18n.lock | 1 + apps/web/locales/de-DE.json | 1 + apps/web/locales/en-US.json | 1 + apps/web/locales/es-ES.json | 1 + apps/web/locales/fr-FR.json | 1 + apps/web/locales/ja-JP.json | 1 + apps/web/locales/nl-NL.json | 1 + apps/web/locales/pt-BR.json | 1 + apps/web/locales/pt-PT.json | 1 + apps/web/locales/ro-RO.json | 1 + apps/web/locales/ru-RU.json | 1 + apps/web/locales/sv-SE.json | 1 + apps/web/locales/zh-Hans-CN.json | 1 + apps/web/locales/zh-Hant-TW.json | 1 + apps/web/modules/ui/components/chart.tsx | 303 +++++++++++++++++ apps/web/package.json | 4 +- cube/cube.js | 15 + cube/schema/FeedbackRecords.js | 44 +++ docker-compose.dev.yml | 18 + pnpm-lock.yaml | 318 +++++++++++++++++- 29 files changed, 1587 insertions(+), 14 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/analysis/chart-builder/page.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/analysis/charts/page.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/analysis/dashboard/[dashboardId]/page.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/analysis/dashboards/page.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/analysis/layout.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/analysis/page.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/analysis/store.ts create mode 100644 apps/web/modules/ui/components/chart.tsx create mode 100644 cube/cube.js create mode 100644 cube/schema/FeedbackRecords.js diff --git a/apps/web/app/(app)/environments/[environmentId]/analysis/chart-builder/page.tsx b/apps/web/app/(app)/environments/[environmentId]/analysis/chart-builder/page.tsx new file mode 100644 index 0000000000..cebfad0470 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/analysis/chart-builder/page.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { + ActivityIcon, + AreaChartIcon, + BarChart3Icon, + LineChartIcon, + MapIcon, + PieChartIcon, + ScatterChart, + SearchIcon, + TableIcon, +} from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/lib/cn"; +import { Button } from "@/modules/ui/components/button"; +import { Input } from "@/modules/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; + +// --- Mock Data --- + +const DATASETS = ["FCC 2018 Survey", "users_channels", "messages", "new_members_daily", "users"]; + +const CHART_TYPES = [ + { id: "area", name: "Area Chart", icon: AreaChartIcon }, + { id: "bar", name: "Bar Chart", icon: BarChart3Icon }, + { id: "line", name: "Line Chart", icon: LineChartIcon }, + { id: "pie", name: "Pie Chart", icon: PieChartIcon }, + { id: "table", name: "Table", icon: TableIcon }, + { id: "big_number", name: "Big Number", icon: ActivityIcon }, // Fallback icon + { id: "scatter", name: "Scatter Plot", icon: ScatterChart }, // Fallback icon + { id: "map", name: "World Map", icon: MapIcon }, +]; + +export default function ChartBuilderPage() { + const [selectedDataset, setSelectedDataset] = useState(""); + const [selectedChartType, setSelectedChartType] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [userQuery, setUserQuery] = useState(""); + + const filteredChartTypes = CHART_TYPES.filter((type) => + type.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+
+

Create a new chart

+
+ +
+ {/* Option 1: Ask AI */} +
+
+
+ +
+
+

Ask your data

+

+ Describe what you want to see and let AI build the chart. +

+
+
+ +
+ setUserQuery(e.target.value)} + className="flex-1" + /> + +
+
+ +
+ + + {/* Option 2: Build Manually */} +
+
+
+ + 1 + +

Choose a dataset

+
+ +
+ +
+
+ +
+
+ + 2 + +

Choose chart type

+
+ +
+
+ + setSearchQuery(e.target.value)} + /> +
+ +
+ {filteredChartTypes.map((chart) => { + const isSelected = selectedChartType === chart.id; + return ( +
setSelectedChartType(chart.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setSelectedChartType(chart.id); + }} + className={cn( + "focus:ring-brand-dark cursor-pointer rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2", + isSelected + ? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1" + : "border-gray-200 hover:border-gray-300" + )}> +
+ +
+ {chart.name} +
+ ); + })} +
+
+
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/analysis/charts/page.tsx b/apps/web/app/(app)/environments/[environmentId]/analysis/charts/page.tsx new file mode 100644 index 0000000000..9a896d9c33 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/analysis/charts/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { Edit2Icon, PlusIcon, SearchIcon, TrashIcon } from "lucide-react"; +import Link from "next/link"; +import { use } from "react"; +import { Button } from "@/modules/ui/components/button"; +import { Input } from "@/modules/ui/components/input"; +import { useAnalysisStore } from "../store"; + +export default function ChartsListPage({ params }: { params: Promise<{ environmentId: string }> }) { + use(params); + const { charts, dashboards } = useAnalysisStore(); + + // Helper to find dashboard names + const getDashboardNames = (dashboardIds: string[]) => { + return dashboardIds + .map((id) => dashboards.find((d) => d.id === id)?.name) + .filter(Boolean) + .join(", "); + }; + + return ( +
+ {/* Header / Actions */} +
+
+

Charts

+
+ + + +
+
+ +
+
+ + +
+ {/* Filter Dropdowns */} +
+ {["Type", "Dataset", "Owner", "Dashboard", "Favorite", "Certified", "Modified by"].map( + (filter) => ( + + ) + )} +
+
+
+ + {/* Table Content */} +
+
+ + + + + + + + {/* Hiding owners to save space if needed, mirroring Superset compact view */} + + + + + + {charts.map((chart) => ( + + + + + + + + + ))} + +
NameTypeDatasetOn dashboardsLast ModifiedActions
+ {chart.name} + + {chart.type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())} + + {chart.dataset} + + {getDashboardNames(chart.dashboardIds) || "-"} + + {formatDistanceToNow(new Date(chart.lastModified), { addSuffix: true })} + +
+ + +
+
+ {charts.length === 0 &&
No charts found.
} +
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/analysis/dashboard/[dashboardId]/page.tsx b/apps/web/app/(app)/environments/[environmentId]/analysis/dashboard/[dashboardId]/page.tsx new file mode 100644 index 0000000000..da3dc7571a --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/analysis/dashboard/[dashboardId]/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { MoreHorizontalIcon, PlusIcon } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { use } from "react"; +import { Bar, BarChart, CartesianGrid, Bar as RechartsBar, XAxis } from "recharts"; +import { Button } from "@/modules/ui/components/button"; +import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; +import { useAnalysisStore } from "../../store"; + +// --- Mock Chart Components for Widget Content --- + +const MockBarChart = () => { + const data = [ + { name: "Jan", value: 400 }, + { name: "Feb", value: 300 }, + { name: "Mar", value: 200 }, + { name: "Apr", value: 278 }, + { name: "May", value: 189 }, + { name: "Jun", value: 239 }, + ]; + return ( + + + + value.slice(0, 3)} + /> + } /> + + + + ); +}; + +const MockBigNumber = ({ title, value }: { title: string; value: string }) => ( +
+
{value}
+
{title}
+
+); + +// --- Widget Wrapper --- + +const DashboardWidget = ({ + title, + children, + error, +}: { + title: string; + children: React.ReactNode; + error?: string; +}) => { + return ( +
+ {/* Header */} +
+

{title}

+ + + + + + Actions + + Force refresh + View as table + Maximize + + +
+ + {/* Body */} +
+ {error ? ( +
+
+
+ +
+ Data error +
+

{error}

+
+ ) : ( + children + )} +
+
+ ); +}; + +export default function DashboardPage({ + params, +}: { + params: Promise<{ environmentId: string; dashboardId: string }>; +}) { + const { dashboardId } = use(params); + const { dashboards } = useAnalysisStore(); + + // Find dashboard + // For the purpose of the demo/port, we might need to map the ID or just grab the first one if mock data logic is simple + const dashboard = dashboards.find((d) => d.id === dashboardId) || dashboards[0]; // Fallback for demo flow + + if (!dashboard) { + return notFound(); + } + + // Demo: Determine if empty based on ID or simple toggle mechanism + // We'll fake an empty dashboard if ID suggests it or random chance for demo + const isEmpty = dashboard.widgets.length === 0 && dashboard.id !== "d1" && dashboard.id !== "d2"; + + return ( +
+ {/* Dashboard Header Context (Actions, filters) would go here */} +
+ + +
+ + {isEmpty ? ( + // Empty State +
+
+
{/* Abstract File Icon */} +
+

No Data

+

+ There is currently no information to display. Add charts to build your dashboard. +

+ + + +
+ ) : ( + // Grid Layout +
+ {/* Row 1 */} +
+ + + +
+ +
+ + + +
+ + {/* Row 2 */} +
+ + + +
+
+ )} +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/analysis/dashboards/page.tsx b/apps/web/app/(app)/environments/[environmentId]/analysis/dashboards/page.tsx new file mode 100644 index 0000000000..bd93908078 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/analysis/dashboards/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { MoreHorizontalIcon, PlusIcon, SearchIcon, StarIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { use, useState } from "react"; +import { Button } from "@/modules/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; +import { Input } from "@/modules/ui/components/input"; +import { useAnalysisStore } from "../store"; + +export default function DashboardsListPage({ params }: { params: Promise<{ environmentId: string }> }) { + use(params); // Unwrap params to satisfy Next.js, even if unused + const { dashboards, addDashboard } = useAnalysisStore(); + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(""); + + const filteredDashboards = dashboards.filter((dashboard) => + dashboard.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleCreateDashboard = () => { + const newId = `d-${Date.now()}`; + addDashboard({ + id: newId, + name: "New Dashboard", + status: "draft", + owners: [{ id: "u1", name: "Admin" }], // Mock owner + lastModified: new Date().toISOString(), + isFavorite: false, + widgets: [], + }); + router.push(`dashboard/${newId}`); + }; + + return ( +
+ {/* Header / Actions */} +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ {/* Filter Dropdowns - Visual Only for MVP port */} + + +
+ +
+ + {/* Table Content */} +
+
+ + + + + + + + + + + + {filteredDashboards.map((dashboard) => ( + + + + + + + + ))} + +
NameStatusOwnersLast ModifiedActions
+
+ {/* Star Icon - Active state simulation */} + + + {dashboard.name} + +
+
+ + {dashboard.status === "published" ? "Published" : "Draft"} + + {dashboard.owners.map((u) => u.name).join(", ")} + {formatDistanceToNow(new Date(dashboard.lastModified), { addSuffix: true })} + + + + + + + Edit + Delete + + +
+ {filteredDashboards.length === 0 && ( +
No dashboards found.
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/analysis/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/analysis/layout.tsx new file mode 100644 index 0000000000..08ee94106a --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/analysis/layout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { use } from "react"; +import { cn } from "@/lib/cn"; +import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; +import { PageHeader } from "@/modules/ui/components/page-header"; + +const TABS = [ + { name: "Dashboards", href: "dashboards" }, + { name: "Charts", href: "charts" }, +]; + +export default function AnalysisLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ environmentId: string }>; +}) { + const pathname = usePathname(); + const { environmentId } = use(params); + + return ( + + +
+ +
+
{children}
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/analysis/page.tsx b/apps/web/app/(app)/environments/[environmentId]/analysis/page.tsx new file mode 100644 index 0000000000..f1d7414037 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/analysis/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; + +export default async function AnalysisPage({ params }: { params: Promise<{ environmentId: string }> }) { + const { environmentId } = await params; + if (!environmentId || environmentId === "undefined") { + redirect("/"); + } + redirect(`/environments/${environmentId}/analysis/dashboards`); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/analysis/store.ts b/apps/web/app/(app)/environments/[environmentId]/analysis/store.ts new file mode 100644 index 0000000000..59b4f2dfe0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/analysis/store.ts @@ -0,0 +1,182 @@ +import { create } from "zustand"; + +// --- Types --- + +export type DashboardStatus = "published" | "draft"; + +export interface AnalysisUser { + id: string; + name: string; +} + +export interface Dashboard { + id: string; + name: string; + description?: string; + status: DashboardStatus; + owners: AnalysisUser[]; + lastModified: string; // ISO Date string + isFavorite: boolean; + widgets: DashboardWidget[]; +} + +export interface DashboardWidget { + id: string; + type: "chart" | "markdown" | "header" | "divider"; + title?: string; + chartId?: string; // If type is chart + layout: { + x: number; + y: number; + w: number; + h: number; + }; +} + +export type ChartType = + | "area" + | "bar" + | "line" + | "pie" + | "big_number" + | "big_number_total" + | "table" + | "funnel" + | "map"; + +export interface Chart { + id: string; + name: string; + type: ChartType; + dataset: string; + owners: AnalysisUser[]; + lastModified: string; + dashboardIds: string[]; + config: Record; // Flexible config for specific chart props +} + +interface AnalysisState { + dashboards: Dashboard[]; + charts: Chart[]; + activeDashboard: Dashboard | null; + layoutMode: "view" | "edit"; + isLoading: boolean; + + // Actions + setDashboards: (dashboards: Dashboard[]) => void; + setCharts: (charts: Chart[]) => void; + setActiveDashboard: (dashboard: Dashboard | null) => void; + setLayoutMode: (mode: "view" | "edit") => void; + addDashboard: (dashboard: Dashboard) => void; + updateDashboard: (id: string, updates: Partial) => void; +} + +// --- Mock Data --- + +const MOCK_USERS: AnalysisUser[] = [ + { id: "u1", name: "Admin User" }, + { id: "u2", name: "Jane Doe" }, +]; + +const MOCK_CHARTS: Chart[] = [ + { + id: "c1", + name: "Gender", + type: "pie", + dataset: "FCC 2018 Survey", + owners: [MOCK_USERS[0]], + lastModified: new Date(Date.now() - 1000 * 60 * 3).toISOString(), // 3 mins ago + dashboardIds: ["d1"], + config: {}, + }, + { + id: "c2", + name: "Cross Channel Relationship", + type: "bar", // Using bar as approximation for chord if not available + dataset: "users_channels-uzooNNtSRO", + owners: [MOCK_USERS[0]], + lastModified: new Date(Date.now() - 1000 * 60 * 3).toISOString(), + dashboardIds: ["d2"], + config: {}, + }, + { + id: "c3", + name: "Weekly Messages", + type: "line", + dataset: "messages", + owners: [MOCK_USERS[0]], + lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(), // 14 mins ago + dashboardIds: ["d2"], + config: {}, + }, + { + id: "c4", + name: "New Members per Month", + type: "line", + dataset: "new_members_daily", + owners: [MOCK_USERS[0]], + lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(), + dashboardIds: ["d2"], + config: {}, + }, + { + id: "c5", + name: "Number of Members", + type: "big_number", + dataset: "users", + owners: [MOCK_USERS[0]], + lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(), + dashboardIds: ["d2"], + config: {}, + }, +]; + +const MOCK_DASHBOARDS: Dashboard[] = [ + { + id: "d1", + name: "FCC New Coder Survey 2018", + status: "published", + owners: [MOCK_USERS[0]], + lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(), + isFavorite: true, + widgets: [], + }, + { + id: "d2", + name: "Slack Dashboard", + status: "published", + owners: [MOCK_USERS[0]], + lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(), + isFavorite: true, + widgets: [], + }, + { + id: "d3", + name: "Sales Dashboard", + status: "published", + owners: [MOCK_USERS[0]], + lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(), + isFavorite: true, + widgets: [], + }, +]; + +// --- Store --- + +export const useAnalysisStore = create((set) => ({ + dashboards: MOCK_DASHBOARDS, + charts: MOCK_CHARTS, + activeDashboard: null, + layoutMode: "view", + isLoading: false, + + setDashboards: (dashboards) => set({ dashboards }), + setCharts: (charts) => set({ charts }), + setActiveDashboard: (activeDashboard) => set({ activeDashboard }), + setLayoutMode: (layoutMode) => set({ layoutMode }), + addDashboard: (dashboard) => set((state) => ({ dashboards: [...state.dashboards, dashboard] })), + updateDashboard: (id, updates) => + set((state) => ({ + dashboards: state.dashboards.map((d) => (d.id === id ? { ...d, ...updates } : d)), + })), +})); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index 10fa618440..40e4becd4c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -8,6 +8,7 @@ import { MessageCircle, PanelLeftCloseIcon, PanelLeftOpenIcon, + PieChart, RocketIcon, UserCircleIcon, UserIcon, @@ -105,6 +106,13 @@ export const MainNavigation = ({ isActive: pathname?.includes("/surveys"), isHidden: false, }, + { + name: t("common.analysis"), + href: `/environments/${environment.id}/analysis`, + icon: PieChart, + isActive: pathname?.includes("/analysis"), + isHidden: false, + }, { href: `/environments/${environment.id}/contacts`, name: t("common.contacts"), @@ -185,7 +193,7 @@ export const MainNavigation = ({ size="icon" onClick={toggleSidebar} className={cn( - "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none" + "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent" )}> {isCollapsed ? ( diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx index 8196a948d7..8243948a20 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx @@ -11,6 +11,12 @@ const EnvLayout = async (props: { children: React.ReactNode; }) => { const params = await props.params; + const { environmentId } = params; + + if (environmentId === "undefined") { + return redirect("/"); + } + const { children } = props; // Check session first (required for userId) diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index b0062afdc1..6fd2ceb8fa 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -106,6 +106,7 @@ checksums: common/allow: 3e39cc5940255e6bff0fea95c817dd43 common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56 common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2 + common/analysis: 409bac6215382c47e59f5039cc4cdcdd common/and: dc75b95c804b16dc617a5f16f7393bca common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4 common/anonymous: 77b5222e710cc1dae073dae32309f8ed diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 59d8aa1805..8babe8a2bb 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -133,6 +133,7 @@ "allow": "erlauben", "allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken", "an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten", + "analysis": "Analyse", "and": "und", "and_response_limit_of": "und Antwortlimit von", "anonymous": "Anonym", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index fd005981f7..c267b389b2 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -133,6 +133,7 @@ "allow": "Allow", "allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey", "an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s", + "analysis": "Analysis", "and": "And", "and_response_limit_of": "and response limit of", "anonymous": "Anonymous", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 298b2f0046..62a5c8e617 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -133,6 +133,7 @@ "allow": "Permitir", "allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta", "an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s", + "analysis": "Análisis", "and": "Y", "and_response_limit_of": "y límite de respuesta de", "anonymous": "Anónimo", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index eded73fdd0..80eb04b98b 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -133,6 +133,7 @@ "allow": "Autoriser", "allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête", "an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s", + "analysis": "Analyse", "and": "Et", "and_response_limit_of": "et limite de réponse de", "anonymous": "Anonyme", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 4a4a021286..59b0e39836 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -133,6 +133,7 @@ "allow": "許可", "allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする", "an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました", + "analysis": "分析", "and": "および", "and_response_limit_of": "と回答数の上限", "anonymous": "匿名", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index aac25524c1..187b3595f1 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -133,6 +133,7 @@ "allow": "Toestaan", "allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken", "an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s", + "analysis": "Analyse", "and": "En", "and_response_limit_of": "en responslimiet van", "anonymous": "Anoniem", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index db595e84cb..6d9f29a18b 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -133,6 +133,7 @@ "allow": "permitir", "allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa", "an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s", + "analysis": "Análise", "and": "E", "and_response_limit_of": "e limite de resposta de", "anonymous": "Anônimo", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index bc9d9a0be6..288e46a70f 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -133,6 +133,7 @@ "allow": "Permitir", "allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'", "an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s", + "analysis": "Análise", "and": "E", "and_response_limit_of": "e limite de resposta de", "anonymous": "Anónimo", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index a202dca9e5..21cb3320e8 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -133,6 +133,7 @@ "allow": "Permite", "allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului", "an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}", + "analysis": "Analiză", "and": "Și", "and_response_limit_of": "și limită răspuns", "anonymous": "Anonim", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index e1fa4480c6..a7582a2293 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -133,6 +133,7 @@ "allow": "Разрешить", "allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса", "an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов", + "analysis": "Анализ", "and": "и", "and_response_limit_of": "и лимит ответов", "anonymous": "Аноним", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 65f827451d..efe882a73b 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -133,6 +133,7 @@ "allow": "Tillåt", "allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten", "an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}", + "analysis": "Analys", "and": "Och", "and_response_limit_of": "och svarsgräns på", "anonymous": "Anonym", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 8f38e813cd..b644189c7d 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -133,6 +133,7 @@ "allow": "允许", "allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出", "an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误", + "analysis": "分析", "and": "和", "and_response_limit_of": "和 响应限制", "anonymous": "匿名", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 3b46823c1f..c63a37bb7a 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -133,6 +133,7 @@ "allow": "允許", "allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出", "an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤", + "analysis": "分析", "and": "且", "and_response_limit_of": "且回應上限為", "anonymous": "匿名", diff --git a/apps/web/modules/ui/components/chart.tsx b/apps/web/modules/ui/components/chart.tsx new file mode 100644 index 0000000000..5ad8f91897 --- /dev/null +++ b/apps/web/modules/ui/components/chart.tsx @@ -0,0 +1,303 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; +import { ResponsiveContainer, Tooltip } from "recharts"; +import { cn } from "@/lib/cn"; + +// Format: { THEME_NAME: CSS_VARIABLE } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ({ color?: string; theme?: never } | { color?: never; theme: Record }); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + {children} +
+
+ ); +}); +ChartContainer.displayName = "ChartContainer"; + +const ChartTooltip = Tooltip; + +const ChartTooltipContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps & + React.ComponentProps<"div"> & { + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; + } +>( + ( + { + active, + payload, + className, + indicator = "dot", + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + }, + ref + ) => { + const { config } = useChart(); + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null; + } + + const [item] = payload; + const key = `${labelKey || item.dataKey || item.name || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const value = + !labelKey && typeof label === "string" + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label; + + if (labelFormatter) { + return
{labelFormatter(value, payload)}
; + } + + if (!value) { + return null; + } + + return
{value}
; + }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]); + + if (!active || !payload?.length) { + return null; + } + + const nestLabel = payload.length === 1 && indicator !== "dot"; + + return ( +
+ {!nestLabel ? tooltipLabel : null} +
+ {payload.map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload.fill || item.color; + + return ( +
svg]:text-muted-foreground flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", + indicator === "dot" && "items-center" + )}> + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + + ) : ( + !hideIndicator && ( +
+ ) + )} +
+
+ {nestLabel ? tooltipLabel : null} + {itemConfig?.label || item.name} +
+ {item.value && ( + + {item.value.toLocaleString()} + + )} +
+ + )} +
+ ); + })} +
+
+ ); + } +); +ChartTooltipContent.displayName = "ChartTooltip"; + +const ChartLegend = RechartsPrimitive.Legend; + +const ChartLegendContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & + Pick & { + hideIcon?: boolean; + nameKey?: string; + } +>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => { + const { config } = useChart(); + + if (!payload?.length) { + return null; + } + + return ( +
+ {payload.map((item) => { + const key = `${nameKey || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + + return ( +
svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}> + {itemConfig?.icon && !hideIcon ? ( + + ) : ( +
+ )} + {itemConfig?.label} +
+ ); + })} +
+ ); +}); +ChartLegendContent.displayName = "ChartLegend"; + +// Helper to extract item config from a payload. +function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) { + if (typeof payload !== "object" || payload === null) { + return undefined; + } + + const payloadPayload = + "payload" in payload && typeof payload.payload === "object" && payload.payload !== null + ? payload.payload + : undefined; + + let configLabelKey: string = key; + + if (key in payload && typeof payload[key as keyof typeof payload] === "string") { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" + ) { + configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string; + } + + return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color); + + if (!colorConfig.length) { + return null; + } + + return ( +