بلاک‌ها · پنل مدیریت

خطِ زمانیِ روزبه‌روز با عکسِ فرد و آیکونِ رنگیِ نوعِ کار، «چه کسی، چه کاری، روی چه چیزی» با جزئیاتِ تغییر، فیلترِ نوع با تب، جست‌وجو و خروجیِ اکسل.

۱. نصب کامپوننت‌ها

این بلاک از ۷ آیتم دیگ استفاده می‌کند: avatarbuttoncontrol-sizeiconspersiansearch-fieldtoggle-group

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.json

۲. کد صفحه

کد را در app/<route>/page.tsx بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

activity-log.tsx
"use client"

import * as React from "react"

import { DownloadIcon, EditIcon, FileTextIcon, ShieldCheckIcon, TrashIcon, UsersIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { SearchField } from "@/components/ui/search-field"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { toFaDigits, toSearchKey } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"

type Kind = "edit" | "create" | "delete" | "access" | "security"
const KIND: Record<Kind, { icon: React.ReactNode; tone: string }> = {
  create: { icon: <FileTextIcon />, tone: "green" },
  edit: { icon: <EditIcon />, tone: "blue" },
  delete: { icon: <TrashIcon />, tone: "red" },
  access: { icon: <UsersIcon />, tone: "violet" },
  security: { icon: <ShieldCheckIcon />, tone: "amber" },
}

const DAYS: { day: string; events: { who: string; photo: StockImageName; kind: Kind; action: string; target: string; time: string; detail?: string }[] }[] = [
  {
    day: "امروز",
    events: [
      { who: "نیما رضایی", photo: "personMan1", kind: "edit", action: "مبلغِ فاکتور را عوض کرد", target: "فاکتورِ ۱۴۰۵-۰۷۱۸", time: "۱۰:۴۲", detail: "۴۹۰٬۰۰۰ ← ۵۲۰٬۰۰۰ تومان" },
      { who: "مهسا کریمی", photo: "personWoman1", kind: "create", action: "مشتری تازه اضافه کرد", target: "کافه رست", time: "۰۹:۱۵" },
      { who: "سارا محمدی", photo: "personWoman3", kind: "security", action: "ورودِ دومرحله‌ای را فعال کرد", target: "حسابِ خودش", time: "۰۸:۰۲" },
    ],
  },
  {
    day: "دیروز",
    events: [
      { who: "سارا محمدی", photo: "personWoman3", kind: "access", action: "نقشِ عضو را تغییر داد", target: "لیلا احمدی", time: "۱۷:۳۰", detail: "عضو ← بیننده" },
      { who: "آرش نوری", photo: "personMan2", kind: "delete", action: "پیش‌نویس را حذف کرد", target: "فاکتورِ پیش‌نویسِ ۱۲", time: "۱۴:۱۰" },
      { who: "نیما رضایی", photo: "personMan1", kind: "create", action: "کلیدِ API ساخت", target: "سرورِ فروشگاه", time: "۱۱:۰۵" },
    ],
  },
]

const FILTERS: { value: "all" | Kind; label: string }[] = [
  { value: "all", label: "همه" },
  { value: "edit", label: "ویرایش" },
  { value: "create", label: "ساخت" },
  { value: "delete", label: "حذف" },
  { value: "access", label: "دسترسی" },
]

/**
 * گزارشِ فعالیت: خطِ زمانی روزبه‌روز با عکسِ فرد، آیکونِ رنگی نوعِ کار، «چه کسی، چه
 * کاری، روی چه چیزی» و جزئیاتِ تغییر؛ فیلترِ نوع با تب، جست‌وجو و خروجی اکسل.
 */
export default function ActivityLogBlock() {
  const [filter, setFilter] = React.useState<"all" | Kind>("all")
  const [query, setQuery] = React.useState("")
  const key = toSearchKey(query)

  const days = DAYS.map((day) => ({
    ...day,
    events: day.events.filter((event) => (filter === "all" || event.kind === filter) && toSearchKey(event.who + event.action + event.target).includes(key)),
  })).filter((day) => day.events.length > 0)

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-4xl flex-col gap-8 p-4 sm:p-6">
        <header className="flex flex-wrap items-end justify-between gap-x-4 gap-y-8">
          <div className="flex flex-col gap-1">
            <h1 className="text-2xl font-bold">گزارش فعالیت</h1>
            <p className="text-muted-foreground">هر تغییری که اعضای تیم در حساب داده‌اند.</p>
          </div>
          <Button variant="outline">
            <DownloadIcon />
            خروجی اکسل
          </Button>
        </header>

        <div className="flex flex-wrap items-center justify-between gap-3">
          <div className="max-w-full overflow-x-auto">
            <ToggleGroup type="single" variant="segmented" value={filter} onValueChange={(value) => value && setFilter(value as "all" | Kind)} aria-label="نوعِ فعالیت">
              {FILTERS.map((item) => (
                <ToggleGroupItem key={item.value} value={item.value} className="whitespace-nowrap">
                  {item.label}
                </ToggleGroupItem>
              ))}
            </ToggleGroup>
          </div>
          <SearchField aria-label="جست‌وجو در فعالیت‌ها" placeholder="نام، کار یا مورد" value={query} onValueChange={setQuery} className="w-full sm:w-64" />
        </div>

        {days.length === 0 ? (
          <p className="rounded-xl border border-dashed border-border p-10 text-center text-muted-foreground">فعالیتی با این فیلتر نیست.</p>
        ) : (
          <div className="flex flex-col gap-10">
            {days.map((day) => (
              <section key={day.day} aria-label={day.day} className="flex flex-col gap-4">
                <h2 className="text-sm font-semibold text-muted-foreground">
                  {day.day} · {toFaDigits(day.events.length)} فعالیت
                </h2>
                <ol className="flex flex-col">
                  {day.events.map((event, index) => (
                    <li key={`${event.time}-${event.target}`} className="relative flex gap-4 pb-6 last:pb-0">
                      {/* خطِ اتصال بینِ رویدادهای یک روز */}
                      {index < day.events.length - 1 && <span aria-hidden className="absolute start-6 top-14 bottom-1 w-px bg-border" />}
                      <span className="relative shrink-0">
                        <Avatar alt={event.who} src={stockImages[event.photo].src} size="lg" />
                        <span
                          aria-hidden
                          className="absolute -end-1 -bottom-1 flex size-5 items-center justify-center rounded-full ring-2 ring-background [&>svg]:size-3"
                          style={{ background: `color-mix(in oklab, var(--dig-${KIND[event.kind].tone}-500) 16%, var(--background))`, color: `var(--dig-${KIND[event.kind].tone}-600)` }}
                        >
                          {KIND[event.kind].icon}
                        </span>
                      </span>
                      <div className="flex min-w-0 flex-1 flex-col gap-1.5 pt-1">
                        <p className="text-sm leading-6">
                          <span className="font-semibold">{event.who}</span> {event.action}: <span className="font-medium">{event.target}</span>
                        </p>
                        {event.detail && <span className="self-start rounded-md bg-subtle px-2.5 py-1 text-xs text-muted-foreground">{event.detail}</span>}
                      </div>
                      <time className="shrink-0 pt-1 text-xs text-muted-foreground tabular-nums">{event.time}</time>
                    </li>
                  ))}
                </ol>
              </section>
            ))}
          </div>
        )}
      </main>
    </ControlSizeProvider>
  )
}