ترکیب داشبورد

الگوی ساخت صفحه داشبورد سوشال لیسنینگ با ترکیب کامپوننت‌های موجود

معرفی

صفحه داشبورد سوشال لیسنینگ نقطه ورود اصلی کاربران به سیستم است و باید در یک نگاه، خلاصه‌ای از وضعیت فعلی برند، احساسات مخاطبان، و روند تغییرات را نمایش دهد. این الگو نحوه ترکیب کامپوننت‌های موجود پرتو را برای ساخت یک داشبورد کامل و واکنش‌گرا توضیح می‌دهد.

صفحهٔ داشبورد: DashboardPage

صفحهٔ داشبورد DashboardPage است: انتخابگر بازه، ردیف شاخص‌ها، شبکهٔ نمودارها و ارتفاع هر نمودار را قالب تعیین می‌کند. این الگو ترکیب شاخص‌ها و نمودارهای داخل آن را نشان می‌دهد.

پیش‌نیاز

قاب برنامه (منو + نوار بالا) را ProductFrame یک بار در layout ریشه می‌سازد. این الگو فقط محتوای صفحه را پوشش می‌دهد: یک DashboardPage با شاخص‌ها و بخش‌های نمودارش.

از این الگو زمانی استفاده کنید که نیاز به ساخت صفحه‌ای دارید که چندین متریک، نمودار روند، و تحلیل احساسات را در کنار هم نمایش می‌دهد. کامپوننت‌های استفاده‌شده در این الگو عبارتند از: MetricCard برای نمایش اعداد کلیدی، Distribution kind="sentiment" برای توزیع احساسات، PartoLineChart برای روند زمانی، و EngagementRate برای تحلیل نرخ تعامل.

یک پیاده‌سازی کامل و کپی‌بردار از این الگو، روی قالب DashboardPage، صفحهٔ «داشبورد» در بلاک قالب شروع است.

بلوک آماده: قالب شروع (Starter)

کد و نمای کامل

ترکیب صفحه کامل

صفحهٔ داشبورد یک DashboardPage است: انتخابگر دوره در period، شاخص‌ها در kpis و هر ردیف نمودار یک DashboardSection از DashboardChartها. عرض (1600)، فاصله‌ها، شبکهٔ شاخص‌ها و نمودارها و ارتفاع هر نمودار را قالب تعیین می‌کند؛ صفحه هیچ کلاس گرید، عرض، ارتفاع یا فاصله‌ای نمی‌نویسد:

'use client'

import { useState } from 'react'
import {
  EngagementRate,
  MetricCard,
  MetricCardContent,
  MetricCardDifferential,
  MetricCardHeader,
  MetricCardLabel,
  MetricCardSparkline,
  MetricCardValue,
  PartoLineChart,
  DateRangePicker,
  Distribution,
  formatNumber,
  resolveDateRangePreset,
  type DateRangeValue,
} from '@partodata/ui'
import { DashboardChart, DashboardPage, DashboardSection } from '@partodata/ui/templates'
import { Eye, MessageCircle, TrendingUp, Users } from 'lucide-react'

const MENTIONS_TREND = [
  { ماه: 'فروردین', منشن‌ها: 1200 },
  { ماه: 'اردیبهشت', منشن‌ها: 1450 },
  { ماه: 'خرداد', منشن‌ها: 1380 },
  { ماه: 'تیر', منشن‌ها: 1620 },
  { ماه: 'مرداد', منشن‌ها: 1800 },
  { ماه: 'شهریور', منشن‌ها: 1750 },
  { ماه: 'مهر', منشن‌ها: 1920 },
]

const METRICS = [
  { label: 'کل منشن‌ها', icon: MessageCircle, value: formatNumber(12450), change: '8.3٪', up: true },
  { label: 'اینفلوئنسرهای فعال', icon: Users, value: formatNumber(348), change: '12.1٪', up: true },
  { label: 'نرخ تعامل میانگین', icon: TrendingUp, value: '4.2٪', change: '0.3٪', up: false },
  { label: 'بازدید کل', icon: Eye, value: '2.4 میلیون', change: '15.7٪', up: true },
]

const SPARK = [1100, 1250, 1180, 1320, 1400, 1350, 1500].map((value, i) => ({
  value,
  timestamp: 1704067200 + i * 86400,
}))

