ランキングLeaderboardExperimental

順位、値、バー、前期比をまとめたランキングリストです。並び順は呼び出し側が決めます。

プレビュー

状態とバリエーション

要対応順

事故率やコストなど、上昇が悪い指標では deltaTones を反転します。

上位順

処理件数や売上など、値が大きいほど良いランキングにも使えます。

バーなし

値の比較より順位ラベルを優先する場合は showBar={false} にします。

プロパティ

表は横にスクロールできます
プロパティ初期値説明
itemsLeaderboardItem[]-事前に並び替え済みの行です。順位は配列位置で決まります。
labelReactNode-ランキングのアクセシブル名です。
showRankbooleantrue番号付きランクチップを表示します。
showBarbooleantrue値のバーを表示します。
maxnumber-バー正規化の最大値です。省略時は最大 value です。
formatValue / formatDelta(value) => ReactNode-値と前期比の表示を整形します。
deltaTones{ positive?, negative?, zero? }-上昇が悪い指標では positive を destructive にできます。
highlightTopnumber3上位 N 件のランクチップを強調します。
selectedIdstring | number-選択中の行を強調します。
LeaderboardItem.onSelect() => void-行を選択可能なボタンにします。

使い方

import * as React from "react";
import {
  Badge,
  Leaderboard,
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  type LeaderboardItem,
} from "@gunjo/ui";
import { IconBus, IconMapPin } from "@tabler/icons-react";

const routes: LeaderboardItem[] = [
  {
    id: "shibuya88",
    label: "渋88 系統",
    sublabel: "渋谷↔六本木",
    value: 3.4,
    delta: 0.9,
    tone: "destructive",
    icon: <IconBus className="h-4 w-4" />,
  },
  {
    id: "shuku51",
    label: "宿51 系統",
    sublabel: "新宿↔渋谷",
    value: 2.8,
    delta: 0.3,
    tone: "destructive",
    icon: <IconBus className="h-4 w-4" />,
  },
  {
    id: "to07",
    label: "都07 系統",
    sublabel: "錦糸町↔門前仲町",
    value: 1.2,
    delta: -0.2,
    tone: "warning",
    icon: <IconMapPin className="h-4 w-4" />,
  },
  {
    id: "km01",
    label: "海01 系統",
    sublabel: "門前仲町↔東京テレポート",
    value: 0.7,
    delta: -0.4,
    tone: "success",
    icon: <IconBus className="h-4 w-4" />,
  },
  {
    id: "shina97",
    label: "品97 系統",
    sublabel: "品川↔新宿",
    value: 0.6,
    delta: -0.1,
    tone: "success",
    icon: <IconBus className="h-4 w-4" />,
  },
  {
    id: "higashi15",
    label: "東15 系統",
    sublabel: "東京駅↔深川車庫",
    value: 0.4,
    delta: 0,
    tone: "info",
    icon: <IconBus className="h-4 w-4" />,
  },
  {
    id: "to05",
    label: "都05 系統",
    sublabel: "東京駅↔晴海埠頭",
    value: 0,
    delta: 0,
    tone: "success",
    icon: <IconMapPin className="h-4 w-4" />,
  },
];

