Stocks Dashboard

PreviousNext

Interactive stock portfolio dashboard with status cards, data table, and detailed stock information modal

Docs
uitripledblock

Preview

Loading preview…
components/components/stocks-dashboard/stocks-dashboard.tsx
"use client";

import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { motion } from "framer-motion";
import {
  Activity,
  BarChart3,
  Building2,
  ChevronRight,
  DollarSign,
  TrendingDown,
  TrendingUp,
} from "lucide-react";
import { useState } from "react";

interface Stock {
  id: string;
  symbol: string;
  name: string;
  price: number;
  change: number;
  changePercent: number;
  volume: number;
  marketCap: string;
  sector: string;
  peRatio: number;
}

const mockStocks: Stock[] = [
  {
    id: "1",
    symbol: "AAPL",
    name: "Apple Inc.",
    price: 178.23,
    change: 2.45,
    changePercent: 1.39,
    volume: 45234567,
    marketCap: "2.8T",
    sector: "Technology",
    peRatio: 31.2,
  },
  {
    id: "2",
    symbol: "GOOGL",
    name: "Alphabet Inc.",
    price: 142.56,
    change: -1.23,
    changePercent: -0.85,
    volume: 28345123,
    marketCap: "1.8T",
    sector: "Technology",
    peRatio: 24.8,
  },
  {
    id: "3",
    symbol: "MSFT",
    name: "Microsoft Corporation",
    price: 378.91,
    change: 5.67,
    changePercent: 1.52,
    volume: 19234567,
    marketCap: "2.9T",
    sector: "Technology",
    peRatio: 35.4,
  },
  {
    id: "4",
    symbol: "TSLA",
    name: "Tesla, Inc.",
    price: 248.42,
    change: -3.21,
    changePercent: -1.28,
    volume: 78234567,
    marketCap: "790B",
    sector: "Automotive",
    peRatio: 62.5,
  },
  {
    id: "5",
    symbol: "AMZN",
    name: "Amazon.com Inc.",
    price: 148.76,
    change: 1.89,
    changePercent: 1.29,
    volume: 45234567,
    marketCap: "1.5T",
    sector: "E-commerce",
    peRatio: 48.3,
  },
];

function formatNumber(num: number): string {
  if (num >= 1000000) {
    return (num / 1000000).toFixed(1) + "M";
  }
  if (num >= 1000) {
    return (num / 1000).toFixed(1) + "K";
  }
  return num.toString();
}

function StatusSection() {
  const totalValue = mockStocks.reduce(
    (sum, stock) => sum + stock.price * 100,
    0
  );
  const totalChange = mockStocks.reduce(
    (sum, stock) => sum + stock.change * 100,
    0
  );
  const totalChangePercent = (totalChange / (totalValue - totalChange)) * 100;

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
      <motion.div
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.4 }}
      >
        <Card className="relative overflow-hidden">
          <CardHeader className="flex flex-row items-center justify-between pb-2">
            <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
              Total Portfolio Value
            </CardTitle>
            <DollarSign className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-xl sm:text-2xl font-bold">
              $
              {totalValue.toLocaleString("en-US", {
                minimumFractionDigits: 2,
                maximumFractionDigits: 2,
              })}
            </div>
            <p className="text-xs text-muted-foreground mt-1">
              Based on current prices
            </p>
          </CardContent>
        </Card>
      </motion.div>

      <motion.div
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.4, delay: 0.1 }}
      >
        <Card className="relative overflow-hidden">
          <CardHeader className="flex flex-row items-center justify-between pb-2">
            <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
              Today's Change
            </CardTitle>
            {totalChange >= 0 ? (
              <TrendingUp className="h-4 w-4 text-green-500" />
            ) : (
              <TrendingDown className="h-4 w-4 text-red-500" />
            )}
          </CardHeader>
          <CardContent>
            <div
              className={`text-xl sm:text-2xl font-bold ${totalChange >= 0 ? "text-green-500" : "text-red-500"}`}
            >
              {totalChange >= 0 ? "+" : ""}$
              {totalChange.toLocaleString("en-US", {
                minimumFractionDigits: 2,
                maximumFractionDigits: 2,
              })}
            </div>
            <p
              className={`text-xs mt-1 ${totalChange >= 0 ? "text-green-500" : "text-red-500"}`}
            >
              {totalChangePercent >= 0 ? "+" : ""}
              {totalChangePercent.toFixed(2)}%
            </p>
          </CardContent>
        </Card>
      </motion.div>

      <motion.div
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.4, delay: 0.2 }}
      >
        <Card className="relative overflow-hidden">
          <CardHeader className="flex flex-row items-center justify-between pb-2">
            <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
              Active Positions
            </CardTitle>
            <BarChart3 className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-xl sm:text-2xl font-bold">
              {mockStocks.length}
            </div>
            <p className="text-xs text-muted-foreground mt-1">
              Stocks in portfolio
            </p>
          </CardContent>
        </Card>
      </motion.div>
    </div>
  );
}

