diff --git a/src/components/admin/EmptyState.tsx b/src/components/admin/EmptyState.tsx
new file mode 100644
index 0000000..65ea6b7
--- /dev/null
+++ b/src/components/admin/EmptyState.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import Link from "next/link";
+import type { ReactNode } from "react";
+import { Inbox } from "lucide-react";
+
+type EmptyStateAction = {
+ /** Visible label for the call-to-action. */
+ label: string;
+ /** If provided, renders a Next.js Link. Mutually exclusive with onClick. */
+ href?: string;
+ /** If provided, renders a button. */
+ onClick?: () => void;
+};
+
+type EmptyStateProps = {
+ /** Optional icon element (typically a lucide-react icon). */
+ icon?: ReactNode;
+ /** Required heading explaining the empty state. */
+ title: string;
+ /** Optional supporting copy shown below the title. */
+ description?: string;
+ /** Optional single primary action. */
+ action?: EmptyStateAction;
+ /** Extra class names appended to the root. */
+ className?: string;
+};
+
+/**
+ * EmptyState — generic empty state for admin pages.
+ *
+ * Centers an icon + title + description and (optionally) a single primary CTA
+ * styled with `.ha-btn-primary`. Uses the new admin design tokens for
+ * colors and Fraunces for the title.
+ */
+export default function EmptyState({
+ icon,
+ title,
+ description,
+ action,
+ className = "",
+}: EmptyStateProps) {
+ return (
+
+
+ {icon ?? }
+
+
+
+ {title}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {action && (
+
+
+
+ )}
+
+ );
+}
+
+function DefaultEmptyIcon() {
+ return ;
+}
+
+function EmptyStateActionButton({ action }: { action: EmptyStateAction }) {
+ // Pick Link vs button based on whether an href is provided. Both render
+ // with the same .ha-btn-primary class so the CTA is visually consistent.
+ if (action.href) {
+ return (
+
+ {action.label}
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/src/components/admin/KPIStat.tsx b/src/components/admin/KPIStat.tsx
new file mode 100644
index 0000000..38bc30e
--- /dev/null
+++ b/src/components/admin/KPIStat.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react";
+
+type KPITone = "default" | "primary" | "accent" | "warning" | "danger";
+
+type KPIStatProps = {
+ /** Eyebrow label above the value (e.g. "Today's Orders") */
+ label: string;
+ /** The primary value to display. Accepts a node so callers can pass formatted strings, JSX, etc. */
+ value: ReactNode;
+ /** Optional icon element (typically a lucide-react icon) shown in the leading tile. */
+ icon?: ReactNode;
+ /** Controls the icon tile's background + icon color. Defaults to "default". */
+ tone?: KPITone;
+ /** Optional trend indicator rendered next to the value. */
+ trend?: { value: string; direction: "up" | "down" | "flat" };
+ /** Extra class names appended to the card. */
+ className?: string;
+};
+
+// Tone -> icon-tile background / icon color. Pulled from --admin-* tokens
+// introduced in the Phase 1 palette unification. No hardcoded hex values.
+const toneStyles: Record = {
+ default: { bg: "var(--admin-bg-subtle)", fg: "var(--admin-text-secondary)" },
+ primary: { bg: "var(--admin-primary-soft)", fg: "var(--admin-primary)" },
+ accent: { bg: "var(--admin-accent-soft)", fg: "var(--admin-accent)" },
+ warning: { bg: "var(--admin-warning-soft)", fg: "var(--admin-warning)" },
+ danger: { bg: "var(--admin-danger-soft)", fg: "var(--admin-danger)" },
+};
+
+// Trend direction -> icon + color. "up" reads as positive (primary),
+// "down" as negative (danger), "flat" as neutral.
+const trendStyles: Record<"up" | "down" | "flat", { Icon: typeof ArrowUp; color: string }> = {
+ up: { Icon: ArrowUp, color: "var(--admin-primary)" },
+ down: { Icon: ArrowDown, color: "var(--admin-danger)" },
+ flat: { Icon: ArrowRight, color: "var(--admin-text-muted)" },
+};
+
+/**
+ * KPIStat — compact metric card for admin surfaces.
+ *
+ * Extracted from the inline pattern previously duplicated four times in
+ * `DashboardClient.tsx`. Reuses the `.admin-stat-card*` classes from
+ * `admin-design-system.css` for the shell, then applies tone-aware
+ * background/foreground via inline `var(--admin-*)` styles.
+ */
+export default function KPIStat({
+ label,
+ value,
+ icon,
+ tone = "default",
+ trend,
+ className = "",
+}: KPIStatProps) {
+ const { bg, fg } = toneStyles[tone];
+
+ return (
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {label}
+
+ {value}
+ {trend && }
+
+
+
+
+ );
+}
+
+function TrendIndicator({
+ trend,
+}: {
+ trend: { value: string; direction: "up" | "down" | "flat" };
+}) {
+ const { Icon, color } = trendStyles[trend.direction];
+ return (
+
+
+ {trend.value}
+
+ );
+}
diff --git a/src/components/admin/LoadingState.tsx b/src/components/admin/LoadingState.tsx
new file mode 100644
index 0000000..e0e15a1
--- /dev/null
+++ b/src/components/admin/LoadingState.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+type LoadingStateProps = {
+ /** Optional eyebrow text rendered above the skeleton (e.g. "Loading orders..."). */
+ label?: string;
+ /** Number of skeleton rows to show. Defaults to 3. */
+ rows?: number;
+ /** Extra class names appended to the wrapper. */
+ className?: string;
+};
+
+/**
+ * LoadingState — generic skeleton for admin list/card surfaces.
+ *
+ * Renders an optional centered "Loading…" eyebrow above `rows` skeleton
+ * blocks, each composed of a label-sized bar and a value-sized bar. The
+ * shimmer comes from the shared `.ha-skeleton` class in
+ * `admin-design-system.css` so motion / reduced-motion stay consistent
+ * with the rest of the admin shell.
+ */
+export default function LoadingState({
+ label,
+ rows = 3,
+ className = "",
+}: LoadingStateProps) {
+ const safeRows = Math.max(1, Math.floor(rows));
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ {Array.from({ length: safeRows }).map((_, i) => (
+ // Index is fine here — the list shape is stable per render.
+ // eslint-disable-next-line react/no-array-index-key
+
+ ))}
+