const routeDetails = {
  shibuya88: {
    status: "重点対応",
    scope: "渋谷から六本木の運行区間",
    owner: "城南営業所",
    period: "2026年6月",
    next: "朝ピーク帯のヒヤリハット記録を翌営業日までに確認します。",
  },
  shuku51: {
    status: "要確認",
    scope: "新宿から渋谷の運行区間",
    owner: "新宿営業所",
    period: "2026年6月",
    next: "交差点付近の遅延要因と運転記録を確認します。",
  },
  to07: {
    status: "改善傾向",
    scope: "錦糸町から門前仲町の運行区間",
    owner: "深川営業所",
    period: "2026年6月",
    next: "改善施策を継続し、雨天日の発生率だけを追加で確認します。",
  },
  km01: {
    status: "安定",
    scope: "門前仲町から東京テレポートの運行区間",
    owner: "臨海営業所",
    period: "2026年6月",
    next: "現行運用を継続し、次月も同じ指標で比較します。",
  },
  shina97: {
    status: "安定",
    scope: "品川から新宿の運行区間",
    owner: "品川営業所",
    period: "2026年6月",
    next: "現行の安全確認フローを継続します。",
  },
  higashi15: {
    status: "通常監視",
    scope: "東京駅から深川車庫の運行区間",
    owner: "深川営業所",
    period: "2026年6月",
    next: "通常の月次レビューで推移を確認します。",
  },
  to05: {
    status: "良好",
    scope: "東京駅から晴海埠頭の運行区間",
    owner: "晴海営業所",
    period: "2026年6月",
    next: "改善値を維持できているか来月も確認します。",
  },
};

