This commit is contained in:
@@ -8,6 +8,7 @@ export default [
|
|||||||
route("forms/new", "routes/create-form.tsx"),
|
route("forms/new", "routes/create-form.tsx"),
|
||||||
route("form/:id", "routes/form.tsx"),
|
route("form/:id", "routes/form.tsx"),
|
||||||
route("form/:id/submit", "routes/submit-form.tsx"),
|
route("form/:id/submit", "routes/submit-form.tsx"),
|
||||||
|
route("form/:id/responses", "routes/form-responses.tsx"),
|
||||||
route("form/:id/edit", "routes/edit-form.tsx"),
|
route("form/:id/edit", "routes/edit-form.tsx"),
|
||||||
route("*", "routes/not-found.tsx"),
|
route("*", "routes/not-found.tsx"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -0,0 +1,651 @@
|
|||||||
|
import { useState, useEffect, useMemo } from "react"
|
||||||
|
import { Link, useParams, useNavigate } from "react-router"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BarChart3,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
MessageSquare,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts"
|
||||||
|
import { Navbar } from "@/components/shared/navbar"
|
||||||
|
import { Footer } from "@/components/shared/footer"
|
||||||
|
import { FormButton } from "@/components/shared/form-button"
|
||||||
|
import { useAuth } from "@/app/context/auth-context"
|
||||||
|
import { getFormById, getFormResponses } from "@/lib/api"
|
||||||
|
import type { FormDetail, FormResponse, Question, QuestionType } from "@/lib/types"
|
||||||
|
|
||||||
|
const CHART_COLORS = [
|
||||||
|
"hsl(221, 83%, 53%)",
|
||||||
|
"hsl(142, 71%, 45%)",
|
||||||
|
"hsl(38, 92%, 50%)",
|
||||||
|
"hsl(0, 84%, 60%)",
|
||||||
|
"hsl(262, 83%, 58%)",
|
||||||
|
"hsl(172, 66%, 50%)",
|
||||||
|
"hsl(326, 80%, 55%)",
|
||||||
|
"hsl(25, 95%, 53%)",
|
||||||
|
"hsl(199, 89%, 48%)",
|
||||||
|
"hsl(47, 96%, 53%)",
|
||||||
|
]
|
||||||
|
|
||||||
|
type TabType = "summary" | "individual"
|
||||||
|
|
||||||
|
export default function FormResponsesPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, loading: authLoading } = useAuth()
|
||||||
|
const [form, setForm] = useState<FormDetail | null>(null)
|
||||||
|
const [responses, setResponses] = useState<FormResponse[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>("summary")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
navigate("/login")
|
||||||
|
}
|
||||||
|
}, [user, authLoading, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || !user) return
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [formData, responsesData] = await Promise.all([
|
||||||
|
getFormById(id!),
|
||||||
|
getFormResponses(id!),
|
||||||
|
])
|
||||||
|
setForm(formData)
|
||||||
|
setResponses(responsesData)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Response && err.status === 404) {
|
||||||
|
setError("Form not found")
|
||||||
|
} else {
|
||||||
|
setError("Failed to load responses. Please try again.")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [id, user])
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<p className="text-muted-foreground">Loading responses...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !form) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-destructive">{error ?? "Form not found"}</p>
|
||||||
|
<Link
|
||||||
|
to="/forms"
|
||||||
|
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to forms
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-8 lg:px-8">
|
||||||
|
<Link
|
||||||
|
to={`/form/${id}`}
|
||||||
|
className="mb-6 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to form
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8 rounded-xl border border-border bg-card shadow-sm">
|
||||||
|
<div className="h-2 rounded-t-xl bg-primary" />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground sm:text-2xl">
|
||||||
|
{form.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{form.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link to={`/form/${id}`}>
|
||||||
|
<FormButton variant="outline" size="sm">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</FormButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-4 border-t border-border pt-5 sm:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
icon={<Users className="h-4 w-4 text-primary" />}
|
||||||
|
label="Responses"
|
||||||
|
value={responses.length}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<FileText className="h-4 w-4 text-primary" />}
|
||||||
|
label="Questions"
|
||||||
|
value={form.questions.length}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Calendar className="h-4 w-4 text-primary" />}
|
||||||
|
label="Created"
|
||||||
|
value={new Date(form.created_at).toLocaleDateString()}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Clock className="h-4 w-4 text-primary" />}
|
||||||
|
label="Last response"
|
||||||
|
value={
|
||||||
|
responses.length > 0
|
||||||
|
? new Date(responses[0].submitted_at).toLocaleDateString()
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mb-6 flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("summary")}
|
||||||
|
className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "summary"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="mr-1.5 inline-block h-4 w-4" />
|
||||||
|
Summary
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("individual")}
|
||||||
|
className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "individual"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MessageSquare className="mr-1.5 inline-block h-4 w-4" />
|
||||||
|
Individual ({responses.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{responses.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/50 py-16">
|
||||||
|
<Users className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No responses yet. Share this form to start collecting data.
|
||||||
|
</p>
|
||||||
|
<Link to={`/form/${id}/submit`} className="mt-3">
|
||||||
|
<FormButton variant="outline" size="sm">
|
||||||
|
View submission page
|
||||||
|
</FormButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : activeTab === "summary" ? (
|
||||||
|
<SummaryTab form={form} responses={responses} />
|
||||||
|
) : (
|
||||||
|
<IndividualTab form={form} responses={responses} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-background p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-foreground">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTab({
|
||||||
|
form,
|
||||||
|
responses,
|
||||||
|
}: {
|
||||||
|
form: FormDetail
|
||||||
|
responses: FormResponse[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{form.questions.map((question, index) => (
|
||||||
|
<QuestionSummary
|
||||||
|
key={question.id}
|
||||||
|
question={question}
|
||||||
|
index={index}
|
||||||
|
responses={responses}
|
||||||
|
totalResponses={responses.length}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionSummary({
|
||||||
|
question,
|
||||||
|
index,
|
||||||
|
responses,
|
||||||
|
totalResponses,
|
||||||
|
}: {
|
||||||
|
question: Question
|
||||||
|
index: number
|
||||||
|
responses: FormResponse[]
|
||||||
|
totalResponses: number
|
||||||
|
}) {
|
||||||
|
const answersForQuestion = useMemo(() => {
|
||||||
|
return responses
|
||||||
|
.map((r) => r.answers.find((a) => a.question_id === question.id))
|
||||||
|
.filter(Boolean)
|
||||||
|
}, [responses, question.id])
|
||||||
|
|
||||||
|
const responseCount = answersForQuestion.length
|
||||||
|
const skipped = totalResponses - responseCount
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||||
|
<div className="border-b border-border p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{question.title}
|
||||||
|
{question.required && (
|
||||||
|
<span className="ml-1 text-destructive">*</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="capitalize">{question.type.replace("_", " ")}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{responseCount} answers</span>
|
||||||
|
{skipped > 0 && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{skipped} skipped</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
{hasChartableType(question.type) ? (
|
||||||
|
<ChartVisualization
|
||||||
|
question={question}
|
||||||
|
responses={responses}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextResponses
|
||||||
|
questionId={question.id}
|
||||||
|
responses={responses}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChartableType(type: QuestionType): boolean {
|
||||||
|
return ["multiple_choice", "checkbox", "dropdown", "rating"].includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartVisualization({
|
||||||
|
question,
|
||||||
|
responses,
|
||||||
|
}: {
|
||||||
|
question: Question
|
||||||
|
responses: FormResponse[]
|
||||||
|
}) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
|
||||||
|
if (["multiple_choice", "dropdown"].includes(question.type)) {
|
||||||
|
question.options.forEach((opt) => {
|
||||||
|
counts[opt.label] = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.type === "checkbox") {
|
||||||
|
question.options.forEach((opt) => {
|
||||||
|
counts[opt.label] = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.type === "rating") {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
counts[String(i)] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.forEach((r) => {
|
||||||
|
const answer = r.answers.find((a) => a.question_id === question.id)
|
||||||
|
if (!answer) return
|
||||||
|
|
||||||
|
if (question.type === "checkbox") {
|
||||||
|
const selected = answer.answer.split(",").map((s) => s.trim())
|
||||||
|
selected.forEach((s) => {
|
||||||
|
counts[s] = (counts[s] || 0) + 1
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
counts[answer.answer] = (counts[answer.answer] || 0) + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.entries(counts).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
}, [question, responses])
|
||||||
|
|
||||||
|
const total = chartData.reduce((sum, d) => sum + d.value, 0)
|
||||||
|
|
||||||
|
if (question.type === "rating") {
|
||||||
|
const avgRating = useMemo(() => {
|
||||||
|
let sum = 0
|
||||||
|
let count = 0
|
||||||
|
responses.forEach((r) => {
|
||||||
|
const answer = r.answers.find((a) => a.question_id === question.id)
|
||||||
|
if (answer) {
|
||||||
|
sum += Number(answer.answer)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return count > 0 ? (sum / count).toFixed(1) : "—"
|
||||||
|
}, [responses, question.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<span className="text-3xl font-bold text-foreground">{avgRating}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">average rating out of 5</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(v) => `${v} ★`}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-center">
|
||||||
|
<div className="mx-auto w-full max-w-[220px] sm:mx-0">
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={90}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{chartData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
formatter={(value: number | undefined) => [
|
||||||
|
`${value ?? 0} (${total > 0 ? (((value ?? 0) / total) * 100).toFixed(0) : 0}%)`,
|
||||||
|
"Responses",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
{chartData.map((item, i) => (
|
||||||
|
<div key={item.name} className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: CHART_COLORS[i % CHART_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-sm text-foreground">{item.name}</span>
|
||||||
|
<span className="text-sm font-medium text-foreground">{item.value}</span>
|
||||||
|
<span className="w-12 text-right text-xs text-muted-foreground">
|
||||||
|
{total > 0 ? ((item.value / total) * 100).toFixed(0) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextResponses({
|
||||||
|
questionId,
|
||||||
|
responses,
|
||||||
|
}: {
|
||||||
|
questionId: string
|
||||||
|
responses: FormResponse[]
|
||||||
|
}) {
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
const textAnswers = useMemo(() => {
|
||||||
|
return responses
|
||||||
|
.map((r) => {
|
||||||
|
const answer = r.answers.find((a) => a.question_id === questionId)
|
||||||
|
return answer
|
||||||
|
? { text: answer.answer, date: r.submitted_at }
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
.filter(Boolean) as { text: string; date: string }[]
|
||||||
|
}, [responses, questionId])
|
||||||
|
|
||||||
|
if (textAnswers.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No responses for this question.</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = showAll ? textAnswers : textAnswers.slice(0, 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{visible.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-lg border border-border bg-background px-4 py-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-foreground">{item.text}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{new Date(item.date).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{textAnswers.length > 5 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll((prev) => !prev)}
|
||||||
|
className="mt-3 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{showAll ? "Show less" : `Show all ${textAnswers.length} responses`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndividualTab({
|
||||||
|
form,
|
||||||
|
responses,
|
||||||
|
}: {
|
||||||
|
form: FormDetail
|
||||||
|
responses: FormResponse[]
|
||||||
|
}) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...responses].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime()
|
||||||
|
),
|
||||||
|
[responses]
|
||||||
|
)
|
||||||
|
|
||||||
|
const current = sorted[selectedIndex]
|
||||||
|
if (!current) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedIndex((i) => Math.max(0, i - 1))}
|
||||||
|
disabled={selectedIndex === 0}
|
||||||
|
className="rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:opacity-40"
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
Response {selectedIndex + 1} of {sorted.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(current.submitted_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedIndex((i) => Math.min(sorted.length - 1, i + 1))
|
||||||
|
}
|
||||||
|
disabled={selectedIndex === sorted.length - 1}
|
||||||
|
className="rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Answers */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{form.questions.map((question, index) => {
|
||||||
|
const answer = current.answers.find(
|
||||||
|
(a) => a.question_id === question.id
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={question.id}
|
||||||
|
className="rounded-xl border border-border bg-card p-5 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{question.title}
|
||||||
|
{question.required && (
|
||||||
|
<span className="ml-1 text-destructive">*</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs capitalize text-muted-foreground">
|
||||||
|
{question.type.replace("_", " ")}
|
||||||
|
</p>
|
||||||
|
{answer ? (
|
||||||
|
<div className="mt-3 rounded-lg border border-border bg-background px-4 py-2.5">
|
||||||
|
<p className="text-sm text-foreground">{answer.answer}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm italic text-muted-foreground">
|
||||||
|
No answer (skipped)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react"
|
|||||||
import { Link, useParams, useNavigate } from "react-router"
|
import { Link, useParams, useNavigate } from "react-router"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
BarChart3,
|
||||||
Calendar,
|
Calendar,
|
||||||
FileText,
|
FileText,
|
||||||
Lock,
|
Lock,
|
||||||
@@ -142,6 +143,12 @@ export default function FormPreviewPage() {
|
|||||||
|
|
||||||
{user && user.id === form.user_id && (
|
{user && user.id === form.user_id && (
|
||||||
<div className="mt-5 flex items-center gap-3 border-t border-border pt-5">
|
<div className="mt-5 flex items-center gap-3 border-t border-border pt-5">
|
||||||
|
<Link to={`/form/${id}/responses`}>
|
||||||
|
<FormButton type="button" variant="ghost" size="sm">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Responses
|
||||||
|
</FormButton>
|
||||||
|
</Link>
|
||||||
<Link to={`/form/${id}/edit`}>
|
<Link to={`/form/${id}/edit`}>
|
||||||
<FormButton type="button" variant="ghost" size="sm">
|
<FormButton type="button" variant="ghost" size="sm">
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
|
|||||||
@@ -63,13 +63,11 @@ export default function FormsPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Initial fetch
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
fetchForms({ search, statusFilter, sortBy, sortDir })
|
fetchForms({ search, statusFilter, sortBy, sortDir })
|
||||||
}, [user]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [user])
|
||||||
|
|
||||||
// Debounced fetch when filters change (skip initial)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || isInitialLoad.current) return
|
if (!user || isInitialLoad.current) return
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export default function SubmitFormPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await getFormById(id!)
|
const data = await getFormById(id!)
|
||||||
setForm(data)
|
setForm(data)
|
||||||
// Initialize empty answers
|
|
||||||
const initial: Record<string, string> = {}
|
const initial: Record<string, string> = {}
|
||||||
data.questions.forEach((q) => {
|
data.questions.forEach((q) => {
|
||||||
initial[q.id] = ""
|
initial[q.id] = ""
|
||||||
@@ -53,7 +52,6 @@ export default function SubmitFormPage() {
|
|||||||
|
|
||||||
function updateAnswer(questionId: string, value: string) {
|
function updateAnswer(questionId: string, value: string) {
|
||||||
setAnswers((prev) => ({ ...prev, [questionId]: value }))
|
setAnswers((prev) => ({ ...prev, [questionId]: value }))
|
||||||
// Clear validation error on change
|
|
||||||
if (validationErrors[questionId]) {
|
if (validationErrors[questionId]) {
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
@@ -82,7 +80,6 @@ export default function SubmitFormPage() {
|
|||||||
if (!id || !form) return
|
if (!id || !form) return
|
||||||
|
|
||||||
if (!validate()) {
|
if (!validate()) {
|
||||||
// Scroll to first error
|
|
||||||
const firstErrorId = form.questions.find((q) => validationErrors[q.id] || (q.required && !answers[q.id]?.trim()))?.id
|
const firstErrorId = form.questions.find((q) => validationErrors[q.id] || (q.required && !answers[q.id]?.trim()))?.id
|
||||||
if (firstErrorId) {
|
if (firstErrorId) {
|
||||||
document.getElementById(`question-${firstErrorId}`)?.scrollIntoView({ behavior: "smooth", block: "center" })
|
document.getElementById(`question-${firstErrorId}`)?.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||||
@@ -161,7 +158,6 @@ export default function SubmitFormPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSubmitted(false)
|
setSubmitted(false)
|
||||||
// Reset answers
|
|
||||||
if (form) {
|
if (form) {
|
||||||
const initial: Record<string, string> = {}
|
const initial: Record<string, string> = {}
|
||||||
form.questions.forEach((q) => { initial[q.id] = "" })
|
form.questions.forEach((q) => { initial[q.id] = "" })
|
||||||
@@ -270,8 +266,6 @@ export default function SubmitFormPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Question field component ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface QuestionFieldProps {
|
interface QuestionFieldProps {
|
||||||
question: Question
|
question: Question
|
||||||
index: number
|
index: number
|
||||||
|
|||||||
+14
-1
@@ -6,7 +6,7 @@ import {
|
|||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
decodeJWT,
|
decodeJWT,
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
import type { FormSummary, FormDetail, CreateFormPayload, UpdateFormPayload, SubmitFormPayload } from "@/lib/types"
|
import type { FormSummary, FormDetail, CreateFormPayload, UpdateFormPayload, SubmitFormPayload, FormResponse } from "@/lib/types"
|
||||||
|
|
||||||
export async function refreshAccessToken(): Promise<string | null> {
|
export async function refreshAccessToken(): Promise<string | null> {
|
||||||
if (typeof window === "undefined") return null
|
if (typeof window === "undefined") return null
|
||||||
@@ -232,4 +232,17 @@ export async function deleteForm(id: string): Promise<void> {
|
|||||||
const data = await res.json().catch(() => null)
|
const data = await res.json().catch(() => null)
|
||||||
throw new Error(data?.message ?? "Failed to delete form")
|
throw new Error(data?.message ?? "Failed to delete form")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFormResponses(id: string): Promise<FormResponse[]> {
|
||||||
|
const res = await fetchWithAuth(`${import.meta.env.VITE_API_BASE_URL}/api/form/${id}/responses`)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new Response("Form not found", { status: 404 })
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch form responses")
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
}
|
}
|
||||||
@@ -75,3 +75,16 @@ export interface SubmitFormAnswer {
|
|||||||
export interface SubmitFormPayload {
|
export interface SubmitFormPayload {
|
||||||
answers: SubmitFormAnswer[]
|
answers: SubmitFormAnswer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FormResponseAnswer {
|
||||||
|
question_id: string
|
||||||
|
question_title: string
|
||||||
|
question_type: QuestionType
|
||||||
|
answer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormResponse {
|
||||||
|
id: string
|
||||||
|
submitted_at: string
|
||||||
|
answers: FormResponseAnswer[]
|
||||||
|
}
|
||||||
|
|||||||
Generated
+389
@@ -16,6 +16,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router": "7.12.0",
|
"react-router": "7.12.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
@@ -2665,6 +2666,42 @@
|
|||||||
"react-router": "7.12.0"
|
"react-router": "7.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/node-fetch-server": {
|
"node_modules/@remix-run/node-fetch-server": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.9.0.tgz",
|
||||||
@@ -3022,6 +3059,18 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
|
||||||
@@ -3294,6 +3343,69 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"vite": "^5.2.0 || ^6 || ^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3331,6 +3443,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -3709,6 +3827,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -3727,6 +3966,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
||||||
@@ -3864,6 +4109,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -3931,6 +4186,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/exit-hook": {
|
"node_modules/exit-hook": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
|
||||||
@@ -4255,12 +4516,31 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -5083,6 +5363,36 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.14.2",
|
"version": "0.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||||
@@ -5211,6 +5521,57 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.58.0",
|
"version": "4.58.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz",
|
||||||
@@ -5508,6 +5869,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -5729,6 +6096,28 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router": "7.12.0",
|
"react-router": "7.12.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user