export default function SocialListeningDashboard() {
  const [period, setPeriod] = useState<DateRangeValue | undefined>(() => resolveDateRangePreset('30d'))

  return (
    <DashboardPage
      title="داشبورد سوشال لیسنینگ"
      description="خلاصه وضعیت برند در شبکه‌های اجتماعی"
      period={<DateRangePicker value={period} onChange={setPeriod} />}
      kpis={METRICS.map((metric) => (
        <MetricCard key={metric.label}>
          <MetricCardHeader>
            <MetricCardLabel icon={<metric.icon className="size-3.5" />}>{metric.label}</MetricCardLabel>
          </MetricCardHeader>
          <MetricCardContent>
            <MetricCardValue>{metric.value}</MetricCardValue>
            <MetricCardDifferential
              direction={metric.up ? 'up' : 'down'}
              tone={metric.up ? 'positive' : 'negative'}
              sign={metric.up ? '+' : '-'}
            >
              {metric.change}
            </MetricCardDifferential>
          </MetricCardContent>
          <MetricCardSparkline data={SPARK} dataKey="value" />
        </MetricCard>
      ))}
    >
      <DashboardSection title="روند گفت‌وگو">
        <DashboardChart title="روند منشن‌ها در طول زمان">
          <PartoLineChart
            data={MENTIONS_TREND}
            dataKeys={['منشن‌ها']}
            xAxisKey="ماه"
            ariaLabel="نمودار روند منشن‌ها در هفت ماه اخیر"
          />
        </DashboardChart>
        <DashboardChart title="توزیع احساسات" height="content">
          <Distribution kind="sentiment" data={{ positive: 5200, negative: 1840, neutral: 5410 }} />
        </DashboardChart>
      </DashboardSection>
      <DashboardSection title="تعامل">
        <DashboardChart title="تحلیل نرخ تعامل" span="full" height="content">
          <EngagementRate currentRate={0.0421} followers={85000} />
        </DashboardChart>
      </DashboardSection>
    </DashboardPage>
  )
}

عنوان صفحه (تنها <h1>) 48 پیکسل زیر نوار بالای قاب است و انتخابگر دوره اولین اقدام سرِ صفحه؛ این‌ها را قالب می‌گذارد. جزئیات اعداد در هندسهٔ صفحه و قالب در DashboardPage آمده است.


ردیف شاخص‌ها

شاخص‌ها را MetricCard کنید و به kpis بدهید. قالب آن‌ها را در یک ردیف می‌چیند: هر تعداد کاشی که در عرض جا شود، هر کدام دست‌کم --layout-tile-min-width، بدون نقطهٔ شکست، و زیر سرعنوان پنهان «شاخص‌ها». گرید خودتان (grid-cols-* با نقطه‌های شکست) نسازید. اگر شاخص‌ها از API می‌آیند، kpis را با حلقه بسازید، مثل نمونهٔ بالا.


ردیف نمودارها

هر ردیف نمودار یک DashboardSection (با عنوان یا بی آن) است و هر نمودار یک DashboardChart. قالب نمودارها را در شبکه‌ای می‌چیند که هر ستون دست‌کم --layout-chart-min-width است (دو ستون در عرض پیش‌فرض، سه ستون در عرض پهن، یک ستون روی موبایل) و ارتفاع ناحیهٔ هر نمودار را یکسان می‌کند. نموداری که کل ردیف را می‌گیرد span="full" است؛ جدول، فهرست یا کامپوننتی که ارتفاع خودش را دارد height="content". به نمودار داخل DashboardChart ارتفاع ندهید و ChartCard خودتان را نسازید.


حالت بارگذاری

حالت کل داشبورد state خود DashboardPage است و همیشه با pageState از خروجی درخواست ساخته می‌شود: تا داده برسد اسکلت ردیف شاخص‌ها و نمودارها به‌جای محتوا می‌نشیند، خطا ErrorState با «تلاش مجدد» است، و سرِ صفحه با انتخابگر دوره در همهٔ حالت‌ها می‌ماند. نموداری که جدا بارگذاری می‌شود state خودش را در DashboardChart می‌گیرد و فقط ناحیهٔ همان نمودار اسکلت یا خطا می‌گیرد.

'use client'

import { useCallback, useEffect, useState } from 'react'
import { DateRangePicker, resolveDateRangePreset, useAsync, type DateRangeValue } from '@partodata/ui'
import { DashboardPage, pageState } from '@partodata/ui/templates'
// درخواست داشبورد و کارت هر شاخص، از کد خود محصول
import { MetricCardFor, fetchDashboardData, type DashboardData } from '@/lib/dashboard'

