mirror of
https://github.com/NicholaiVogel/dashore-incubator.git
synced 2026-04-26 17:46:16 +00:00
feat(wishlist): redesign stats bar and filter layout
- Replace 4-card stats grid with single horizontal bar showing total items, high priority count, submissions, and total budget - Add search input for filtering by name/description - Add priority filter dropdown - Move Add Item button to header row next to Export/Refresh - Widen filter dropdowns to prevent text truncation
This commit is contained in:
parent
186c1c21b8
commit
f9e7dbd464
@ -476,45 +476,32 @@ export async function getItemWithComments(itemId: string, userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWishlistStats(userId: string) {
|
export interface WishlistStats {
|
||||||
|
totalItems: number
|
||||||
|
highPriority: number
|
||||||
|
yourSubmissions: number
|
||||||
|
totalBudget: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWishlistStats(userId: string): Promise<WishlistStats> {
|
||||||
const { env } = await getCloudflareContext()
|
const { env } = await getCloudflareContext()
|
||||||
const db = getDb(env.DB)
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
const allItems = await db.select().from(wishlistItems)
|
const allItems = await db.select().from(wishlistItems)
|
||||||
const allVotes = await db.select().from(wishlistVotes)
|
|
||||||
|
|
||||||
const totalItems = allItems.length
|
const totalItems = allItems.length
|
||||||
const yourItems = allItems.filter((i) => i.submittedBy === userId).length
|
const highPriority = allItems.filter((i) => i.priority === "high").length
|
||||||
|
const yourSubmissions = allItems.filter((i) => i.submittedBy === userId).length
|
||||||
let mostWanted = null
|
const totalBudget = allItems.reduce(
|
||||||
if (allItems.length > 0) {
|
(sum, item) => sum + (item.estimatedCost ?? 0),
|
||||||
const itemScores = allItems.map((item) => {
|
0
|
||||||
const itemVotes = allVotes.filter((v) => v.itemId === item.id)
|
)
|
||||||
const upvotes = itemVotes.filter((v) => v.voteType === "up").length
|
|
||||||
const downvotes = itemVotes.filter((v) => v.voteType === "down").length
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
score: upvotes - downvotes,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const topItem = itemScores.sort((a, b) => b.score - a.score)[0]
|
|
||||||
if (topItem.score > 0) {
|
|
||||||
mostWanted = { name: topItem.item.name, score: topItem.score }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentItems = allItems
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
||||||
)
|
|
||||||
.slice(0, 5).length
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalItems,
|
totalItems,
|
||||||
yourItems,
|
highPriority,
|
||||||
mostWanted,
|
yourSubmissions,
|
||||||
recentItems,
|
totalBudget,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { IconPlus } from "@tabler/icons-react"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -11,54 +10,73 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
interface WishlistFiltersProps {
|
interface WishlistFiltersProps {
|
||||||
|
search: string
|
||||||
category: string
|
category: string
|
||||||
|
priority: string
|
||||||
sortBy: string
|
sortBy: string
|
||||||
|
onSearchChange: (search: string) => void
|
||||||
onCategoryChange: (category: string) => void
|
onCategoryChange: (category: string) => void
|
||||||
|
onPriorityChange: (priority: string) => void
|
||||||
onSortChange: (sort: string) => void
|
onSortChange: (sort: string) => void
|
||||||
onAddClick: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WishlistFilters({
|
export function WishlistFilters({
|
||||||
|
search,
|
||||||
category,
|
category,
|
||||||
|
priority,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
onSearchChange,
|
||||||
onCategoryChange,
|
onCategoryChange,
|
||||||
|
onPriorityChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onAddClick,
|
|
||||||
}: WishlistFiltersProps) {
|
}: WishlistFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 px-4 lg:px-6">
|
<div className="flex flex-wrap items-center gap-4 px-4 lg:px-6">
|
||||||
<div className="flex items-center gap-2">
|
<Input
|
||||||
<Select value={category} onValueChange={onCategoryChange}>
|
placeholder="Search items..."
|
||||||
<SelectTrigger className="w-[140px]" size="sm">
|
value={search}
|
||||||
<SelectValue placeholder="Category" />
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
</SelectTrigger>
|
className="h-9 w-56"
|
||||||
<SelectContent>
|
/>
|
||||||
<SelectItem value="all">All Categories</SelectItem>
|
|
||||||
<SelectItem value="hardware">Hardware</SelectItem>
|
|
||||||
<SelectItem value="software">Software</SelectItem>
|
|
||||||
<SelectItem value="network">Network</SelectItem>
|
|
||||||
<SelectItem value="storage">Storage</SelectItem>
|
|
||||||
<SelectItem value="other">Other</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={sortBy} onValueChange={onSortChange}>
|
<Select value={category} onValueChange={onCategoryChange}>
|
||||||
<SelectTrigger className="w-[130px]" size="sm">
|
<SelectTrigger className="w-40">
|
||||||
<SelectValue placeholder="Sort by" />
|
<SelectValue placeholder="Category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="score">Top Score</SelectItem>
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
<SelectItem value="newest">Newest</SelectItem>
|
<SelectItem value="hardware">Hardware</SelectItem>
|
||||||
<SelectItem value="oldest">Oldest</SelectItem>
|
<SelectItem value="software">Software</SelectItem>
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
<SelectItem value="network">Network</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="storage">Storage</SelectItem>
|
||||||
</Select>
|
<SelectItem value="other">Other</SelectItem>
|
||||||
</div>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Button size="sm" onClick={onAddClick}>
|
<Select value={priority} onValueChange={onPriorityChange}>
|
||||||
<IconPlus className="size-4" />
|
<SelectTrigger className="w-36">
|
||||||
<span className="hidden sm:inline">Add Item</span>
|
<SelectValue placeholder="Priority" />
|
||||||
</Button>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Priorities</SelectItem>
|
||||||
|
<SelectItem value="critical">Critical</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={sortBy} onValueChange={onSortChange}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue placeholder="Sort by" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="score">Top Score</SelectItem>
|
||||||
|
<SelectItem value="newest">Newest</SelectItem>
|
||||||
|
<SelectItem value="oldest">Oldest</SelectItem>
|
||||||
|
<SelectItem value="priority">Priority</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,74 +1,42 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { IconList, IconStar, IconUser, IconClock } from "@tabler/icons-react"
|
import { Card } from "@/components/ui/card"
|
||||||
import {
|
import type { WishlistStats as WishlistStatsType } from "@/app/actions/wishlist"
|
||||||
Card,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
|
|
||||||
interface WishlistStatsProps {
|
interface WishlistStatsProps {
|
||||||
stats: {
|
stats: WishlistStatsType
|
||||||
totalItems: number
|
|
||||||
yourItems: number
|
|
||||||
mostWanted: { name: string; score: number } | null
|
|
||||||
recentItems: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WishlistStats({ stats }: WishlistStatsProps) {
|
export function WishlistStats({ stats }: WishlistStatsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 px-4 lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
<div className="px-4 lg:px-6">
|
||||||
<Card>
|
<Card className="px-4 py-3">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||||
<CardDescription>Total Items</CardDescription>
|
<span>
|
||||||
<IconList className="text-muted-foreground size-4" />
|
<span className="text-muted-foreground">Total Items:</span>{" "}
|
||||||
</CardHeader>
|
<span className="font-medium">{stats.totalItems}</span>
|
||||||
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
|
</span>
|
||||||
{stats.totalItems}
|
<span className="text-muted-foreground">|</span>
|
||||||
</CardTitle>
|
<span>
|
||||||
</Card>
|
<span className="text-muted-foreground">High Priority:</span>{" "}
|
||||||
|
<span className="font-medium">{stats.highPriority}</span>
|
||||||
<Card>
|
</span>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<span className="text-muted-foreground">|</span>
|
||||||
<CardDescription>Most Wanted</CardDescription>
|
<span>
|
||||||
<IconStar className="text-muted-foreground size-4" />
|
<span className="text-muted-foreground">Your Submissions:</span>{" "}
|
||||||
</CardHeader>
|
<span className="font-medium">{stats.yourSubmissions}</span>
|
||||||
<CardTitle className="px-6 pb-6 text-2xl font-semibold">
|
</span>
|
||||||
{stats.mostWanted ? (
|
<span className="text-muted-foreground">|</span>
|
||||||
<span className="flex items-center gap-2">
|
<span>
|
||||||
<span className="truncate">{stats.mostWanted.name}</span>
|
<span className="text-muted-foreground">Est. Total Budget:</span>{" "}
|
||||||
<span className="text-muted-foreground text-sm font-normal">
|
<span className="font-medium">
|
||||||
(score: {stats.mostWanted.score})
|
${stats.totalBudget.toLocaleString(undefined, {
|
||||||
</span>
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
</span>
|
||||||
<span className="text-muted-foreground text-base font-normal">
|
</div>
|
||||||
No votes yet
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardDescription>Your Submissions</CardDescription>
|
|
||||||
<IconUser className="text-muted-foreground size-4" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
|
|
||||||
{stats.yourItems}
|
|
||||||
</CardTitle>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardDescription>Recent (Last 5)</CardDescription>
|
|
||||||
<IconClock className="text-muted-foreground size-4" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
|
|
||||||
{stats.recentItems}
|
|
||||||
</CardTitle>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useTransition, useCallback } from "react"
|
import { useState, useEffect, useTransition, useCallback, useMemo } from "react"
|
||||||
import {
|
import {
|
||||||
IconArrowBigDown,
|
IconArrowBigDown,
|
||||||
IconArrowBigDownFilled,
|
IconArrowBigDownFilled,
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
IconFileSpreadsheet,
|
IconFileSpreadsheet,
|
||||||
IconFileTypePdf,
|
IconFileTypePdf,
|
||||||
IconLoader2,
|
IconLoader2,
|
||||||
|
IconPlus,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
@ -47,6 +48,7 @@ import {
|
|||||||
toggleItemVote,
|
toggleItemVote,
|
||||||
deleteWishlistItem,
|
deleteWishlistItem,
|
||||||
type WishlistItemWithMeta,
|
type WishlistItemWithMeta,
|
||||||
|
type WishlistStats as WishlistStatsType,
|
||||||
type SortOption,
|
type SortOption,
|
||||||
type VoteType,
|
type VoteType,
|
||||||
} from "@/app/actions/wishlist"
|
} from "@/app/actions/wishlist"
|
||||||
@ -66,18 +68,31 @@ interface WishlistTableProps {
|
|||||||
export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [items, setItems] = useState<WishlistItemWithMeta[]>([])
|
const [items, setItems] = useState<WishlistItemWithMeta[]>([])
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState<WishlistStatsType>({
|
||||||
totalItems: 0,
|
totalItems: 0,
|
||||||
yourItems: 0,
|
highPriority: 0,
|
||||||
mostWanted: null as { name: string; score: number } | null,
|
yourSubmissions: 0,
|
||||||
recentItems: 0,
|
totalBudget: 0,
|
||||||
})
|
})
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
const [category, setCategory] = useState("all")
|
const [category, setCategory] = useState("all")
|
||||||
|
const [priority, setPriority] = useState("all")
|
||||||
const [sortBy, setSortBy] = useState<SortOption>("score")
|
const [sortBy, setSortBy] = useState<SortOption>("score")
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null)
|
const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null)
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
return items.filter((item) => {
|
||||||
|
const matchesSearch =
|
||||||
|
search === "" ||
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
const matchesPriority = priority === "all" || item.priority === priority
|
||||||
|
return matchesSearch && matchesPriority
|
||||||
|
})
|
||||||
|
}, [items, search, priority])
|
||||||
|
|
||||||
const fetchData = useCallback(() => {
|
const fetchData = useCallback(() => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const [itemsData, statsData] = await Promise.all([
|
const [itemsData, statsData] = await Promise.all([
|
||||||
@ -100,7 +115,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
|
|
||||||
const getExportData = () => {
|
const getExportData = () => {
|
||||||
const headers = ["Name", "Description", "Category", "Priority", "Est. Cost", "Score", "Submitted By", "Created"]
|
const headers = ["Name", "Description", "Category", "Priority", "Est. Cost", "Score", "Submitted By", "Created"]
|
||||||
const rows = items.map((item) => [
|
const rows = filteredItems.map((item) => [
|
||||||
item.name,
|
item.name,
|
||||||
item.description,
|
item.description,
|
||||||
item.category,
|
item.category,
|
||||||
@ -178,7 +193,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={items.length === 0}
|
disabled={filteredItems.length === 0}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<IconDownload className="size-4" />
|
<IconDownload className="size-4" />
|
||||||
@ -206,17 +221,24 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
<IconRefresh className={cn("size-4", isPending && "animate-spin")} />
|
<IconRefresh className={cn("size-4", isPending && "animate-spin")} />
|
||||||
<span className="hidden sm:inline">Refresh</span>
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setAddDialogOpen(true)} className="gap-2">
|
||||||
|
<IconPlus className="size-4" />
|
||||||
|
<span className="hidden sm:inline">Add Item</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WishlistStats stats={stats} />
|
<WishlistStats stats={stats} />
|
||||||
|
|
||||||
<WishlistFilters
|
<WishlistFilters
|
||||||
|
search={search}
|
||||||
category={category}
|
category={category}
|
||||||
|
priority={priority}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
|
onSearchChange={setSearch}
|
||||||
onCategoryChange={setCategory}
|
onCategoryChange={setCategory}
|
||||||
|
onPriorityChange={setPriority}
|
||||||
onSortChange={(sort) => setSortBy(sort as SortOption)}
|
onSortChange={(sort) => setSortBy(sort as SortOption)}
|
||||||
onAddClick={() => setAddDialogOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-4 lg:px-6">
|
<div className="px-4 lg:px-6">
|
||||||
@ -233,6 +255,12 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
Be the first to add something!
|
Be the first to add something!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
No items match your filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border">
|
<div className="rounded-lg border">
|
||||||
<Table>
|
<Table>
|
||||||
@ -248,7 +276,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item) => (
|
{filteredItems.map((item) => (
|
||||||
<WishlistTableRow
|
<WishlistTableRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user