function StockDetails({
  stock,
  isOpen,
  onClose,
}: {
  stock: Stock | null;
  isOpen: boolean;
  onClose: () => void;
}) {
  if (!stock) return null;

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="max-w-2xl mx-4 sm:mx-auto">
        <DialogHeader>
          <div className="flex items-center gap-3">
            <div className="h-10 w-10 sm:h-12 sm:w-12 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
              <Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-primary" />
            </div>
            <div className="min-w-0">
              <DialogTitle className="text-xl sm:text-2xl truncate">
                {stock.symbol}
              </DialogTitle>
              <DialogDescription className="truncate">
                {stock.name}
              </DialogDescription>
            </div>
          </div>
        </DialogHeader>

        <div className="space-y-4 sm:space-y-6 mt-4">
          <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
            <Card>
              <CardHeader className="pb-2">
                <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
                  Current Price
                </CardTitle>
              </CardHeader>
              <CardContent>
                <div className="text-xl sm:text-2xl font-bold">
                  ${stock.price.toFixed(2)}
                </div>
              </CardContent>
            </Card>

            <Card>
              <CardHeader className="pb-2">
                <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
                  Change
                </CardTitle>
              </CardHeader>
              <CardContent>
                <div
                  className={`text-xl sm:text-2xl font-bold ${stock.change >= 0 ? "text-green-500" : "text-red-500"}`}
                >
                  {stock.change >= 0 ? "+" : ""}${stock.change.toFixed(2)}
                </div>
                <div
                  className={`text-xs sm:text-sm ${stock.change >= 0 ? "text-green-500" : "text-red-500"}`}
                >
                  {stock.change >= 0 ? "+" : ""}
                  {stock.changePercent.toFixed(2)}%
                </div>
              </CardContent>
            </Card>
          </div>

          <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
            <Card>
              <CardHeader className="pb-2">
                <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
                  Market Cap
                </CardTitle>
              </CardHeader>
              <CardContent>
                <div className="text-lg sm:text-xl font-semibold">
                  ${stock.marketCap}
                </div>
              </CardContent>
            </Card>

            <Card>
              <CardHeader className="pb-2">
                <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
                  Volume
                </CardTitle>
              </CardHeader>
              <CardContent>
                <div className="text-lg sm:text-xl font-semibold">
                  {formatNumber(stock.volume)}
                </div>
              </CardContent>
            </Card>
          </div>

          <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
            <Card>
              <CardHeader className="pb-2">
                <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
                  Sector
                </CardTitle>
              </CardHeader>
              <CardContent>
                <Badge variant="outline">{stock.sector}</Badge>
              </CardContent>
            </Card>

            <Card>
              <CardHeader className="pb-2">
                <CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
                  P/E Ratio
                </CardTitle>
              </CardHeader>
              <CardContent>
                <div className="text-lg sm:text-xl font-semibold">
                  {stock.peRatio}
                </div>
              </CardContent>
            </Card>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}