export default function SocialListeningDashboard() {
  const [period, setPeriod] = useState<DateRangeValue | undefined>(() => resolveDateRangePreset('30d'))
  const { data, isLoading, error, run } = useAsync<DashboardData>()
  const load = useCallback(() => run(() => fetchDashboardData(period)), [run, period])
  useEffect(() => {
    load()
  }, [load])

  return (
    <DashboardPage
      title="داشبورد سوشال لیسنینگ"
      period={<DateRangePicker value={period} onChange={setPeriod} />}
      state={pageState({ data, isLoading, error, onRetry: load })}
      kpis={data?.metrics.map((metric) => (
        <MetricCardFor key={metric.id} metric={metric} />
      ))}
    >
      {/* DashboardSectionهای نمودار */}
    </DashboardPage>
  )
}

بهترین روش‌ها

انتخابگر دوره زمانی

بازهٔ زمانی همهٔ داده‌های داشبورد یک DateRangePicker در period است، با بازه‌های آماده‌اش («7 روز اخیر» … «1 سال اخیر» و «بازهٔ دلخواه»)؛ داشبورد تازه با «ماه گذشته» شروع می‌شود. بازه را به API بفرستید تا همه بخش‌ها هماهنگ باشند؛ بازهٔ آماده کلیدش را هم دارد (period.preset)، پس نمای ذخیره‌شده نسبی می‌ماند و سرور آن را با resolveDateRangePreset حل می‌کند:

const [period, setPeriod] = useState<DateRangeValue | undefined>(() => resolveDateRangePreset('30d'))
const { data, run } = useAsync<DashboardData>()
const load = useCallback(() => run(() => fetchDashboardData(period)), [run, period])

;<DateRangePicker value={period} onChange={setPeriod} />

PeriodSelector فقط برای پنجرهٔ زمانی یک نمودار در سرِ کارت همان نمودار است (actions در DashboardChart).

نمایش اسکلت‌بندی قبل از بارگذاری

همیشه قبل از رسیدن داده‌ها، اسکلت‌بندی نمایش دهید: state={pageState({ … })} روی DashboardPage (یا روی یک DashboardChart) اسکلت را خودش می‌گذارد. هرگز صفحه خالی نمایش ندهید.

درصد تغییر نسبت به دوره قبل

از MetricCardDifferential برای نمایش تغییر نسبت به دوره قبلی استفاده کنید. direction پیکان را از علامت تغییر می‌گیرد (up، down، neutral) و tone رنگ را از قطبیت شاخص: افزایش نرخ تعامل positive است، افزایش حجم منشن‌ها neutral (حجم قطبیت ندارد).

نمودار Sparkline در هر کارت

از MetricCardSparkline برای نمایش روند تغییرات در هر کارت متریک استفاده کنید. این نمودار کوچک به کاربر امکان می‌دهد بدون مراجعه به نمودارهای بزرگ، روند کلی را ببیند.

محدودیت تعداد کارت‌ها

تعداد کارت‌های متریک را به 4 تا 6 عدد در هر ردیف محدود کنید. اطلاعات بیش از حد در یک نگاه قابل پردازش نیست. برای متریک‌های ثانویه، از بخش‌های جداگانه در پایین صفحه استفاده کنید.

عرض داشبورد

عرض داشبورد را DashboardPage می‌دهد: wide (1600 پیکسل، توکن --layout-content-wide) به‌طور پیش‌فرض تا نمودارها و متریک‌ها فضای کافی داشته باشند؛ width="full" فقط برای داشبوردی از نمودارهای بسیار پهن. کانتینر دستی w-full یا max-w-* نسازید. قاعدهٔ کامل در هندسهٔ صفحه آمده است.


دام‌های رایج

اشتباهات زیر در پیاده‌سازی داشبورد بارها دیده شده‌اند. برای هر مورد، اشتباه، دلیل مشکل‌ساز بودن، و الگوی درست را بررسی کنید.

کلاس‌های جهت‌دار فیزیکی به جای Logical Properties

اشتباه: استفاده از کلاس‌های فیزیکی مانند ml-*، mr-*، pl-*، text-left یا left-0 برای فاصله‌گذاری و تراز در هدر کارت‌ها و چیدمان داشبورد.

چرا مشکل‌ساز است: کل صفحه با dir="rtl" رندر می‌شود؛ کلاس فیزیکی آیکون یا برچسب را به سمت اشتباه می‌چسباند و اگر روزی نسخه چندزبانه (LTR) اضافه شود، چیدمان به‌کلی می‌شکند. خود کتابخانه پرتو این قاعده را با قانون ESLint اختصاصی (no-physical-css-properties) اجبار می‌کند — کد مصرف‌کننده نیز باید همان انضباط را رعایت کند.