export function IncidentLeaderboard() {
  const [selectedId, setSelectedId] = React.useState<string | number | undefined>("shibuya88");
  const [compactDetailOpen, setCompactDetailOpen] = React.useState(false);
  const [portalContainer, setPortalContainer] = React.useState<HTMLDivElement | null>(null);
  const [isCompactLayout, setIsCompactLayout] = React.useState(false);
  const selectedRoute = routes.find((route) => route.id === selectedId);
  const selectedDetail = selectedId ? routeDetails[selectedId as keyof typeof routeDetails] : undefined;

  React.useEffect(() => {
    if (!portalContainer) return;

    const updateLayout = () => {
      setIsCompactLayout(portalContainer.getBoundingClientRect().width < 720);
    };

    updateLayout();
    const observer = new ResizeObserver(updateLayout);
    observer.observe(portalContainer);
    return () => observer.disconnect();
  }, [portalContainer]);

  React.useEffect(() => {
    if (!isCompactLayout) {
      setCompactDetailOpen(false);
    }
  }, [isCompactLayout]);

  const items = routes.map((route) => ({
    ...route,
    onSelect: () => {
      setSelectedId(route.id);
      setCompactDetailOpen(isCompactLayout);
    },
  }));

  return (
    <div
      ref={setPortalContainer}
      className={
        isCompactLayout
          ? "relative grid w-full max-w-5xl grid-cols-[minmax(0,1fr)] gap-4 overflow-visible rounded-lg border bg-card p-4"
          : "relative grid w-full max-w-5xl grid-cols-[minmax(0,1fr)] gap-4 overflow-hidden rounded-lg border bg-card p-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,320px)]"
      }
    >
      <Leaderboard
        items={items}
        label="系統別 事故率ランキング"
        selectedId={selectedId}
        formatValue={(value) => value === 0 ? "0件" : value.toFixed(1) + "件"}
        deltaTones={{ positive: "destructive", negative: "success" }}
      />
      <Sheet
        open={isCompactLayout && compactDetailOpen && Boolean(selectedRoute && selectedDetail)}
        onOpenChange={setCompactDetailOpen}
      >
        <SheetContent
          side="bottom"
          portalContainer={portalContainer}
          closeLabel="閉じる"
          overlayClassName="bg-overlay/45"
          className="max-h-[calc(100%-2rem)] overflow-y-auto rounded-t-lg p-4"
        >
          <SheetHeader className="pr-8 text-left">
            <SheetTitle>ランキング詳細</SheetTitle>
            {selectedRoute ? (
              <SheetDescription>{selectedRoute.label} / {selectedRoute.sublabel}</SheetDescription>
            ) : null}
          </SheetHeader>
          {selectedRoute && selectedDetail ? (
            <div className="mt-4">
              <div className="min-w-0 rounded-lg border border-l-4 border-primary bg-background p-3 text-sm shadow-sm">
                <div className="flex items-start justify-between gap-3">
                  <div className="min-w-0">
                    <p className="truncate text-base font-semibold text-foreground">{selectedRoute.label}</p>
                    <p className="mt-1 text-xs text-muted-foreground">{selectedRoute.sublabel}</p>
                  </div>
                  <Badge
                    variant="outline"
                    className="shrink-0 border-border bg-background text-foreground shadow-sm"
                  >{selectedDetail.status}</Badge>
                </div>
                <dl className="mt-3 grid gap-2 text-xs">
                  <div>
                    <dt className="text-muted-foreground">指標値</dt>
                    <dd className="font-medium text-foreground">
                      {selectedRoute.value === 0 ? "0件" : selectedRoute.value.toFixed(1) + "件"}
                    </dd>
                  </div>
                  <div>
                    <dt className="text-muted-foreground">前月比</dt>
                    <dd className="font-medium text-foreground">
                      {selectedRoute.delta && selectedRoute.delta > 0 ? "+" : ""}{selectedRoute.delta?.toFixed(1)}
                    </dd>
                  </div>
                  <div>
                    <dt className="text-muted-foreground">対象範囲</dt>
                    <dd className="font-medium text-foreground">
                      {selectedDetail.scope}
                    </dd>
                  </div>
                  <div>
                    <dt className="text-muted-foreground">担当</dt>
                    <dd className="font-medium text-foreground">
                      {selectedDetail.owner}
                    </dd>
                  </div>
                  <div>
                    <dt className="text-muted-foreground">集計期間</dt>
                    <dd className="font-medium text-foreground">
                      {selectedDetail.period}
                    </dd>
                  </div>
                  <div>
                    <dt className="text-muted-foreground">次の対応</dt>
                    <dd className="font-medium text-foreground">
                      {selectedDetail.next}
                    </dd>
                  </div>
                </dl>
              </div>
            </div>
          ) : null}
        </SheetContent>
      </Sheet>
      {!isCompactLayout && selectedRoute && selectedDetail ? (
        <aside className="hidden min-w-0 rounded-lg border bg-muted/20 p-3 lg:block">
          <h3 className="mb-3 text-sm font-semibold text-foreground">ランキング詳細</h3>
          <div className="min-w-0 rounded-lg border border-l-4 border-primary bg-background p-3 text-sm shadow-sm">
            <div className="flex items-start justify-between gap-3">
              <div className="min-w-0">
                <p className="truncate text-base font-semibold text-foreground">{selectedRoute.label}</p>
                <p className="mt-1 text-xs text-muted-foreground">{selectedRoute.sublabel}</p>
              </div>
              <Badge
                variant="outline"
                className="shrink-0 border-border bg-background text-foreground shadow-sm"
              >{selectedDetail.status}</Badge>
            </div>
            <dl className="mt-3 grid gap-2 text-xs">
              <div>
                <dt className="text-muted-foreground">指標値</dt>
                <dd className="font-medium text-foreground">
                  {selectedRoute.value === 0 ? "0件" : selectedRoute.value.toFixed(1) + "件"}
                </dd>
              </div>
              <div>
                <dt className="text-muted-foreground">前月比</dt>
                <dd className="font-medium text-foreground">
                  {selectedRoute.delta && selectedRoute.delta > 0 ? "+" : ""}{selectedRoute.delta?.toFixed(1)}
                </dd>
              </div>
              <div>
                <dt className="text-muted-foreground">対象範囲</dt>
                <dd className="font-medium text-foreground">
                  {selectedDetail.scope}
                </dd>
              </div>
              <div>
                <dt className="text-muted-foreground">担当</dt>
                <dd className="font-medium text-foreground">
                  {selectedDetail.owner}
                </dd>
              </div>
              <div>
                <dt className="text-muted-foreground">集計期間</dt>
                <dd className="font-medium text-foreground">
                  {selectedDetail.period}
                </dd>
              </div>
              <div>
                <dt className="text-muted-foreground">次の対応</dt>
                <dd className="font-medium text-foreground">
                  {selectedDetail.next}
                </dd>
              </div>
            </dl>
          </div>
        </aside>
      ) : null}
    </div>
  );
}

使用コンポーネント