function DataTable({ onRowClick }: { onRowClick: (stock: Stock) => void }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
          <Activity className="h-4 w-4 sm:h-5 sm:w-5" />
          Stock Holdings
        </CardTitle>
      </CardHeader>
      <CardContent className="p-0 sm:p-6">
        <div className="overflow-x-auto -mx-4 sm:mx-0">
          <div className="inline-block min-w-full align-middle px-4 sm:px-0">
            <table className="w-full min-w-[800px]">
              <thead>
                <tr className="border-b border-border">
                  <th className="text-left py-3 px-3 sm:px-4 text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">
                    Symbol
                  </th>
                  <th className="text-left py-3 px-3 sm:px-4 text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">
                    Name
                  </th>
                  <th className="text-right py-3 px-3 sm:px-4 text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">
                    Price
                  </th>
                  <th className="text-right py-3 px-3 sm:px-4 text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">
                    Change
                  </th>
                  <th className="text-right py-3 px-3 sm:px-4 text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap hidden md:table-cell">
                    Volume
                  </th>
                  <th className="text-right py-3 px-3 sm:px-4 text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap hidden lg:table-cell">
                    Market Cap
                  </th>
                  <th className="text-right py-3 px-3 sm:px-4 text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap hidden lg:table-cell">
                    Sector
                  </th>
                  <th className="text-right py-3 px-3 sm:px-4 text-xs sm:text-sm font-medium text-muted-foreground">
                    <span className="sr-only">Actions</span>
                  </th>
                </tr>
              </thead>
              <tbody>
                {mockStocks.map((stock, index) => (
                  <motion.tr
                    key={stock.id}
                    initial={{ opacity: 0, x: -20 }}
                    animate={{ opacity: 1, x: 0 }}
                    transition={{ duration: 0.3, delay: index * 0.05 }}
                    className="border-b border-border hover:bg-muted/50 cursor-pointer transition-colors"
                    onClick={() => onRowClick(stock)}
                  >
                    <td className="py-3 px-3 sm:px-4">
                      <div className="font-semibold text-sm sm:text-base">
                        {stock.symbol}
                      </div>
                    </td>
                    <td className="py-3 px-3 sm:px-4">
                      <div className="text-xs sm:text-sm text-muted-foreground truncate max-w-[120px] sm:max-w-none">
                        {stock.name}
                      </div>
                    </td>
                    <td className="py-3 px-3 sm:px-4 text-right">
                      <div className="font-semibold text-sm sm:text-base">
                        ${stock.price.toFixed(2)}
                      </div>
                    </td>
                    <td className="py-3 px-3 sm:px-4 text-right">
                      <div
                        className={`font-semibold flex items-center justify-end gap-1 text-sm sm:text-base ${stock.change >= 0 ? "text-green-500" : "text-red-500"}`}
                      >
                        {stock.change >= 0 ? (
                          <TrendingUp className="h-3 w-3 flex-shrink-0" />
                        ) : (
                          <TrendingDown className="h-3 w-3 flex-shrink-0" />
                        )}
                        <span className="whitespace-nowrap">
                          {stock.change >= 0 ? "+" : ""}$
                          {stock.change.toFixed(2)}
                        </span>
                      </div>
                      <div
                        className={`text-xs ${stock.change >= 0 ? "text-green-500" : "text-red-500"}`}
                      >
                        {stock.changePercent >= 0 ? "+" : ""}
                        {stock.changePercent.toFixed(2)}%
                      </div>
                    </td>
                    <td className="py-3 px-3 sm:px-4 text-right hidden md:table-cell">
                      <div className="text-xs sm:text-sm">
                        {formatNumber(stock.volume)}
                      </div>
                    </td>
                    <td className="py-3 px-3 sm:px-4 text-right hidden lg:table-cell">
                      <div className="text-xs sm:text-sm">
                        ${stock.marketCap}
                      </div>
                    </td>
                    <td className="py-3 px-3 sm:px-4 text-right hidden lg:table-cell">
                      <Badge variant="outline" className="text-xs">
                        {stock.sector}
                      </Badge>
                    </td>
                    <td className="py-3 px-3 sm:px-4 text-right">
                      <ChevronRight className="h-4 w-4 text-muted-foreground ml-auto" />
                    </td>
                  </motion.tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

export function StocksDashboard() {
  const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
  const [isDetailsOpen, setIsDetailsOpen] = useState(false);

  const handleRowClick = (stock: Stock) => {
    setSelectedStock(stock);
    setIsDetailsOpen(true);
  };

  const handleCloseDetails = () => {
    setIsDetailsOpen(false);
  };

  return (
    <div className="w-full px-3 sm:px-4 py-4 sm:py-8">
      <div className="mx-auto max-w-7xl">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          className="mb-6 sm:mb-8"
        >
          <h1 className="text-2xl sm:text-3xl font-bold mb-2">
            Stock Portfolio Dashboard
          </h1>
          <p className="text-sm sm:text-base text-muted-foreground">
            Track your investments and monitor market performance
          </p>
        </motion.div>

        <StatusSection />
        <DataTable onRowClick={handleRowClick} />
        <StockDetails
          stock={selectedStock}
          isOpen={isDetailsOpen}
          onClose={handleCloseDetails}
        />
      </div>
    </div>
  );
}

Installation

npx shadcn@latest add @uitripled/stocks-dashboard

Usage

import { StocksDashboard } from "@/components/stocks-dashboard"
<StocksDashboard />