الگوی درست: همیشه معادل منطقی را به کار ببرید: ms/me به جای ml/mr، ps/pe به جای pl/pr، start/end به جای left/right، و text-start به جای text-left.

// ❌ نادرست — mr-2 در صفحه RTL آیکون را به سمت اشتباه می‌چسباند
<h3 className="mb-4 text-left text-subheading text-foreground">
  <TrendingUp className="mr-2 inline h-4 w-4" />
  روند منشن‌ها در طول زمان
</h3>

// ✅ درست — معادل‌های منطقی در هر دو جهت صفحه درست کار می‌کنند
<h3 className="mb-4 text-start text-subheading text-foreground">
  <TrendingUp className="me-2 inline h-4 w-4" />
  روند منشن‌ها در طول زمان
</h3>

نکته: prop مربوط به margin در PartoLineChart (با کلیدهای left و right) بخشی از API بوم نمودار است، نه CSS — نیازی به تغییر آن نیست.

رنگ هاردکد به جای توکن‌های معنایی

اشتباه: استفاده از text-green-500 برای رشد، text-red-500 یا مقدار hex مانند #ef4444 برای کاهش، یا آرایه رنگ ثابت برای سری‌های نمودار.

چرا مشکل‌ساز است: سیستم دو تم دارد و رنگی که برای یک تم انتخاب شده در تم دیگر کنتراست کافی ندارد. نمودارهای پرتو رنگ‌ها را در زمان اجرا از متغیرهای CSS می‌خوانند و با تغییر تم به‌روز می‌شوند؛ رنگ هاردکد از این چرخه بیرون می‌ماند و هنگام تغییر تم ثابت باقی می‌ماند.

الگوی درست: برای درصد تغییر از variant خود کامپوننت استفاده کنید و برای عناصر سفارشی، توکن‌های معنایی (--sentiment-*، --chart-*) را به کار ببرید:

// ❌ نادرست — رنگ ثابت Tailwind خارج از سیستم توکن
<span className="text-green-500">+8.3٪</span>

// ✅ درست — variant توکن معنایی مناسب را اعمال می‌کند
<MetricCardDifferential direction="up" tone="positive">+8.3٪</MetricCardDifferential>

// ✅ درست — برای عناصر سفارشی، توکن احساسات
<span className="text-[var(--sentiment-positive-text)]">+8.3٪</span>

آزمودن فقط در تم روشن

اشتباه: ساخت کارت‌ها با bg-white و border-gray-200 و بررسی خروجی فقط در تم روشن.

چرا مشکل‌ساز است: در @partodata/ui تم تیره پیش‌فرض است — مصرف‌کننده‌ای که هیچ تمی تنظیم نکند، تیره رندر می‌شود. کارت bg-white روی پس‌زمینه تیره به شکل لکه‌ای روشن ظاهر می‌شود و ترکیب آن با متن text-gray-900 خوانایی را از بین می‌برد.

الگوی درست: نمودار و کارت داده را در ChartCard بگذارید — همان الگویی که در نمونه‌های همین صفحه به کار رفته است — و برای عناصر سفارشی فقط از توکن‌های سطح استفاده کنید (bg-card، border-border، text-foreground، text-muted-foreground). پیش از انتشار، داشبورد را در هر دو تم بررسی کنید.

// ❌ نادرست — کارت روشنِ ثابت؛ در تم تیره پیش‌فرض می‌شکند
<div className="rounded-lg border border-gray-200 bg-white p-4">
  <h3 className="text-gray-900">توزیع احساسات</h3>
</div>

// ✅ درست — ChartCard سطح و حاشیهٔ درست را در هر دو تم دارد
<ChartCard>
  <ChartCardHeader title="توزیع احساسات" />
  <ChartCardContent>{/* نمودار */}</ChartCardContent>
</ChartCard>

بارگذاری کل داده‌ها در کلاینت به جای صفحه‌بندی سمت سرور

اشتباه: برای فهرست منشن‌ها یا اینفلوئنسرهای زیر داشبورد، دریافت همه ردیف‌ها در یک درخواست و صفحه‌بندی آن‌ها در مرورگر.

