mirror of
https://github.com/NicholaiVogel/dashore-incubator.git
synced 2026-03-30 22:38:56 +00:00
Merge pull request #4 from NicholaiVogel/feat/wishlist-ui-redesign
feat(wishlist): redesign stats bar and filter layout
This commit is contained in:
commit
bacf3d5d61
@ -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 db = getDb(env.DB)
|
||||
|
||||
const allItems = await db.select().from(wishlistItems)
|
||||
const allVotes = await db.select().from(wishlistVotes)
|
||||
|
||||
const totalItems = allItems.length
|
||||
const yourItems = allItems.filter((i) => i.submittedBy === userId).length
|
||||
|
||||
let mostWanted = null
|
||||
if (allItems.length > 0) {
|
||||
const itemScores = allItems.map((item) => {
|
||||
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
|
||||
const highPriority = allItems.filter((i) => i.priority === "high").length
|
||||
const yourSubmissions = allItems.filter((i) => i.submittedBy === userId).length
|
||||
const totalBudget = allItems.reduce(
|
||||
(sum, item) => sum + (item.estimatedCost ?? 0),
|
||||
0
|
||||
)
|
||||
|
||||
return {
|
||||
totalItems,
|
||||
yourItems,
|
||||
mostWanted,
|
||||
recentItems,
|
||||
highPriority,
|
||||
yourSubmissions,
|
||||
totalBudget,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { IconPlus } from "@tabler/icons-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -11,54 +10,73 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
|
||||
interface WishlistFiltersProps {
|
||||
search: string
|
||||
category: string
|
||||
priority: string
|
||||
sortBy: string
|
||||
onSearchChange: (search: string) => void
|
||||
onCategoryChange: (category: string) => void
|
||||
onPriorityChange: (priority: string) => void
|
||||
onSortChange: (sort: string) => void
|
||||
onAddClick: () => void
|
||||
}
|
||||
|
||||
export function WishlistFilters({
|
||||
search,
|
||||
category,
|
||||
priority,
|
||||
sortBy,
|
||||
onSearchChange,
|
||||
onCategoryChange,
|
||||
onPriorityChange,
|
||||
onSortChange,
|
||||
onAddClick,
|
||||
}: WishlistFiltersProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 px-4 lg:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={category} onValueChange={onCategoryChange}>
|
||||
<SelectTrigger className="w-[140px]" size="sm">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
<div className="flex flex-wrap items-center gap-4 px-4 lg:px-6">
|
||||
<Input
|
||||
placeholder="Search items..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-9 w-56"
|
||||
/>
|
||||
|
||||
<Select value={sortBy} onValueChange={onSortChange}>
|
||||
<SelectTrigger className="w-[130px]" size="sm">
|
||||
<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>
|
||||
<Select value={category} onValueChange={onCategoryChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
|
||||
<Button size="sm" onClick={onAddClick}>
|
||||
<IconPlus className="size-4" />
|
||||
<span className="hidden sm:inline">Add Item</span>
|
||||
</Button>
|
||||
<Select value={priority} onValueChange={onPriorityChange}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,74 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { IconList, IconStar, IconUser, IconClock } from "@tabler/icons-react"
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import type { WishlistStats as WishlistStatsType } from "@/app/actions/wishlist"
|
||||
|
||||
interface WishlistStatsProps {
|
||||
stats: {
|
||||
totalItems: number
|
||||
yourItems: number
|
||||
mostWanted: { name: string; score: number } | null
|
||||
recentItems: number
|
||||
}
|
||||
stats: WishlistStatsType
|
||||
}
|
||||
|
||||
export function WishlistStats({ stats }: WishlistStatsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 px-4 lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardDescription>Total Items</CardDescription>
|
||||
<IconList className="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
|
||||
{stats.totalItems}
|
||||
</CardTitle>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardDescription>Most Wanted</CardDescription>
|
||||
<IconStar className="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardTitle className="px-6 pb-6 text-2xl font-semibold">
|
||||
{stats.mostWanted ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate">{stats.mostWanted.name}</span>
|
||||
<span className="text-muted-foreground text-sm font-normal">
|
||||
(score: {stats.mostWanted.score})
|
||||
</span>
|
||||
<div className="px-4 lg:px-6">
|
||||
<Card className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<span>
|
||||
<span className="text-muted-foreground">Total Items:</span>{" "}
|
||||
<span className="font-medium">{stats.totalItems}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">High Priority:</span>{" "}
|
||||
<span className="font-medium">{stats.highPriority}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Your Submissions:</span>{" "}
|
||||
<span className="font-medium">{stats.yourSubmissions}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Est. Total Budget:</span>{" "}
|
||||
<span className="font-medium">
|
||||
${stats.totalBudget.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-base font-normal">
|
||||
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>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useTransition, useCallback } from "react"
|
||||
import { useState, useEffect, useTransition, useCallback, useMemo } from "react"
|
||||
import {
|
||||
IconArrowBigDown,
|
||||
IconArrowBigDownFilled,
|
||||
@ -13,6 +13,7 @@ import {
|
||||
IconFileSpreadsheet,
|
||||
IconFileTypePdf,
|
||||
IconLoader2,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react"
|
||||
@ -47,6 +48,7 @@ import {
|
||||
toggleItemVote,
|
||||
deleteWishlistItem,
|
||||
type WishlistItemWithMeta,
|
||||
type WishlistStats as WishlistStatsType,
|
||||
type SortOption,
|
||||
type VoteType,
|
||||
} from "@/app/actions/wishlist"
|
||||
@ -66,18 +68,31 @@ interface WishlistTableProps {
|
||||
export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [items, setItems] = useState<WishlistItemWithMeta[]>([])
|
||||
const [stats, setStats] = useState({
|
||||
const [stats, setStats] = useState<WishlistStatsType>({
|
||||
totalItems: 0,
|
||||
yourItems: 0,
|
||||
mostWanted: null as { name: string; score: number } | null,
|
||||
recentItems: 0,
|
||||
highPriority: 0,
|
||||
yourSubmissions: 0,
|
||||
totalBudget: 0,
|
||||
})
|
||||
const [search, setSearch] = useState("")
|
||||
const [category, setCategory] = useState("all")
|
||||
const [priority, setPriority] = useState("all")
|
||||
const [sortBy, setSortBy] = useState<SortOption>("score")
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null)
|
||||
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(() => {
|
||||
startTransition(async () => {
|
||||
const [itemsData, statsData] = await Promise.all([
|
||||
@ -100,7 +115,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
||||
|
||||
const getExportData = () => {
|
||||
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.description,
|
||||
item.category,
|
||||
@ -178,7 +193,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={items.length === 0}
|
||||
disabled={filteredItems.length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
<IconDownload className="size-4" />
|
||||
@ -206,17 +221,24 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
||||
<IconRefresh className={cn("size-4", isPending && "animate-spin")} />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</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>
|
||||
|
||||
<WishlistStats stats={stats} />
|
||||
|
||||
<WishlistFilters
|
||||
search={search}
|
||||
category={category}
|
||||
priority={priority}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={setSearch}
|
||||
onCategoryChange={setCategory}
|
||||
onPriorityChange={setPriority}
|
||||
onSortChange={(sort) => setSortBy(sort as SortOption)}
|
||||
onAddClick={() => setAddDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="px-4 lg:px-6">
|
||||
@ -233,6 +255,12 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
||||
Be the first to add something!
|
||||
</p>
|
||||
</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">
|
||||
<Table>
|
||||
@ -248,7 +276,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
{filteredItems.map((item) => (
|
||||
<WishlistTableRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user