چرا مشکل‌ساز است: داده‌های سوشال لیسنینگ بزرگ‌اند — یک کمپین فعال ممکن است ده‌ها هزار منشن داشته باشد. دریافت کامل، بارگذاری اولیه را کند و حافظه مرورگر را سنگین می‌کند. DataTable پرتو از اساس برای صفحه‌بندی سمت سرور طراحی شده است: prop مربوط به pagination مقادیر currentPage، totalPages و totalRows را از بیرون می‌گیرد و نمی‌تواند آن‌ها را از داده استنتاج کند.

الگوی درست: شماره صفحه را در درخواست بگنجانید و فقط ردیف‌های همان صفحه را دریافت کنید؛ period را هم در وابستگی‌های load بگذارید تا جدول با انتخابگر دوره هماهنگ بماند. فهرست داخل داشبورد یک DashboardChart با height="content" است که حالت‌هایش را در state خودش می‌گیرد؛ جدول isLoading و emptyState ندارد (صفحه‌بندی خودش را دارد، چون فهرست صفحهٔ فهرست نیست):

import * as React from 'react'
import { DataTable, useAsync, type DataTableColumn } from '@partodata/ui'
import { DashboardChart, pageState } from '@partodata/ui/templates'

interface Mention {
  author: string
  text: string
  platform: string
}

interface MentionsPage {
  rows: Mention[]
  totalPages: number
  totalRows: number
}

declare function fetchMentions(query: { period: string; page: number; pageSize: number }): Promise<MentionsPage>

const columns: DataTableColumn<Mention>[] = [
  { id: 'author', header: 'نویسنده', cell: (row) => row.author },
  { id: 'text', header: 'متن منشن', cell: (row) => row.text },
  { id: 'platform', header: 'پلتفرم', cell: (row) => row.platform },
]

function MentionsTable({ period }: { period: string }) {
  const [page, setPage] = React.useState(1)
  const { data, isLoading, error, run } = useAsync<MentionsPage>()
  const load = React.useCallback(() => run(() => fetchMentions({ period, page, pageSize: 20 })), [run, period, page])
  React.useEffect(() => {
    load()
  }, [load])

  return (
    <DashboardChart
      title="آخرین منشن‌ها"
      height="content"
      span="full"
      state={pageState({
        data: data?.rows,
        isLoading,
        error,
        onRetry: load,
        emptyCopy: { title: 'در این دوره منشنی ثبت نشده است' },
      })}
    >
      <DataTable
        columns={columns}
        data={data?.rows ?? []}
        pagination={{
          currentPage: page,
          totalPages: data?.totalPages ?? 1,
          onPageChange: setPage,
          pageSize: 20,
          totalRows: data?.totalRows,
        }}
      />
    </DashboardChart>
  )
}

لحن غیررسمی و ارقام ناهماهنگ

اشتباه: نوشتن متن‌های حالت خالی یا بارگذاری با لحن محاوره‌ای و نمایش هم‌زمان ارقام لاتین و فارسی در یک نما (مثلاً 12,450 در یک کارت و 4.2٪ در کارت کناری).

چرا مشکل‌ساز است: داشبورد یک محصول گزارش‌محور سازمانی است و مخاطب آن تحلیل‌گران و مدیران‌اند؛ لحن محاوره‌ای اعتبار گزارش را کم می‌کند و قاعده محتوایی این سیستم طراحی، فارسی رسمی است. ارقام ناهماهنگ نیز مقایسه سریع متریک‌ها را دشوار می‌کند.

الگوی درست: همه متن‌ها با فارسی رسمی، و اعداد از یک مسیر قالب‌بندی واحد. ارقام را تبدیل نکنید — فونت این کار را می‌کند (فارسی‌محور بودن). برای جداکنندهٔ هزار formatNumber و برای مقدار مختصر formatLargeNumber(n, 'fa'):

import { formatNumber, formatLargeNumber } from '@partodata/ui'

// ❌ نادرست — لحن محاوره‌ای، عددِ بدون جداکننده، و تبدیل رقم در کد
<p>هنوز دیتایی نیومده، یه کم صبر کن!</p>
<MetricCardValue>12450</MetricCardValue>
<MetricCardValue>{(12450).toLocaleString('fa-IR')}</MetricCardValue>

// ✅ درست — فارسی رسمی، جداکننده از formatNumber، و ارقام از فونت
<p>داده‌ای برای این بازه زمانی ثبت نشده است.</p>
<MetricCardValue>{formatNumber(12450)}</MetricCardValue>

// ✅ مقدار مختصر، با پسوند فارسی نه پسوند لاتین
<MetricCardValue>{formatLargeNumber(12450, 'fa')}</MetricCardValue>

صفحات مرتبط